Local changes before sync

Dieser Commit ist enthalten in:
2025-06-28 20:41:24 +00:00
Ursprung 972401cce9
Commit 3a75523384
1499 geänderte Dateien mit 44121 neuen und 18 gelöschten Zeilen

33
v2_adminpanel/Dockerfile Normale Datei
Datei anzeigen

@@ -0,0 +1,33 @@
FROM python:3.11-slim
# Locale für deutsche Sprache und UTF-8 setzen
ENV LANG=de_DE.UTF-8
ENV LC_ALL=de_DE.UTF-8
ENV PYTHONIOENCODING=utf-8
# Zeitzone auf Europe/Berlin setzen
ENV TZ=Europe/Berlin
WORKDIR /app
# System-Dependencies inkl. PostgreSQL-Tools installieren
RUN apt-get update && apt-get install -y \
locales \
postgresql-client \
tzdata \
&& sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \
&& locale-gen \
&& update-locale LANG=de_DE.UTF-8 \
&& ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \
&& echo "Europe/Berlin" > /etc/timezone \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]

Binäre Datei nicht angezeigt.

168
v2_adminpanel/app.py Normale Datei
Datei anzeigen

@@ -0,0 +1,168 @@
import os
import sys
import logging
from datetime import datetime
# 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, session
from flask_session import Session
from werkzeug.middleware.proxy_fix import ProxyFix
from apscheduler.schedulers.background import BackgroundScheduler
from prometheus_flask_exporter import PrometheusMetrics
# Import our configuration and utilities
import config
from utils.backup import create_backup
# Import error handling system
from core.error_handlers import init_error_handlers
from core.logging_config import setup_logging
from core.monitoring import init_monitoring
from middleware.error_middleware import ErrorHandlingMiddleware
app = Flask(__name__)
# Initialize Prometheus metrics
metrics = PrometheusMetrics(app)
metrics.info('admin_panel_info', 'Admin Panel Information', version='1.0.0')
# 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
# Initialize error handling system
setup_logging(app)
init_error_handlers(app)
init_monitoring(app)
ErrorHandlingMiddleware(app)
# Logging konfigurieren
logging.basicConfig(level=logging.INFO)
# Initialize scheduler from scheduler module
from scheduler import init_scheduler
scheduler = init_scheduler()
# Import and register blueprints
try:
from routes.auth_routes import auth_bp
from routes.admin_routes import admin_bp
from routes.api_routes import api_bp
from routes.batch_routes import batch_bp
from routes.customer_routes import customer_bp
from routes.export_routes import export_bp
from routes.license_routes import license_bp
from routes.resource_routes import resource_bp
from routes.session_routes import session_bp
from routes.monitoring_routes import monitoring_bp
from leads import leads_bp
print("All blueprints imported successfully!")
except Exception as e:
print(f"Blueprint import error: {str(e)}")
import traceback
traceback.print_exc()
# Register all blueprints
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(api_bp)
app.register_blueprint(batch_bp)
app.register_blueprint(customer_bp)
app.register_blueprint(export_bp)
app.register_blueprint(license_bp)
app.register_blueprint(resource_bp)
app.register_blueprint(session_bp)
app.register_blueprint(monitoring_bp)
app.register_blueprint(leads_bp, url_prefix='/leads')
# Template filters
@app.template_filter('nl2br')
def nl2br_filter(s):
"""Convert newlines to <br> tags"""
return s.replace('\n', '<br>\n') if s else ''
# Debug routes to test
@app.route('/test-customers-licenses')
def test_route():
return "Test route works! If you see this, routing is working."
@app.route('/direct-customers-licenses')
def direct_customers_licenses():
"""Direct route without blueprint"""
try:
return render_template("customers_licenses.html", customers=[])
except Exception as e:
return f"Error: {str(e)}"
@app.route('/debug-routes')
def debug_routes():
"""Show all registered routes"""
routes = []
for rule in app.url_map.iter_rules():
routes.append(f"{rule.endpoint}: {rule.rule}")
return "<br>".join(sorted(routes))
# Scheduled backup job is now handled by scheduler module
# Error handlers are now managed by the error handling system in core/error_handlers.py
# Context processors
@app.context_processor
def inject_global_vars():
"""Inject global variables into all templates"""
return {
'current_year': datetime.now().year,
'app_version': '2.0.0',
'is_logged_in': session.get('logged_in', False),
'username': session.get('username', '')
}
# Simple test route that should always work
@app.route('/simple-test')
def simple_test():
return "Simple test works!"
@app.route('/test-db')
def test_db():
"""Test database connection"""
try:
import psycopg2
conn = psycopg2.connect(
host=os.getenv("POSTGRES_HOST", "postgres"),
port=os.getenv("POSTGRES_PORT", "5432"),
dbname=os.getenv("POSTGRES_DB"),
user=os.getenv("POSTGRES_USER"),
password=os.getenv("POSTGRES_PASSWORD")
)
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM customers")
count = cur.fetchone()[0]
cur.close()
conn.close()
return f"Database works! Customers count: {count}"
except Exception as e:
import traceback
return f"Database error: {str(e)}<br><pre>{traceback.format_exc()}</pre>"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)

Datei anzeigen

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""
Apply Lead Management Tables Migration
"""
import psycopg2
import os
from db import get_db_connection
def apply_migration():
"""Apply the lead tables migration"""
try:
# Read migration SQL
migration_file = os.path.join(os.path.dirname(__file__),
'migrations', 'create_lead_tables.sql')
with open(migration_file, 'r') as f:
migration_sql = f.read()
# Connect and execute
with get_db_connection() as conn:
cur = conn.cursor()
print("Applying lead management tables migration...")
cur.execute(migration_sql)
# Verify tables were created
cur.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name LIKE 'lead_%'
ORDER BY table_name
""")
tables = cur.fetchall()
print(f"\nCreated {len(tables)} tables:")
for table in tables:
print(f" - {table[0]}")
cur.close()
print("\n✅ Migration completed successfully!")
except FileNotFoundError:
print(f"❌ Migration file not found: {migration_file}")
except psycopg2.Error as e:
print(f"❌ Database error: {e}")
except Exception as e:
print(f"❌ Unexpected error: {e}")
if __name__ == "__main__":
apply_migration()

Datei anzeigen

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
Apply the license_heartbeats table migration
"""
import os
import psycopg2
import logging
from datetime import datetime
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_db_connection():
"""Get database connection"""
return psycopg2.connect(
host=os.environ.get('POSTGRES_HOST', 'postgres'),
database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'),
user=os.environ.get('POSTGRES_USER', 'postgres'),
password=os.environ.get('POSTGRES_PASSWORD', 'postgres')
)
def apply_migration():
"""Apply the license_heartbeats migration"""
conn = None
try:
logger.info("Connecting to database...")
conn = get_db_connection()
cur = conn.cursor()
# Read migration file
migration_file = os.path.join(os.path.dirname(__file__), 'migrations', 'create_license_heartbeats_table.sql')
logger.info(f"Reading migration file: {migration_file}")
with open(migration_file, 'r') as f:
migration_sql = f.read()
# Execute migration
logger.info("Executing migration...")
cur.execute(migration_sql)
# Verify table was created
cur.execute("""
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_name = 'license_heartbeats'
)
""")
if cur.fetchone()[0]:
logger.info("✓ license_heartbeats table created successfully!")
# Check partitions
cur.execute("""
SELECT tablename
FROM pg_tables
WHERE tablename LIKE 'license_heartbeats_%'
ORDER BY tablename
""")
partitions = cur.fetchall()
logger.info(f"✓ Created {len(partitions)} partitions:")
for partition in partitions:
logger.info(f" - {partition[0]}")
else:
logger.error("✗ Failed to create license_heartbeats table")
return False
conn.commit()
logger.info("✓ Migration completed successfully!")
return True
except Exception as e:
logger.error(f"✗ Migration failed: {str(e)}")
if conn:
conn.rollback()
return False
finally:
if conn:
cur.close()
conn.close()
if __name__ == "__main__":
logger.info("=== Applying license_heartbeats migration ===")
logger.info(f"Timestamp: {datetime.now()}")
if apply_migration():
logger.info("=== Migration successful! ===")
else:
logger.error("=== Migration failed! ===")
exit(1)

Datei anzeigen

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
Apply partition migration for license_heartbeats table.
This script creates missing partitions for the current and future months.
"""
import psycopg2
import os
import sys
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
def get_db_connection():
"""Get database connection"""
return psycopg2.connect(
host=os.environ.get('POSTGRES_HOST', 'postgres'),
database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'),
user=os.environ.get('POSTGRES_USER', 'postgres'),
password=os.environ.get('POSTGRES_PASSWORD', 'postgres')
)
def create_partition(cursor, year, month):
"""Create a partition for the given year and month"""
partition_name = f"license_heartbeats_{year}_{month:02d}"
start_date = f"{year}-{month:02d}-01"
# Calculate end date (first day of next month)
if month == 12:
end_date = f"{year + 1}-01-01"
else:
end_date = f"{year}-{month + 1:02d}-01"
# Check if partition already exists
cursor.execute("""
SELECT EXISTS (
SELECT 1
FROM pg_tables
WHERE tablename = %s
)
""", (partition_name,))
exists = cursor.fetchone()[0]
if not exists:
try:
cursor.execute(f"""
CREATE TABLE {partition_name} PARTITION OF license_heartbeats
FOR VALUES FROM ('{start_date}') TO ('{end_date}')
""")
print(f"✓ Created partition {partition_name}")
return True
except Exception as e:
print(f"✗ Error creating partition {partition_name}: {e}")
return False
else:
print(f"- Partition {partition_name} already exists")
return False
def main():
"""Main function"""
print("Applying license_heartbeats partition migration...")
print("-" * 50)
try:
# Connect to database
conn = get_db_connection()
cursor = conn.cursor()
# Check if license_heartbeats table exists
cursor.execute("""
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_name = 'license_heartbeats'
)
""")
if not cursor.fetchone()[0]:
print("✗ Error: license_heartbeats table does not exist!")
print(" Please run the init.sql script first.")
return 1
# Get current date
current_date = datetime.now()
partitions_created = 0
# Create partitions for the next 6 months (including current month)
for i in range(7):
target_date = current_date + relativedelta(months=i)
if create_partition(cursor, target_date.year, target_date.month):
partitions_created += 1
# Commit changes
conn.commit()
print("-" * 50)
print(f"✓ Migration complete. Created {partitions_created} new partitions.")
# List all partitions
cursor.execute("""
SELECT tablename
FROM pg_tables
WHERE tablename LIKE 'license_heartbeats_%'
ORDER BY tablename
""")
partitions = cursor.fetchall()
print(f"\nTotal partitions: {len(partitions)}")
for partition in partitions:
print(f" - {partition[0]}")
cursor.close()
conn.close()
return 0
except Exception as e:
print(f"✗ Error: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())

Datei anzeigen

@@ -0,0 +1 @@
# Auth module initialization

Datei anzeigen

@@ -0,0 +1,44 @@
from functools import wraps
from flask import session, redirect, url_for, flash, request
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import logging
from utils.audit import log_audit
logger = logging.getLogger(__name__)
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'logged_in' not in session:
return redirect(url_for('auth.login'))
# Check if session has expired
if 'last_activity' in session:
last_activity = datetime.fromisoformat(session['last_activity'])
time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity
# Debug logging
logger.info(f"Session check for {session.get('username', 'unknown')}: "
f"Last activity: {last_activity}, "
f"Time since: {time_since_activity.total_seconds()} seconds")
if time_since_activity > timedelta(minutes=5):
# Session expired - Logout
username = session.get('username', 'unbekannt')
logger.info(f"Session timeout for user {username} - auto logout")
# Audit log for automatic logout (before session.clear()!)
try:
log_audit('AUTO_LOGOUT', 'session',
additional_info={'reason': 'Session timeout (5 minutes)', 'username': username})
except:
pass
session.clear()
flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning')
return redirect(url_for('auth.login'))
# Activity is NOT automatically updated
# Only on explicit user actions (done by heartbeat)
return f(*args, **kwargs)
return decorated_function

Datei anzeigen

@@ -0,0 +1,11 @@
import bcrypt
def hash_password(password):
"""Hash a password using bcrypt"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def verify_password(password, hashed):
"""Verify a password against its hash"""
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))

Datei anzeigen

@@ -0,0 +1,124 @@
import random
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import request
from db import execute_query, get_db_connection, get_db_cursor
from config import FAIL_MESSAGES, MAX_LOGIN_ATTEMPTS, BLOCK_DURATION_HOURS, EMAIL_ENABLED
from utils.audit import log_audit
from utils.network import get_client_ip
logger = logging.getLogger(__name__)
def check_ip_blocked(ip_address):
"""Check if an IP address is blocked"""
result = execute_query(
"""
SELECT blocked_until FROM login_attempts
WHERE ip_address = %s AND blocked_until IS NOT NULL
""",
(ip_address,),
fetch_one=True
)
if result and result[0]:
if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None):
return True, result[0]
return False, None
def record_failed_attempt(ip_address, username):
"""Record a failed login attempt"""
# Random error message
error_message = random.choice(FAIL_MESSAGES)
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
try:
# Check if IP already exists
cur.execute("""
SELECT attempt_count FROM login_attempts
WHERE ip_address = %s
""", (ip_address,))
result = cur.fetchone()
if result:
# Update existing entry
new_count = result[0] + 1
blocked_until = None
if new_count >= MAX_LOGIN_ATTEMPTS:
blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS)
# Email notification (if enabled)
if EMAIL_ENABLED:
send_security_alert_email(ip_address, username, new_count)
cur.execute("""
UPDATE login_attempts
SET attempt_count = %s,
last_attempt = CURRENT_TIMESTAMP,
blocked_until = %s,
last_username_tried = %s,
last_error_message = %s
WHERE ip_address = %s
""", (new_count, blocked_until, username, error_message, ip_address))
else:
# Create new entry
cur.execute("""
INSERT INTO login_attempts
(ip_address, attempt_count, last_username_tried, last_error_message)
VALUES (%s, 1, %s, %s)
""", (ip_address, username, error_message))
conn.commit()
# Audit log
log_audit('LOGIN_FAILED', 'user',
additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}")
except Exception as e:
logger.error(f"Rate limiting error: {e}")
conn.rollback()
return error_message
def reset_login_attempts(ip_address):
"""Reset login attempts for an IP"""
execute_query(
"DELETE FROM login_attempts WHERE ip_address = %s",
(ip_address,)
)
def get_login_attempts(ip_address):
"""Get the number of login attempts for an IP"""
result = execute_query(
"SELECT attempt_count FROM login_attempts WHERE ip_address = %s",
(ip_address,),
fetch_one=True
)
return result[0] if result else 0
def send_security_alert_email(ip_address, username, attempt_count):
"""Send a security alert email"""
subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche"
body = f"""
WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt!
IP-Adresse: {ip_address}
Versuchter Benutzername: {username}
Anzahl Versuche: {attempt_count}
Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')}
Die IP-Adresse wurde für 24 Stunden gesperrt.
Dies ist eine automatische Nachricht vom v2-Docker Admin Panel.
"""
# TODO: Email sending implementation when SMTP is configured
logger.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}")
print(f"E-Mail würde gesendet: {subject}")

Datei anzeigen

@@ -0,0 +1,57 @@
import pyotp
import qrcode
import random
import string
import hashlib
from io import BytesIO
import base64
def generate_totp_secret():
"""Generate a new TOTP secret"""
return pyotp.random_base32()
def generate_qr_code(username, totp_secret):
"""Generate QR code for TOTP setup"""
totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
name=username,
issuer_name='V2 Admin Panel'
)
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(totp_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return base64.b64encode(buf.getvalue()).decode()
def verify_totp(totp_secret, token):
"""Verify a TOTP token"""
totp = pyotp.TOTP(totp_secret)
return totp.verify(token, valid_window=1)
def generate_backup_codes(count=8):
"""Generate backup codes for 2FA recovery"""
codes = []
for _ in range(count):
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
codes.append(code)
return codes
def hash_backup_code(code):
"""Hash a backup code for storage"""
return hashlib.sha256(code.encode()).hexdigest()
def verify_backup_code(code, hashed_codes):
"""Verify a backup code against stored hashes"""
code_hash = hashlib.sha256(code.encode()).hexdigest()
return code_hash in hashed_codes

70
v2_adminpanel/config.py Normale Datei
Datei anzeigen

@@ -0,0 +1,70 @@
import os
from datetime import timedelta
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# Flask Configuration
SECRET_KEY = os.urandom(24)
SESSION_TYPE = 'filesystem'
JSON_AS_ASCII = False
JSONIFY_MIMETYPE = 'application/json; charset=utf-8'
PERMANENT_SESSION_LIFETIME = timedelta(minutes=5)
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "true").lower() == "true" # Default True for HTTPS
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_NAME = 'admin_session'
SESSION_REFRESH_EACH_REQUEST = False
# Database Configuration
DATABASE_CONFIG = {
'host': os.getenv("POSTGRES_HOST", "postgres"),
'port': os.getenv("POSTGRES_PORT", "5432"),
'dbname': os.getenv("POSTGRES_DB"),
'user': os.getenv("POSTGRES_USER"),
'password': os.getenv("POSTGRES_PASSWORD"),
'options': '-c client_encoding=UTF8'
}
# Backup Configuration
BACKUP_DIR = Path("/app/backups")
BACKUP_DIR.mkdir(exist_ok=True)
BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY")
# Rate Limiting Configuration
FAIL_MESSAGES = [
"NOPE!",
"ACCESS DENIED, TRY HARDER",
"WRONG! 🚫",
"COMPUTER SAYS NO",
"YOU FAILED"
]
MAX_LOGIN_ATTEMPTS = 5
BLOCK_DURATION_HOURS = 24
CAPTCHA_AFTER_ATTEMPTS = 2
# reCAPTCHA Configuration
RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY')
RECAPTCHA_SECRET_KEY = os.getenv('RECAPTCHA_SECRET_KEY')
# Email Configuration
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "false").lower() == "true"
# Admin Users (for backward compatibility)
ADMIN_USERS = {
os.getenv("ADMIN1_USERNAME"): os.getenv("ADMIN1_PASSWORD"),
os.getenv("ADMIN2_USERNAME"): os.getenv("ADMIN2_PASSWORD")
}
# Scheduler Configuration
SCHEDULER_CONFIG = {
'backup_hour': 3,
'backup_minute': 0
}
# Logging Configuration
LOGGING_CONFIG = {
'level': 'INFO',
'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
}

Datei anzeigen

Datei anzeigen

@@ -0,0 +1,273 @@
import logging
import traceback
from functools import wraps
from typing import Optional, Dict, Any, Callable, Union
from flask import (
Flask, request, jsonify, render_template, flash, redirect,
url_for, current_app, g
)
from werkzeug.exceptions import HTTPException
import psycopg2
from .exceptions import (
BaseApplicationException, DatabaseException, ValidationException,
AuthenticationException, ResourceException, QueryError,
ConnectionError, TransactionError
)
logger = logging.getLogger(__name__)
def init_error_handlers(app: Flask) -> None:
@app.before_request
def before_request():
g.request_id = request.headers.get('X-Request-ID',
BaseApplicationException('', '', 0).request_id)
@app.errorhandler(BaseApplicationException)
def handle_application_error(error: BaseApplicationException):
return _handle_error(error)
@app.errorhandler(HTTPException)
def handle_http_error(error: HTTPException):
return _handle_error(error)
@app.errorhandler(psycopg2.Error)
def handle_database_error(error: psycopg2.Error):
db_exception = _convert_psycopg2_error(error)
return _handle_error(db_exception)
@app.errorhandler(Exception)
def handle_unexpected_error(error: Exception):
logger.error(
f"Unexpected error: {str(error)}",
exc_info=True,
extra={'request_id': getattr(g, 'request_id', 'unknown')}
)
if current_app.debug:
raise
generic_error = BaseApplicationException(
message="An unexpected error occurred",
code="INTERNAL_ERROR",
status_code=500,
user_message="Ein unerwarteter Fehler ist aufgetreten"
)
return _handle_error(generic_error)
def _handle_error(error: Union[BaseApplicationException, HTTPException, Exception]) -> tuple:
if isinstance(error, HTTPException):
status_code = error.code
error_dict = {
'error': {
'code': error.name.upper().replace(' ', '_'),
'message': error.description or str(error),
'request_id': getattr(g, 'request_id', 'unknown')
}
}
user_message = error.description or str(error)
elif isinstance(error, BaseApplicationException):
status_code = error.status_code
error_dict = error.to_dict(include_details=current_app.debug)
error_dict['error']['request_id'] = getattr(g, 'request_id', error.request_id)
user_message = error.user_message
logger.error(
f"{error.__class__.__name__}: {error.message}",
extra={
'error_code': error.code,
'details': error.details,
'request_id': error_dict['error']['request_id']
}
)
else:
status_code = 500
error_dict = {
'error': {
'code': 'INTERNAL_ERROR',
'message': 'An internal error occurred',
'request_id': getattr(g, 'request_id', 'unknown')
}
}
user_message = "Ein interner Fehler ist aufgetreten"
if _is_json_request():
return jsonify(error_dict), status_code
else:
if status_code == 404:
return render_template('404.html'), 404
elif status_code >= 500:
return render_template('500.html', error=user_message), status_code
else:
flash(user_message, 'error')
return render_template('error.html',
error=user_message,
error_code=error_dict['error']['code'],
request_id=error_dict['error']['request_id']), status_code
def _convert_psycopg2_error(error: psycopg2.Error) -> DatabaseException:
error_code = getattr(error, 'pgcode', None)
error_message = str(error).split('\n')[0]
if isinstance(error, psycopg2.OperationalError):
return ConnectionError(
message=f"Database connection failed: {error_message}",
host=None
)
elif isinstance(error, psycopg2.IntegrityError):
if error_code == '23505':
return ValidationException(
message="Duplicate entry violation",
details={'constraint': error_message},
user_message="Dieser Eintrag existiert bereits"
)
elif error_code == '23503':
return ValidationException(
message="Foreign key violation",
details={'constraint': error_message},
user_message="Referenzierte Daten existieren nicht"
)
else:
return ValidationException(
message="Data integrity violation",
details={'error_code': error_code},
user_message="Datenintegritätsfehler"
)
elif isinstance(error, psycopg2.DataError):
return ValidationException(
message="Invalid data format",
details={'error': error_message},
user_message="Ungültiges Datenformat"
)
else:
return QueryError(
message=error_message,
query="[query hidden for security]",
error_code=error_code
)
def _is_json_request() -> bool:
return (request.is_json or
request.path.startswith('/api/') or
request.accept_mimetypes.best == 'application/json')
def handle_errors(
catch: tuple = (Exception,),
message: str = "Operation failed",
user_message: Optional[str] = None,
redirect_to: Optional[str] = None
) -> Callable:
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except catch as e:
if isinstance(e, BaseApplicationException):
raise
logger.error(
f"Error in {func.__name__}: {str(e)}",
exc_info=True,
extra={'request_id': getattr(g, 'request_id', 'unknown')}
)
if _is_json_request():
return jsonify({
'error': {
'code': 'OPERATION_FAILED',
'message': user_message or message,
'request_id': getattr(g, 'request_id', 'unknown')
}
}), 500
else:
flash(user_message or message, 'error')
if redirect_to:
return redirect(url_for(redirect_to))
return redirect(request.referrer or url_for('admin.dashboard'))
return wrapper
return decorator
def validate_request(
required_fields: Optional[Dict[str, type]] = None,
optional_fields: Optional[Dict[str, type]] = None
) -> Callable:
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
data = request.get_json() if request.is_json else request.form
if required_fields:
for field, expected_type in required_fields.items():
if field not in data:
raise ValidationException(
message=f"Missing required field: {field}",
field=field,
user_message=f"Pflichtfeld fehlt: {field}"
)
try:
if expected_type != str:
if expected_type == int:
int(data[field])
elif expected_type == float:
float(data[field])
elif expected_type == bool:
if isinstance(data[field], str):
if data[field].lower() not in ['true', 'false', '1', '0']:
raise ValueError
except (ValueError, TypeError):
raise ValidationException(
message=f"Invalid type for field {field}",
field=field,
value=data[field],
details={'expected_type': expected_type.__name__},
user_message=f"Ungültiger Typ für Feld {field}"
)
return func(*args, **kwargs)
return wrapper
return decorator
class ErrorContext:
def __init__(
self,
operation: str,
resource_type: Optional[str] = None,
resource_id: Optional[Any] = None
):
self.operation = operation
self.resource_type = resource_type
self.resource_id = resource_id
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_val is None:
return False
if isinstance(exc_val, BaseApplicationException):
return False
logger.error(
f"Error during {self.operation}",
exc_info=True,
extra={
'operation': self.operation,
'resource_type': self.resource_type,
'resource_id': self.resource_id,
'request_id': getattr(g, 'request_id', 'unknown')
}
)
return False

Datei anzeigen

@@ -0,0 +1,356 @@
import uuid
from typing import Optional, Dict, Any
from datetime import datetime
class BaseApplicationException(Exception):
def __init__(
self,
message: str,
code: str,
status_code: int = 500,
details: Optional[Dict[str, Any]] = None,
user_message: Optional[str] = None
):
super().__init__(message)
self.message = message
self.code = code
self.status_code = status_code
self.details = details or {}
self.user_message = user_message or message
self.timestamp = datetime.utcnow()
self.request_id = str(uuid.uuid4())
def to_dict(self, include_details: bool = False) -> Dict[str, Any]:
result = {
'error': {
'code': self.code,
'message': self.user_message,
'timestamp': self.timestamp.isoformat(),
'request_id': self.request_id
}
}
if include_details and self.details:
result['error']['details'] = self.details
return result
class ValidationException(BaseApplicationException):
def __init__(
self,
message: str,
field: Optional[str] = None,
value: Any = None,
details: Optional[Dict[str, Any]] = None,
user_message: Optional[str] = None
):
details = details or {}
if field:
details['field'] = field
if value is not None:
details['value'] = str(value)
super().__init__(
message=message,
code='VALIDATION_ERROR',
status_code=400,
details=details,
user_message=user_message or "Ungültige Eingabe"
)
class InputValidationError(ValidationException):
def __init__(
self,
field: str,
message: str,
value: Any = None,
expected_type: Optional[str] = None
):
details = {'expected_type': expected_type} if expected_type else None
super().__init__(
message=f"Invalid input for field '{field}': {message}",
field=field,
value=value,
details=details,
user_message=f"Ungültiger Wert für Feld '{field}'"
)
class BusinessRuleViolation(ValidationException):
def __init__(
self,
rule: str,
message: str,
context: Optional[Dict[str, Any]] = None
):
super().__init__(
message=message,
details={'rule': rule, 'context': context or {}},
user_message="Geschäftsregel verletzt"
)
class DataIntegrityError(ValidationException):
def __init__(
self,
entity: str,
constraint: str,
message: str,
details: Optional[Dict[str, Any]] = None
):
details = details or {}
details.update({'entity': entity, 'constraint': constraint})
super().__init__(
message=message,
details=details,
user_message="Datenintegritätsfehler"
)
class AuthenticationException(BaseApplicationException):
def __init__(
self,
message: str,
details: Optional[Dict[str, Any]] = None,
user_message: Optional[str] = None
):
super().__init__(
message=message,
code='AUTHENTICATION_ERROR',
status_code=401,
details=details,
user_message=user_message or "Authentifizierung fehlgeschlagen"
)
class InvalidCredentialsError(AuthenticationException):
def __init__(self, username: Optional[str] = None):
details = {'username': username} if username else None
super().__init__(
message="Invalid username or password",
details=details,
user_message="Ungültiger Benutzername oder Passwort"
)
class SessionExpiredError(AuthenticationException):
def __init__(self, session_id: Optional[str] = None):
details = {'session_id': session_id} if session_id else None
super().__init__(
message="Session has expired",
details=details,
user_message="Ihre Sitzung ist abgelaufen"
)
class InsufficientPermissionsError(AuthenticationException):
def __init__(
self,
required_permission: str,
user_permissions: Optional[list] = None
):
super().__init__(
message=f"User lacks required permission: {required_permission}",
details={
'required': required_permission,
'user_permissions': user_permissions or []
},
user_message="Unzureichende Berechtigungen für diese Aktion"
)
self.status_code = 403
class DatabaseException(BaseApplicationException):
def __init__(
self,
message: str,
query: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
user_message: Optional[str] = None
):
details = details or {}
if query:
details['query_hash'] = str(hash(query))
super().__init__(
message=message,
code='DATABASE_ERROR',
status_code=500,
details=details,
user_message=user_message or "Datenbankfehler aufgetreten"
)
class ConnectionError(DatabaseException):
def __init__(self, message: str, host: Optional[str] = None):
details = {'host': host} if host else None
super().__init__(
message=message,
details=details,
user_message="Datenbankverbindung fehlgeschlagen"
)
class QueryError(DatabaseException):
def __init__(
self,
message: str,
query: str,
error_code: Optional[str] = None
):
super().__init__(
message=message,
query=query,
details={'error_code': error_code} if error_code else None,
user_message="Datenbankabfrage fehlgeschlagen"
)
class TransactionError(DatabaseException):
def __init__(self, message: str, operation: str):
super().__init__(
message=message,
details={'operation': operation},
user_message="Transaktion fehlgeschlagen"
)
class ExternalServiceException(BaseApplicationException):
def __init__(
self,
service_name: str,
message: str,
details: Optional[Dict[str, Any]] = None,
user_message: Optional[str] = None
):
details = details or {}
details['service'] = service_name
super().__init__(
message=message,
code='EXTERNAL_SERVICE_ERROR',
status_code=502,
details=details,
user_message=user_message or f"Fehler beim Zugriff auf {service_name}"
)
class APIError(ExternalServiceException):
def __init__(
self,
service_name: str,
endpoint: str,
status_code: int,
message: str
):
super().__init__(
service_name=service_name,
message=message,
details={
'endpoint': endpoint,
'response_status': status_code
},
user_message=f"API-Fehler bei {service_name}"
)
class TimeoutError(ExternalServiceException):
def __init__(
self,
service_name: str,
timeout_seconds: int,
operation: str
):
super().__init__(
service_name=service_name,
message=f"Timeout after {timeout_seconds}s while {operation}",
details={
'timeout_seconds': timeout_seconds,
'operation': operation
},
user_message=f"Zeitüberschreitung bei {service_name}"
)
class ResourceException(BaseApplicationException):
def __init__(
self,
message: str,
resource_type: str,
resource_id: Any = None,
details: Optional[Dict[str, Any]] = None,
user_message: Optional[str] = None
):
details = details or {}
details.update({
'resource_type': resource_type,
'resource_id': str(resource_id) if resource_id else None
})
super().__init__(
message=message,
code='RESOURCE_ERROR',
status_code=404,
details=details,
user_message=user_message or "Ressourcenfehler"
)
class ResourceNotFoundError(ResourceException):
def __init__(
self,
resource_type: str,
resource_id: Any = None,
search_criteria: Optional[Dict[str, Any]] = None
):
details = {'search_criteria': search_criteria} if search_criteria else None
super().__init__(
message=f"{resource_type} not found",
resource_type=resource_type,
resource_id=resource_id,
details=details,
user_message=f"{resource_type} nicht gefunden"
)
self.status_code = 404
class ResourceConflictError(ResourceException):
def __init__(
self,
resource_type: str,
resource_id: Any,
conflict_reason: str
):
super().__init__(
message=f"Conflict with {resource_type}: {conflict_reason}",
resource_type=resource_type,
resource_id=resource_id,
details={'conflict_reason': conflict_reason},
user_message=f"Konflikt mit {resource_type}"
)
self.status_code = 409
class ResourceLimitExceeded(ResourceException):
def __init__(
self,
resource_type: str,
limit: int,
current: int,
requested: Optional[int] = None
):
details = {
'limit': limit,
'current': current,
'requested': requested
}
super().__init__(
message=f"{resource_type} limit exceeded: {current}/{limit}",
resource_type=resource_type,
details=details,
user_message=f"Limit für {resource_type} überschritten"
)
self.status_code = 429

Datei anzeigen

@@ -0,0 +1,190 @@
import logging
import logging.handlers
import json
import sys
import os
from datetime import datetime
from typing import Dict, Any
from flask import g, request, has_request_context
class StructuredFormatter(logging.Formatter):
def format(self, record):
log_data = {
'timestamp': datetime.utcnow().isoformat(),
'level': record.levelname,
'logger': record.name,
'message': record.getMessage(),
'module': record.module,
'function': record.funcName,
'line': record.lineno
}
if has_request_context():
log_data['request'] = {
'method': request.method,
'path': request.path,
'remote_addr': request.remote_addr,
'user_agent': request.user_agent.string,
'request_id': getattr(g, 'request_id', 'unknown')
}
if hasattr(record, 'request_id'):
log_data['request_id'] = record.request_id
if hasattr(record, 'error_code'):
log_data['error_code'] = record.error_code
if hasattr(record, 'details') and record.details:
log_data['details'] = self._sanitize_details(record.details)
if record.exc_info:
log_data['exception'] = {
'type': record.exc_info[0].__name__,
'message': str(record.exc_info[1]),
'traceback': self.formatException(record.exc_info)
}
return json.dumps(log_data, ensure_ascii=False)
def _sanitize_details(self, details: Dict[str, Any]) -> Dict[str, Any]:
sensitive_fields = {
'password', 'secret', 'token', 'api_key', 'authorization',
'credit_card', 'ssn', 'pin'
}
sanitized = {}
for key, value in details.items():
if any(field in key.lower() for field in sensitive_fields):
sanitized[key] = '[REDACTED]'
elif isinstance(value, dict):
sanitized[key] = self._sanitize_details(value)
else:
sanitized[key] = value
return sanitized
class ErrorLevelFilter(logging.Filter):
def __init__(self, min_level=logging.ERROR):
self.min_level = min_level
def filter(self, record):
return record.levelno >= self.min_level
def setup_logging(app):
log_level = os.getenv('LOG_LEVEL', 'INFO').upper()
log_dir = os.getenv('LOG_DIR', 'logs')
os.makedirs(log_dir, exist_ok=True)
root_logger = logging.getLogger()
root_logger.setLevel(getattr(logging, log_level))
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
if app.debug:
console_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
else:
console_formatter = StructuredFormatter()
console_handler.setFormatter(console_formatter)
root_logger.addHandler(console_handler)
app_log_handler = logging.handlers.RotatingFileHandler(
os.path.join(log_dir, 'app.log'),
maxBytes=10 * 1024 * 1024,
backupCount=10
)
app_log_handler.setLevel(logging.DEBUG)
app_log_handler.setFormatter(StructuredFormatter())
root_logger.addHandler(app_log_handler)
error_log_handler = logging.handlers.RotatingFileHandler(
os.path.join(log_dir, 'errors.log'),
maxBytes=10 * 1024 * 1024,
backupCount=10
)
error_log_handler.setLevel(logging.ERROR)
error_log_handler.setFormatter(StructuredFormatter())
error_log_handler.addFilter(ErrorLevelFilter())
root_logger.addHandler(error_log_handler)
security_logger = logging.getLogger('security')
security_handler = logging.handlers.RotatingFileHandler(
os.path.join(log_dir, 'security.log'),
maxBytes=10 * 1024 * 1024,
backupCount=20
)
security_handler.setFormatter(StructuredFormatter())
security_logger.addHandler(security_handler)
security_logger.setLevel(logging.INFO)
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.setLevel(logging.WARNING)
@app.before_request
def log_request_info():
logger = logging.getLogger('request')
logger.info(
'Request started',
extra={
'request_id': getattr(g, 'request_id', 'unknown'),
'details': {
'method': request.method,
'path': request.path,
'query_params': dict(request.args),
'content_length': request.content_length
}
}
)
@app.after_request
def log_response_info(response):
logger = logging.getLogger('request')
logger.info(
'Request completed',
extra={
'request_id': getattr(g, 'request_id', 'unknown'),
'details': {
'status_code': response.status_code,
'content_length': response.content_length or 0
}
}
)
return response
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)
def log_error(logger: logging.Logger, message: str, error: Exception = None, **kwargs):
extra = kwargs.copy()
if error:
extra['error_type'] = type(error).__name__
extra['error_message'] = str(error)
if hasattr(error, 'code'):
extra['error_code'] = error.code
if hasattr(error, 'details'):
extra['details'] = error.details
logger.error(message, exc_info=error, extra=extra)
def log_security_event(event_type: str, message: str, **details):
logger = logging.getLogger('security')
logger.warning(
f"Security Event: {event_type} - {message}",
extra={
'security_event': event_type,
'details': details,
'request_id': getattr(g, 'request_id', 'unknown') if has_request_context() else None
}
)

Datei anzeigen

@@ -0,0 +1,246 @@
import time
import functools
from typing import Dict, Any, Optional, List
from collections import defaultdict, deque
from datetime import datetime, timedelta
from threading import Lock
import logging
from prometheus_client import Counter, Histogram, Gauge, generate_latest
from flask import g, request, Response
from .exceptions import BaseApplicationException
from .logging_config import log_security_event
logger = logging.getLogger(__name__)
class ErrorMetrics:
def __init__(self):
self.error_counter = Counter(
'app_errors_total',
'Total number of errors',
['error_code', 'status_code', 'endpoint']
)
self.error_rate = Gauge(
'app_error_rate',
'Error rate per minute',
['error_code']
)
self.request_duration = Histogram(
'app_request_duration_seconds',
'Request duration in seconds',
['method', 'endpoint', 'status_code']
)
self.validation_errors = Counter(
'app_validation_errors_total',
'Total validation errors',
['field', 'endpoint']
)
self.auth_failures = Counter(
'app_auth_failures_total',
'Total authentication failures',
['reason', 'endpoint']
)
self.db_errors = Counter(
'app_database_errors_total',
'Total database errors',
['error_type', 'operation']
)
self._error_history = defaultdict(lambda: deque(maxlen=60))
self._lock = Lock()
def record_error(self, error: BaseApplicationException, endpoint: str = None):
endpoint = endpoint or request.endpoint or 'unknown'
self.error_counter.labels(
error_code=error.code,
status_code=error.status_code,
endpoint=endpoint
).inc()
with self._lock:
self._error_history[error.code].append(datetime.utcnow())
self._update_error_rates()
if error.code == 'VALIDATION_ERROR' and 'field' in error.details:
self.validation_errors.labels(
field=error.details['field'],
endpoint=endpoint
).inc()
elif error.code == 'AUTHENTICATION_ERROR':
reason = error.__class__.__name__
self.auth_failures.labels(
reason=reason,
endpoint=endpoint
).inc()
elif error.code == 'DATABASE_ERROR':
error_type = error.__class__.__name__
operation = error.details.get('operation', 'unknown')
self.db_errors.labels(
error_type=error_type,
operation=operation
).inc()
def _update_error_rates(self):
now = datetime.utcnow()
one_minute_ago = now - timedelta(minutes=1)
for error_code, timestamps in self._error_history.items():
recent_count = sum(1 for ts in timestamps if ts >= one_minute_ago)
self.error_rate.labels(error_code=error_code).set(recent_count)
class AlertManager:
def __init__(self):
self.alerts = []
self.alert_thresholds = {
'error_rate': 10,
'auth_failure_rate': 5,
'db_error_rate': 3,
'response_time_95th': 2.0
}
self._lock = Lock()
def check_alerts(self, metrics: ErrorMetrics):
new_alerts = []
for error_code, rate in self._get_current_error_rates(metrics).items():
if rate > self.alert_thresholds['error_rate']:
new_alerts.append({
'type': 'high_error_rate',
'severity': 'critical',
'error_code': error_code,
'rate': rate,
'threshold': self.alert_thresholds['error_rate'],
'message': f'High error rate for {error_code}: {rate}/min',
'timestamp': datetime.utcnow()
})
auth_failure_rate = self._get_auth_failure_rate(metrics)
if auth_failure_rate > self.alert_thresholds['auth_failure_rate']:
new_alerts.append({
'type': 'auth_failures',
'severity': 'warning',
'rate': auth_failure_rate,
'threshold': self.alert_thresholds['auth_failure_rate'],
'message': f'High authentication failure rate: {auth_failure_rate}/min',
'timestamp': datetime.utcnow()
})
log_security_event(
'HIGH_AUTH_FAILURE_RATE',
f'Authentication failure rate exceeded threshold',
rate=auth_failure_rate,
threshold=self.alert_thresholds['auth_failure_rate']
)
with self._lock:
self.alerts.extend(new_alerts)
self.alerts = [a for a in self.alerts
if a['timestamp'] > datetime.utcnow() - timedelta(hours=24)]
return new_alerts
def _get_current_error_rates(self, metrics: ErrorMetrics) -> Dict[str, float]:
rates = {}
with metrics._lock:
now = datetime.utcnow()
one_minute_ago = now - timedelta(minutes=1)
for error_code, timestamps in metrics._error_history.items():
rates[error_code] = sum(1 for ts in timestamps if ts >= one_minute_ago)
return rates
def _get_auth_failure_rate(self, metrics: ErrorMetrics) -> float:
return sum(
sample.value
for sample in metrics.auth_failures._child_samples()
) / 60.0
def get_active_alerts(self) -> List[Dict[str, Any]]:
with self._lock:
return list(self.alerts)
error_metrics = ErrorMetrics()
alert_manager = AlertManager()
def init_monitoring(app):
@app.before_request
def before_request():
g.start_time = time.time()
@app.after_request
def after_request(response):
if hasattr(g, 'start_time'):
duration = time.time() - g.start_time
error_metrics.request_duration.labels(
method=request.method,
endpoint=request.endpoint or 'unknown',
status_code=response.status_code
).observe(duration)
return response
@app.route('/metrics')
def metrics():
alert_manager.check_alerts(error_metrics)
return Response(generate_latest(), mimetype='text/plain')
@app.route('/api/alerts')
def get_alerts():
alerts = alert_manager.get_active_alerts()
return {
'alerts': alerts,
'total': len(alerts),
'critical': len([a for a in alerts if a['severity'] == 'critical']),
'warning': len([a for a in alerts if a['severity'] == 'warning'])
}
def monitor_performance(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
try:
result = func(*args, **kwargs)
return result
finally:
duration = time.time() - start_time
if duration > 1.0:
logger.warning(
f"Slow function execution: {func.__name__}",
extra={
'function': func.__name__,
'duration': duration,
'request_id': getattr(g, 'request_id', 'unknown')
}
)
return wrapper
def track_error(error: BaseApplicationException):
error_metrics.record_error(error)
if error.status_code >= 500:
logger.error(
f"Critical error occurred: {error.code}",
extra={
'error_code': error.code,
'message': error.message,
'details': error.details,
'request_id': error.request_id
}
)

Datei anzeigen

@@ -0,0 +1,435 @@
import re
from typing import Any, Optional, List, Dict, Callable, Union
from datetime import datetime, date
from functools import wraps
import ipaddress
from flask import request
from .exceptions import InputValidationError, ValidationException
class ValidationRules:
EMAIL_PATTERN = re.compile(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
)
PHONE_PATTERN = re.compile(
r'^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,10}$'
)
LICENSE_KEY_PATTERN = re.compile(
r'^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
)
SAFE_STRING_PATTERN = re.compile(
r'^[a-zA-Z0-9\s\-\_\.\,\!\?\@\#\$\%\&\*\(\)\[\]\{\}\:\;\'\"\+\=\/\\]+$'
)
USERNAME_PATTERN = re.compile(
r'^[a-zA-Z0-9_\-\.]{3,50}$'
)
PASSWORD_MIN_LENGTH = 8
PASSWORD_REQUIRE_UPPER = True
PASSWORD_REQUIRE_LOWER = True
PASSWORD_REQUIRE_DIGIT = True
PASSWORD_REQUIRE_SPECIAL = True
class Validators:
@staticmethod
def required(value: Any, field_name: str = "field") -> Any:
if value is None or (isinstance(value, str) and not value.strip()):
raise InputValidationError(
field=field_name,
message="This field is required",
value=value
)
return value
@staticmethod
def email(value: str, field_name: str = "email") -> str:
value = Validators.required(value, field_name).strip()
if not ValidationRules.EMAIL_PATTERN.match(value):
raise InputValidationError(
field=field_name,
message="Invalid email format",
value=value,
expected_type="email"
)
return value.lower()
@staticmethod
def phone(value: str, field_name: str = "phone") -> str:
value = Validators.required(value, field_name).strip()
cleaned = re.sub(r'[\s\-\(\)]', '', value)
if not ValidationRules.PHONE_PATTERN.match(value):
raise InputValidationError(
field=field_name,
message="Invalid phone number format",
value=value,
expected_type="phone"
)
return cleaned
@staticmethod
def license_key(value: str, field_name: str = "license_key") -> str:
value = Validators.required(value, field_name).strip().upper()
if not ValidationRules.LICENSE_KEY_PATTERN.match(value):
raise InputValidationError(
field=field_name,
message="Invalid license key format (expected: XXXX-XXXX-XXXX-XXXX)",
value=value,
expected_type="license_key"
)
return value
@staticmethod
def integer(
value: Union[str, int],
field_name: str = "field",
min_value: Optional[int] = None,
max_value: Optional[int] = None
) -> int:
try:
int_value = int(value)
except (ValueError, TypeError):
raise InputValidationError(
field=field_name,
message="Must be a valid integer",
value=value,
expected_type="integer"
)
if min_value is not None and int_value < min_value:
raise InputValidationError(
field=field_name,
message=f"Must be at least {min_value}",
value=int_value
)
if max_value is not None and int_value > max_value:
raise InputValidationError(
field=field_name,
message=f"Must be at most {max_value}",
value=int_value
)
return int_value
@staticmethod
def float_number(
value: Union[str, float],
field_name: str = "field",
min_value: Optional[float] = None,
max_value: Optional[float] = None
) -> float:
try:
float_value = float(value)
except (ValueError, TypeError):
raise InputValidationError(
field=field_name,
message="Must be a valid number",
value=value,
expected_type="float"
)
if min_value is not None and float_value < min_value:
raise InputValidationError(
field=field_name,
message=f"Must be at least {min_value}",
value=float_value
)
if max_value is not None and float_value > max_value:
raise InputValidationError(
field=field_name,
message=f"Must be at most {max_value}",
value=float_value
)
return float_value
@staticmethod
def boolean(value: Union[str, bool], field_name: str = "field") -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
value_lower = value.lower()
if value_lower in ['true', '1', 'yes', 'on']:
return True
elif value_lower in ['false', '0', 'no', 'off']:
return False
raise InputValidationError(
field=field_name,
message="Must be a valid boolean",
value=value,
expected_type="boolean"
)
@staticmethod
def string(
value: str,
field_name: str = "field",
min_length: Optional[int] = None,
max_length: Optional[int] = None,
pattern: Optional[re.Pattern] = None,
safe_only: bool = False
) -> str:
value = Validators.required(value, field_name).strip()
if min_length is not None and len(value) < min_length:
raise InputValidationError(
field=field_name,
message=f"Must be at least {min_length} characters",
value=value
)
if max_length is not None and len(value) > max_length:
raise InputValidationError(
field=field_name,
message=f"Must be at most {max_length} characters",
value=value
)
if safe_only and not ValidationRules.SAFE_STRING_PATTERN.match(value):
raise InputValidationError(
field=field_name,
message="Contains invalid characters",
value=value
)
if pattern and not pattern.match(value):
raise InputValidationError(
field=field_name,
message="Does not match required format",
value=value
)
return value
@staticmethod
def username(value: str, field_name: str = "username") -> str:
value = Validators.required(value, field_name).strip()
if not ValidationRules.USERNAME_PATTERN.match(value):
raise InputValidationError(
field=field_name,
message="Username must be 3-50 characters and contain only letters, numbers, _, -, or .",
value=value,
expected_type="username"
)
return value
@staticmethod
def password(value: str, field_name: str = "password") -> str:
value = Validators.required(value, field_name)
errors = []
if len(value) < ValidationRules.PASSWORD_MIN_LENGTH:
errors.append(f"at least {ValidationRules.PASSWORD_MIN_LENGTH} characters")
if ValidationRules.PASSWORD_REQUIRE_UPPER and not re.search(r'[A-Z]', value):
errors.append("at least one uppercase letter")
if ValidationRules.PASSWORD_REQUIRE_LOWER and not re.search(r'[a-z]', value):
errors.append("at least one lowercase letter")
if ValidationRules.PASSWORD_REQUIRE_DIGIT and not re.search(r'\d', value):
errors.append("at least one digit")
if ValidationRules.PASSWORD_REQUIRE_SPECIAL and not re.search(r'[!@#$%^&*(),.?":{}|<>]', value):
errors.append("at least one special character")
if errors:
raise InputValidationError(
field=field_name,
message=f"Password must contain {', '.join(errors)}",
value="[hidden]"
)
return value
@staticmethod
def date_string(
value: str,
field_name: str = "date",
format: str = "%Y-%m-%d",
min_date: Optional[date] = None,
max_date: Optional[date] = None
) -> date:
value = Validators.required(value, field_name).strip()
try:
date_value = datetime.strptime(value, format).date()
except ValueError:
raise InputValidationError(
field=field_name,
message=f"Invalid date format (expected: {format})",
value=value,
expected_type="date"
)
if min_date and date_value < min_date:
raise InputValidationError(
field=field_name,
message=f"Date must be after {min_date}",
value=value
)
if max_date and date_value > max_date:
raise InputValidationError(
field=field_name,
message=f"Date must be before {max_date}",
value=value
)
return date_value
@staticmethod
def ip_address(
value: str,
field_name: str = "ip_address",
version: Optional[int] = None
) -> str:
value = Validators.required(value, field_name).strip()
try:
ip = ipaddress.ip_address(value)
if version and ip.version != version:
raise ValueError
except ValueError:
version_str = f"IPv{version}" if version else "IP"
raise InputValidationError(
field=field_name,
message=f"Invalid {version_str} address",
value=value,
expected_type="ip_address"
)
return str(ip)
@staticmethod
def url(
value: str,
field_name: str = "url",
require_https: bool = False
) -> str:
value = Validators.required(value, field_name).strip()
url_pattern = re.compile(
r'^https?://'
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|'
r'localhost|'
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
r'(?::\d+)?'
r'(?:/?|[/?]\S+)$', re.IGNORECASE
)
if not url_pattern.match(value):
raise InputValidationError(
field=field_name,
message="Invalid URL format",
value=value,
expected_type="url"
)
if require_https and not value.startswith('https://'):
raise InputValidationError(
field=field_name,
message="URL must use HTTPS",
value=value
)
return value
@staticmethod
def enum(
value: Any,
field_name: str,
allowed_values: List[Any]
) -> Any:
if value not in allowed_values:
raise InputValidationError(
field=field_name,
message=f"Must be one of: {', '.join(map(str, allowed_values))}",
value=value
)
return value
def validate(rules: Dict[str, Dict[str, Any]]) -> Callable:
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
data = request.get_json() if request.is_json else request.form
validated_data = {}
for field_name, field_rules in rules.items():
value = data.get(field_name)
if 'required' in field_rules and field_rules['required']:
value = Validators.required(value, field_name)
elif value is None or value == '':
if 'default' in field_rules:
validated_data[field_name] = field_rules['default']
continue
validator_name = field_rules.get('type', 'string')
validator_func = getattr(Validators, validator_name, None)
if not validator_func:
raise ValueError(f"Unknown validator type: {validator_name}")
validator_params = {
k: v for k, v in field_rules.items()
if k not in ['type', 'required', 'default']
}
validator_params['field_name'] = field_name
validated_data[field_name] = validator_func(value, **validator_params)
request.validated_data = validated_data
return func(*args, **kwargs)
return wrapper
return decorator
def sanitize_html(value: str) -> str:
dangerous_tags = re.compile(
r'<(script|iframe|object|embed|form|input|button|textarea|select|link|meta|style).*?>.*?</\1>',
re.IGNORECASE | re.DOTALL
)
dangerous_attrs = re.compile(
r'\s*(on\w+|style|javascript:)[\s]*=[\s]*["\']?[^"\'>\s]+',
re.IGNORECASE
)
value = dangerous_tags.sub('', value)
value = dangerous_attrs.sub('', value)
return value
def sanitize_sql_identifier(value: str) -> str:
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', value):
raise ValidationException(
message="Invalid SQL identifier",
details={'value': value},
user_message="Ungültiger Bezeichner"
)
return value

84
v2_adminpanel/db.py Normale Datei
Datei anzeigen

@@ -0,0 +1,84 @@
import psycopg2
from psycopg2.extras import Json, RealDictCursor
from contextlib import contextmanager
from config import DATABASE_CONFIG
def get_connection():
"""Create and return a new database connection"""
conn = psycopg2.connect(**DATABASE_CONFIG)
conn.set_client_encoding('UTF8')
return conn
@contextmanager
def get_db_connection():
"""Context manager for database connections"""
conn = get_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
@contextmanager
def get_db_cursor(conn=None):
"""Context manager for database cursors"""
if conn is None:
with get_db_connection() as connection:
cur = connection.cursor()
try:
yield cur
finally:
cur.close()
else:
cur = conn.cursor()
try:
yield cur
finally:
cur.close()
@contextmanager
def get_dict_cursor(conn=None):
"""Context manager for dictionary cursors"""
if conn is None:
with get_db_connection() as connection:
cur = connection.cursor(cursor_factory=RealDictCursor)
try:
yield cur
finally:
cur.close()
else:
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
yield cur
finally:
cur.close()
def execute_query(query, params=None, fetch_one=False, fetch_all=False, as_dict=False):
"""Execute a query and optionally fetch results"""
with get_db_connection() as conn:
cursor_func = get_dict_cursor if as_dict else get_db_cursor
with cursor_func(conn) as cur:
cur.execute(query, params)
if fetch_one:
return cur.fetchone()
elif fetch_all:
return cur.fetchall()
else:
return cur.rowcount
def execute_many(query, params_list):
"""Execute a query multiple times with different parameters"""
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.executemany(query, params_list)
return cur.rowcount

704
v2_adminpanel/init.sql Normale Datei
Datei anzeigen

@@ -0,0 +1,704 @@
-- UTF-8 Encoding für deutsche Sonderzeichen sicherstellen
SET client_encoding = 'UTF8';
-- Zeitzone auf Europe/Berlin setzen
SET timezone = 'Europe/Berlin';
CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
is_fake BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_email UNIQUE (email)
);
CREATE TABLE IF NOT EXISTS licenses (
id SERIAL PRIMARY KEY,
license_key TEXT UNIQUE NOT NULL,
customer_id INTEGER REFERENCES customers(id),
license_type TEXT NOT NULL,
valid_from DATE NOT NULL,
valid_until DATE NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
is_fake BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id),
license_key VARCHAR(60), -- Denormalized for performance
session_id TEXT UNIQUE NOT NULL,
username VARCHAR(50),
computer_name VARCHAR(100),
hardware_id VARCHAR(100),
ip_address TEXT,
user_agent TEXT,
app_version VARCHAR(20),
login_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, -- Alias for started_at
last_activity TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, -- Alias for last_heartbeat
logout_time TIMESTAMP WITH TIME ZONE, -- Alias for ended_at
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT TRUE,
active BOOLEAN DEFAULT TRUE -- Alias for is_active
);
-- Audit-Log-Tabelle für Änderungsprotokolle
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
username TEXT NOT NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
old_values JSONB,
new_values JSONB,
ip_address TEXT,
user_agent TEXT,
additional_info TEXT
);
-- Index für bessere Performance bei Abfragen
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC);
CREATE INDEX idx_audit_log_username ON audit_log(username);
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id);
-- Backup-Historie-Tabelle
CREATE TABLE IF NOT EXISTS backup_history (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL,
filepath TEXT NOT NULL,
filesize BIGINT,
backup_type TEXT NOT NULL, -- 'manual' oder 'scheduled'
status TEXT NOT NULL, -- 'success', 'failed', 'in_progress'
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL,
tables_count INTEGER,
records_count INTEGER,
duration_seconds NUMERIC,
is_encrypted BOOLEAN DEFAULT TRUE
);
-- Index für bessere Performance
CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC);
CREATE INDEX idx_backup_history_status ON backup_history(status);
-- Login-Attempts-Tabelle für Rate-Limiting
CREATE TABLE IF NOT EXISTS login_attempts (
ip_address VARCHAR(45) PRIMARY KEY,
attempt_count INTEGER DEFAULT 0,
first_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
blocked_until TIMESTAMP WITH TIME ZONE NULL,
last_username_tried TEXT,
last_error_message TEXT
);
-- Index für schnelle Abfragen
CREATE INDEX idx_login_attempts_blocked_until ON login_attempts(blocked_until);
CREATE INDEX idx_login_attempts_last_attempt ON login_attempts(last_attempt DESC);
-- Migration: Füge created_at zu licenses hinzu, falls noch nicht vorhanden
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'created_at') THEN
ALTER TABLE licenses ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
-- Setze created_at für bestehende Einträge auf das valid_from Datum
UPDATE licenses SET created_at = valid_from WHERE created_at IS NULL;
END IF;
END $$;
-- ===================== RESOURCE POOL SYSTEM =====================
-- Haupttabelle für den Resource Pool
CREATE TABLE IF NOT EXISTS resource_pools (
id SERIAL PRIMARY KEY,
resource_type VARCHAR(20) NOT NULL CHECK (resource_type IN ('domain', 'ipv4', 'phone')),
resource_value VARCHAR(255) NOT NULL,
status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('available', 'allocated', 'quarantine')),
allocated_to_license INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
status_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
status_changed_by VARCHAR(50),
quarantine_reason VARCHAR(100) CHECK (quarantine_reason IN ('abuse', 'defect', 'maintenance', 'blacklisted', 'expired', 'review', NULL)),
quarantine_until TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
notes TEXT,
is_fake BOOLEAN DEFAULT FALSE,
UNIQUE(resource_type, resource_value)
);
-- Resource History für vollständige Nachverfolgbarkeit
CREATE TABLE IF NOT EXISTS resource_history (
id SERIAL PRIMARY KEY,
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
license_id INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL CHECK (action IN ('allocated', 'deallocated', 'quarantined', 'released', 'created', 'deleted')),
action_by VARCHAR(50) NOT NULL,
action_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
details JSONB,
ip_address TEXT
);
-- Resource Metrics für Performance-Tracking und ROI
CREATE TABLE IF NOT EXISTS resource_metrics (
id SERIAL PRIMARY KEY,
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
metric_date DATE NOT NULL,
usage_count INTEGER DEFAULT 0,
performance_score DECIMAL(5,2) DEFAULT 0.00,
cost DECIMAL(10,2) DEFAULT 0.00,
revenue DECIMAL(10,2) DEFAULT 0.00,
issues_count INTEGER DEFAULT 0,
availability_percent DECIMAL(5,2) DEFAULT 100.00,
UNIQUE(resource_id, metric_date)
);
-- Zuordnungstabelle zwischen Lizenzen und Ressourcen
CREATE TABLE IF NOT EXISTS license_resources (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
assigned_by VARCHAR(50),
is_active BOOLEAN DEFAULT TRUE,
UNIQUE(license_id, resource_id)
);
-- Erweiterung der licenses Tabelle um Resource-Counts
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'domain_count') THEN
ALTER TABLE licenses
ADD COLUMN domain_count INTEGER DEFAULT 1 CHECK (domain_count >= 0 AND domain_count <= 10),
ADD COLUMN ipv4_count INTEGER DEFAULT 1 CHECK (ipv4_count >= 0 AND ipv4_count <= 10),
ADD COLUMN phone_count INTEGER DEFAULT 1 CHECK (phone_count >= 0 AND phone_count <= 10);
END IF;
END $$;
-- Erweiterung der licenses Tabelle um device_limit
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'device_limit') THEN
ALTER TABLE licenses
ADD COLUMN device_limit INTEGER DEFAULT 3 CHECK (device_limit >= 1 AND device_limit <= 10);
END IF;
END $$;
-- Tabelle für Geräte-Registrierungen
CREATE TABLE IF NOT EXISTS device_registrations (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
hardware_id TEXT NOT NULL,
device_name TEXT,
operating_system TEXT,
first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
deactivated_at TIMESTAMP WITH TIME ZONE,
deactivated_by TEXT,
ip_address TEXT,
user_agent TEXT,
UNIQUE(license_id, hardware_id)
);
-- Indizes für device_registrations
CREATE INDEX IF NOT EXISTS idx_device_license ON device_registrations(license_id);
CREATE INDEX IF NOT EXISTS idx_device_hardware ON device_registrations(hardware_id);
CREATE INDEX IF NOT EXISTS idx_device_active ON device_registrations(license_id, is_active) WHERE is_active = TRUE;
-- Indizes für Performance
CREATE INDEX IF NOT EXISTS idx_resource_status ON resource_pools(status);
CREATE INDEX IF NOT EXISTS idx_resource_type_status ON resource_pools(resource_type, status);
CREATE INDEX IF NOT EXISTS idx_resource_allocated ON resource_pools(allocated_to_license) WHERE allocated_to_license IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_resource_quarantine ON resource_pools(quarantine_until) WHERE status = 'quarantine';
CREATE INDEX IF NOT EXISTS idx_resource_history_date ON resource_history(action_at DESC);
CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id);
CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC);
CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE;
-- Users table for authentication with password and 2FA support
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(100),
totp_secret VARCHAR(32),
totp_enabled BOOLEAN DEFAULT FALSE,
backup_codes TEXT, -- JSON array of hashed backup codes
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
password_reset_token VARCHAR(64),
password_reset_expires TIMESTAMP WITH TIME ZONE,
failed_2fa_attempts INTEGER DEFAULT 0,
last_failed_2fa TIMESTAMP WITH TIME ZONE
);
-- Index for faster login lookups
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL;
-- Migration: Add is_fake column to licenses if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'is_fake') THEN
ALTER TABLE licenses ADD COLUMN is_fake BOOLEAN DEFAULT FALSE;
-- Mark all existing licenses as fake data
UPDATE licenses SET is_fake = TRUE;
-- Add index for better performance when filtering fake data
CREATE INDEX idx_licenses_is_fake ON licenses(is_fake);
END IF;
END $$;
-- Migration: Add is_fake column to customers if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'customers' AND column_name = 'is_fake') THEN
ALTER TABLE customers ADD COLUMN is_fake BOOLEAN DEFAULT FALSE;
-- Mark all existing customers as fake data
UPDATE customers SET is_fake = TRUE;
-- Add index for better performance
CREATE INDEX idx_customers_is_fake ON customers(is_fake);
END IF;
END $$;
-- Migration: Add is_fake column to resource_pools if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'resource_pools' AND column_name = 'is_fake') THEN
ALTER TABLE resource_pools ADD COLUMN is_fake BOOLEAN DEFAULT FALSE;
-- Mark all existing resources as fake data
UPDATE resource_pools SET is_fake = TRUE;
-- Add index for better performance
CREATE INDEX idx_resource_pools_is_fake ON resource_pools(is_fake);
END IF;
END $$;
-- Migration: Add missing columns to sessions table
DO $$
BEGIN
-- Add license_key column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'sessions' AND column_name = 'license_key') THEN
ALTER TABLE sessions ADD COLUMN license_key VARCHAR(60);
END IF;
-- Add username column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'sessions' AND column_name = 'username') THEN
ALTER TABLE sessions ADD COLUMN username VARCHAR(50);
END IF;
-- Add computer_name column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'sessions' AND column_name = 'computer_name') THEN
ALTER TABLE sessions ADD COLUMN computer_name VARCHAR(100);
END IF;
-- Add hardware_id column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'sessions' AND column_name = 'hardware_id') THEN
ALTER TABLE sessions ADD COLUMN hardware_id VARCHAR(100);
END IF;
-- Add app_version column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'sessions' AND column_name = 'app_version') THEN
ALTER TABLE sessions ADD COLUMN app_version VARCHAR(20);
END IF;
-- Add login_time as alias for started_at
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'sessions' AND column_name = 'login_time') THEN
ALTER TABLE sessions ADD COLUMN login_time TIMESTAMP WITH TIME ZONE;
UPDATE sessions SET login_time = started_at;
END IF;
-- Add last_activity as alias for last_heartbeat
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'sessions' AND column_name = 'last_activity') THEN
ALTER TABLE sessions ADD COLUMN last_activity TIMESTAMP WITH TIME ZONE;
UPDATE sessions SET last_activity = last_heartbeat;
END IF;
-- Add logout_time as alias for ended_at
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'sessions' AND column_name = 'logout_time') THEN
ALTER TABLE sessions ADD COLUMN logout_time TIMESTAMP WITH TIME ZONE;
UPDATE sessions SET logout_time = ended_at;
END IF;
-- Add active as alias for is_active
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'sessions' AND column_name = 'active') THEN
ALTER TABLE sessions ADD COLUMN active BOOLEAN DEFAULT TRUE;
UPDATE sessions SET active = is_active;
END IF;
END $$;
-- ===================== LICENSE SERVER TABLES =====================
-- Following best practices: snake_case for DB fields, clear naming conventions
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- License tokens for offline validation
CREATE TABLE IF NOT EXISTS license_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
token VARCHAR(512) NOT NULL UNIQUE,
hardware_id VARCHAR(255) NOT NULL,
valid_until TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_validated TIMESTAMP,
validation_count INTEGER DEFAULT 0
);
CREATE INDEX idx_token ON license_tokens(token);
CREATE INDEX idx_hardware ON license_tokens(hardware_id);
CREATE INDEX idx_valid_until ON license_tokens(valid_until);
-- Heartbeat tracking with partitioning support
CREATE TABLE IF NOT EXISTS license_heartbeats (
id BIGSERIAL,
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
hardware_id VARCHAR(255) NOT NULL,
ip_address INET,
user_agent VARCHAR(500),
app_version VARCHAR(50),
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
session_data JSONB,
PRIMARY KEY (id, timestamp)
) PARTITION BY RANGE (timestamp);
-- Create partitions for the current and next month
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_01 PARTITION OF license_heartbeats
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_02 PARTITION OF license_heartbeats
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
-- Add June 2025 partition for current month
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_06 PARTITION OF license_heartbeats
FOR VALUES FROM ('2025-06-01') TO ('2025-07-01');
CREATE INDEX idx_heartbeat_license_time ON license_heartbeats(license_id, timestamp DESC);
CREATE INDEX idx_heartbeat_hardware_time ON license_heartbeats(hardware_id, timestamp DESC);
-- Activation events tracking
CREATE TABLE IF NOT EXISTS activation_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL CHECK (event_type IN ('activation', 'deactivation', 'reactivation', 'transfer')),
hardware_id VARCHAR(255),
previous_hardware_id VARCHAR(255),
ip_address INET,
user_agent VARCHAR(500),
success BOOLEAN DEFAULT true,
error_message TEXT,
metadata JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_license_events ON activation_events(license_id, created_at DESC);
CREATE INDEX idx_event_type ON activation_events(event_type, created_at DESC);
-- API rate limiting
CREATE TABLE IF NOT EXISTS api_rate_limits (
id SERIAL PRIMARY KEY,
api_key VARCHAR(255) NOT NULL UNIQUE,
requests_per_minute INTEGER DEFAULT 60,
requests_per_hour INTEGER DEFAULT 1000,
requests_per_day INTEGER DEFAULT 10000,
burst_size INTEGER DEFAULT 100,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Anomaly detection
CREATE TABLE IF NOT EXISTS anomaly_detections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id INTEGER REFERENCES licenses(id),
anomaly_type VARCHAR(100) NOT NULL CHECK (anomaly_type IN ('multiple_ips', 'rapid_hardware_change', 'suspicious_pattern', 'concurrent_use', 'geo_anomaly')),
severity VARCHAR(20) NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
details JSONB NOT NULL,
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved BOOLEAN DEFAULT false,
resolved_at TIMESTAMP,
resolved_by VARCHAR(255),
action_taken TEXT
);
CREATE INDEX idx_unresolved ON anomaly_detections(resolved, severity, detected_at DESC);
CREATE INDEX idx_license_anomalies ON anomaly_detections(license_id, detected_at DESC);
-- API clients for authentication
CREATE TABLE IF NOT EXISTS api_clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_name VARCHAR(255) NOT NULL,
api_key VARCHAR(255) NOT NULL UNIQUE,
secret_key VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT true,
allowed_endpoints TEXT[],
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Feature flags for gradual rollout
CREATE TABLE IF NOT EXISTS feature_flags (
id SERIAL PRIMARY KEY,
feature_name VARCHAR(100) NOT NULL UNIQUE,
is_enabled BOOLEAN DEFAULT false,
rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage >= 0 AND rollout_percentage <= 100),
whitelist_license_ids INTEGER[],
blacklist_license_ids INTEGER[],
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert default feature flags
INSERT INTO feature_flags (feature_name, is_enabled, rollout_percentage) VALUES
('anomaly_detection', true, 100),
('offline_tokens', true, 100),
('advanced_analytics', false, 0),
('geo_restriction', false, 0)
ON CONFLICT (feature_name) DO NOTHING;
-- Session management for concurrent use tracking
CREATE TABLE IF NOT EXISTS active_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
hardware_id VARCHAR(255) NOT NULL,
session_token VARCHAR(512) NOT NULL UNIQUE,
ip_address INET,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_session_license ON active_sessions(license_id);
CREATE INDEX idx_session_expires ON active_sessions(expires_at);
-- Update trigger for updated_at columns
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_api_rate_limits_updated_at BEFORE UPDATE ON api_rate_limits
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_api_clients_updated_at BEFORE UPDATE ON api_clients
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_feature_flags_updated_at BEFORE UPDATE ON feature_flags
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Function to automatically create monthly partitions for heartbeats
CREATE OR REPLACE FUNCTION create_monthly_partition()
RETURNS void AS $$
DECLARE
start_date date;
end_date date;
partition_name text;
BEGIN
start_date := date_trunc('month', CURRENT_DATE + interval '1 month');
end_date := start_date + interval '1 month';
partition_name := 'license_heartbeats_' || to_char(start_date, 'YYYY_MM');
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)',
partition_name, start_date, end_date);
END;
$$ LANGUAGE plpgsql;
-- Migration: Add max_devices column to licenses if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'max_devices') THEN
ALTER TABLE licenses ADD COLUMN max_devices INTEGER DEFAULT 3 CHECK (max_devices >= 1);
END IF;
END $$;
-- Migration: Add expires_at column to licenses if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'expires_at') THEN
ALTER TABLE licenses ADD COLUMN expires_at TIMESTAMP;
-- Set expires_at based on valid_until for existing licenses
UPDATE licenses SET expires_at = valid_until::timestamp WHERE expires_at IS NULL;
END IF;
END $$;
-- Migration: Add features column to licenses if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'features') THEN
ALTER TABLE licenses ADD COLUMN features TEXT[] DEFAULT '{}';
END IF;
END $$;
-- Migration: Add updated_at column to licenses if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'updated_at') THEN
ALTER TABLE licenses ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
CREATE TRIGGER update_licenses_updated_at BEFORE UPDATE ON licenses
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;
-- Migration: Add device_type column to device_registrations table
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'device_registrations' AND column_name = 'device_type') THEN
ALTER TABLE device_registrations ADD COLUMN device_type VARCHAR(50) DEFAULT 'unknown';
-- Update existing records to have a device_type based on operating system
UPDATE device_registrations
SET device_type = CASE
WHEN operating_system ILIKE '%windows%' THEN 'desktop'
WHEN operating_system ILIKE '%mac%' THEN 'desktop'
WHEN operating_system ILIKE '%linux%' THEN 'desktop'
WHEN operating_system ILIKE '%android%' THEN 'mobile'
WHEN operating_system ILIKE '%ios%' THEN 'mobile'
ELSE 'unknown'
END
WHERE device_type IS NULL OR device_type = 'unknown';
END IF;
END $$;
-- Client configuration table for Account Forger
CREATE TABLE IF NOT EXISTS client_configs (
id SERIAL PRIMARY KEY,
client_name VARCHAR(100) NOT NULL DEFAULT 'Account Forger',
api_key VARCHAR(255) NOT NULL,
heartbeat_interval INTEGER DEFAULT 30, -- seconds
session_timeout INTEGER DEFAULT 60, -- seconds (2x heartbeat)
current_version VARCHAR(20) NOT NULL,
minimum_version VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- License sessions for single-session enforcement
CREATE TABLE IF NOT EXISTS license_sessions (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
hardware_id VARCHAR(255) NOT NULL,
ip_address INET,
client_version VARCHAR(20),
session_token VARCHAR(255) UNIQUE NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_heartbeat TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(license_id) -- Only one active session per license
);
-- Session history for debugging
CREATE TABLE IF NOT EXISTS session_history (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
hardware_id VARCHAR(255) NOT NULL,
ip_address INET,
client_version VARCHAR(20),
started_at TIMESTAMP,
ended_at TIMESTAMP,
end_reason VARCHAR(50) -- 'normal', 'timeout', 'forced', 'replaced'
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_license_sessions_license_id ON license_sessions(license_id);
CREATE INDEX IF NOT EXISTS idx_license_sessions_last_heartbeat ON license_sessions(last_heartbeat);
CREATE INDEX IF NOT EXISTS idx_session_history_license_id ON session_history(license_id);
CREATE INDEX IF NOT EXISTS idx_session_history_ended_at ON session_history(ended_at);
-- Insert default client configuration if not exists
INSERT INTO client_configs (client_name, api_key, current_version, minimum_version)
VALUES ('Account Forger', 'AF-' || gen_random_uuid()::text, '1.0.0', '1.0.0')
ON CONFLICT DO NOTHING;
-- ===================== SYSTEM API KEY TABLE =====================
-- Single API key for system-wide authentication
CREATE TABLE IF NOT EXISTS system_api_key (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), -- Ensures single row
api_key VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
regenerated_at TIMESTAMP WITH TIME ZONE,
last_used_at TIMESTAMP WITH TIME ZONE,
usage_count INTEGER DEFAULT 0,
created_by VARCHAR(50),
regenerated_by VARCHAR(50)
);
-- Function to generate API key with AF-YYYY- prefix
CREATE OR REPLACE FUNCTION generate_api_key() RETURNS VARCHAR AS $$
DECLARE
year_part VARCHAR(4);
random_part VARCHAR(32);
BEGIN
year_part := to_char(CURRENT_DATE, 'YYYY');
random_part := upper(substring(md5(random()::text || clock_timestamp()::text) from 1 for 32));
RETURN 'AF-' || year_part || '-' || random_part;
END;
$$ LANGUAGE plpgsql;
-- Initialize with a default API key if none exists
INSERT INTO system_api_key (api_key, created_by)
SELECT generate_api_key(), 'system'
WHERE NOT EXISTS (SELECT 1 FROM system_api_key);
-- Audit trigger for API key changes
CREATE OR REPLACE FUNCTION audit_api_key_changes() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'UPDATE' AND OLD.api_key != NEW.api_key THEN
INSERT INTO audit_log (
timestamp,
username,
action,
entity_type,
entity_id,
old_values,
new_values,
additional_info
) VALUES (
CURRENT_TIMESTAMP,
COALESCE(NEW.regenerated_by, 'system'),
'api_key_regenerated',
'system_api_key',
NEW.id,
jsonb_build_object('api_key', LEFT(OLD.api_key, 8) || '...'),
jsonb_build_object('api_key', LEFT(NEW.api_key, 8) || '...'),
'API Key regenerated'
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_system_api_key_changes
AFTER UPDATE ON system_api_key
FOR EACH ROW EXECUTE FUNCTION audit_api_key_changes();

Datei anzeigen

@@ -0,0 +1,6 @@
# Lead Management Module
from flask import Blueprint
leads_bp = Blueprint('leads', __name__, template_folder='templates')
from . import routes

Datei anzeigen

@@ -0,0 +1,48 @@
# Lead Management Data Models
from dataclasses import dataclass
from datetime import datetime
from typing import List, Optional, Dict, Any
from uuid import UUID
@dataclass
class Institution:
id: UUID
name: str
metadata: Dict[str, Any]
created_at: datetime
updated_at: datetime
created_by: str
contact_count: Optional[int] = 0
@dataclass
class Contact:
id: UUID
institution_id: UUID
first_name: str
last_name: str
position: Optional[str]
extra_fields: Dict[str, Any]
created_at: datetime
updated_at: datetime
institution_name: Optional[str] = None
@dataclass
class ContactDetail:
id: UUID
contact_id: UUID
detail_type: str # 'phone', 'email'
detail_value: str
detail_label: Optional[str] # 'Mobil', 'Geschäftlich', etc.
is_primary: bool
created_at: datetime
@dataclass
class Note:
id: UUID
contact_id: UUID
note_text: str
version: int
is_current: bool
created_at: datetime
created_by: str
parent_note_id: Optional[UUID] = None

Datei anzeigen

@@ -0,0 +1,359 @@
# Database Repository for Lead Management
import psycopg2
from psycopg2.extras import RealDictCursor
from uuid import UUID
from typing import List, Optional, Dict, Any
from datetime import datetime
class LeadRepository:
def __init__(self, get_db_connection):
self.get_db_connection = get_db_connection
# Institution Methods
def get_institutions_with_counts(self) -> List[Dict[str, Any]]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = """
SELECT
i.id,
i.name,
i.metadata,
i.created_at,
i.updated_at,
i.created_by,
COUNT(c.id) as contact_count
FROM lead_institutions i
LEFT JOIN lead_contacts c ON c.institution_id = i.id
GROUP BY i.id
ORDER BY i.name
"""
cur.execute(query)
results = cur.fetchall()
cur.close()
return results
def create_institution(self, name: str, created_by: str) -> Dict[str, Any]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = """
INSERT INTO lead_institutions (name, created_by)
VALUES (%s, %s)
RETURNING *
"""
cur.execute(query, (name, created_by))
result = cur.fetchone()
cur.close()
return result
def get_institution_by_id(self, institution_id: UUID) -> Optional[Dict[str, Any]]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = """
SELECT * FROM lead_institutions WHERE id = %s
"""
cur.execute(query, (str(institution_id),))
result = cur.fetchone()
cur.close()
return result
def update_institution(self, institution_id: UUID, name: str) -> Dict[str, Any]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = """
UPDATE lead_institutions
SET name = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
cur.execute(query, (name, str(institution_id)))
result = cur.fetchone()
cur.close()
return result
# Contact Methods
def get_contacts_by_institution(self, institution_id: UUID) -> List[Dict[str, Any]]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = """
SELECT
c.*,
i.name as institution_name
FROM lead_contacts c
JOIN lead_institutions i ON i.id = c.institution_id
WHERE c.institution_id = %s
ORDER BY c.last_name, c.first_name
"""
cur.execute(query, (str(institution_id),))
results = cur.fetchall()
cur.close()
return results
def create_contact(self, data: Dict[str, Any]) -> Dict[str, Any]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = """
INSERT INTO lead_contacts
(institution_id, first_name, last_name, position, extra_fields)
VALUES (%s, %s, %s, %s, %s)
RETURNING *
"""
cur.execute(query, (
str(data['institution_id']),
data['first_name'],
data['last_name'],
data.get('position'),
psycopg2.extras.Json(data.get('extra_fields', {}))
))
result = cur.fetchone()
cur.close()
return result
def get_contact_with_details(self, contact_id: UUID) -> Dict[str, Any]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
# Get contact base info
query = """
SELECT
c.*,
i.name as institution_name
FROM lead_contacts c
JOIN lead_institutions i ON i.id = c.institution_id
WHERE c.id = %s
"""
cur.execute(query, (str(contact_id),))
contact = cur.fetchone()
if contact:
# Get contact details (phones, emails)
details_query = """
SELECT * FROM lead_contact_details
WHERE contact_id = %s
ORDER BY detail_type, is_primary DESC, created_at
"""
cur.execute(details_query, (str(contact_id),))
contact['details'] = cur.fetchall()
# Get notes
notes_query = """
SELECT * FROM lead_notes
WHERE contact_id = %s AND is_current = true
ORDER BY created_at DESC
"""
cur.execute(notes_query, (str(contact_id),))
contact['notes'] = cur.fetchall()
cur.close()
return contact
def update_contact(self, contact_id: UUID, data: Dict[str, Any]) -> Dict[str, Any]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = """
UPDATE lead_contacts
SET first_name = %s, last_name = %s, position = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
cur.execute(query, (
data['first_name'],
data['last_name'],
data.get('position'),
str(contact_id)
))
result = cur.fetchone()
cur.close()
return result
# Contact Details Methods
def add_contact_detail(self, contact_id: UUID, detail_type: str,
detail_value: str, detail_label: str = None) -> Dict[str, Any]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = """
INSERT INTO lead_contact_details
(contact_id, detail_type, detail_value, detail_label)
VALUES (%s, %s, %s, %s)
RETURNING *
"""
cur.execute(query, (
str(contact_id),
detail_type,
detail_value,
detail_label
))
result = cur.fetchone()
cur.close()
return result
def get_contact_detail_by_id(self, detail_id: UUID) -> Optional[Dict[str, Any]]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = "SELECT * FROM lead_contact_details WHERE id = %s"
cur.execute(query, (str(detail_id),))
result = cur.fetchone()
cur.close()
return result
def update_contact_detail(self, detail_id: UUID, detail_value: str,
detail_label: str = None) -> Dict[str, Any]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = """
UPDATE lead_contact_details
SET detail_value = %s, detail_label = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING *
"""
cur.execute(query, (detail_value, detail_label, str(detail_id)))
result = cur.fetchone()
cur.close()
return result
def delete_contact_detail(self, detail_id: UUID) -> bool:
with self.get_db_connection() as conn:
cur = conn.cursor()
query = "DELETE FROM lead_contact_details WHERE id = %s"
cur.execute(query, (str(detail_id),))
deleted = cur.rowcount > 0
cur.close()
return deleted
# Notes Methods
def create_note(self, contact_id: UUID, note_text: str, created_by: str) -> Dict[str, Any]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = """
INSERT INTO lead_notes
(contact_id, note_text, created_by)
VALUES (%s, %s, %s)
RETURNING *
"""
cur.execute(query, (
str(contact_id),
note_text,
created_by
))
result = cur.fetchone()
cur.close()
return result
def update_note(self, note_id: UUID, note_text: str, updated_by: str) -> Dict[str, Any]:
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
# First, mark current version as not current
update_old = """
UPDATE lead_notes
SET is_current = false
WHERE id = %s
"""
cur.execute(update_old, (str(note_id),))
# Create new version
create_new = """
INSERT INTO lead_notes
(contact_id, note_text, created_by, parent_note_id, version)
SELECT contact_id, %s, %s, %s, version + 1
FROM lead_notes
WHERE id = %s
RETURNING *
"""
cur.execute(create_new, (
note_text,
updated_by,
str(note_id),
str(note_id)
))
result = cur.fetchone()
cur.close()
return result
def delete_note(self, note_id: UUID) -> bool:
with self.get_db_connection() as conn:
cur = conn.cursor()
# Soft delete by marking as not current
query = """
UPDATE lead_notes
SET is_current = false
WHERE id = %s
"""
cur.execute(query, (str(note_id),))
deleted = cur.rowcount > 0
cur.close()
return deleted
def get_all_contacts_with_institutions(self) -> List[Dict[str, Any]]:
"""Get all contacts with their institution information"""
with self.get_db_connection() as conn:
cur = conn.cursor(cursor_factory=RealDictCursor)
query = """
SELECT
c.id,
c.first_name,
c.last_name,
c.position,
c.created_at,
c.updated_at,
c.institution_id,
i.name as institution_name,
(SELECT COUNT(*) FROM lead_contact_details
WHERE contact_id = c.id AND detail_type = 'phone') as phone_count,
(SELECT COUNT(*) FROM lead_contact_details
WHERE contact_id = c.id AND detail_type = 'email') as email_count,
(SELECT COUNT(*) FROM lead_notes
WHERE contact_id = c.id AND is_current = true) as note_count
FROM lead_contacts c
JOIN lead_institutions i ON i.id = c.institution_id
ORDER BY c.last_name, c.first_name
"""
cur.execute(query)
results = cur.fetchall()
cur.close()
return results

397
v2_adminpanel/leads/routes.py Normale Datei
Datei anzeigen

@@ -0,0 +1,397 @@
# Routes for Lead Management
from flask import render_template, request, jsonify, redirect, url_for, flash
from auth.decorators import login_required
from flask import session as flask_session
from . import leads_bp
from .services import LeadService
from .repositories import LeadRepository
from db import get_db_connection
from uuid import UUID
import traceback
# Service will be initialized per request
lead_repository = None
lead_service = None
def get_lead_service():
"""Get or create lead service instance"""
global lead_repository, lead_service
if lead_service is None:
lead_repository = LeadRepository(get_db_connection) # Pass the function, not call it
lead_service = LeadService(lead_repository)
return lead_service
# HTML Routes
@leads_bp.route('/management')
@login_required
def lead_management():
"""Lead Management Dashboard"""
try:
# Get institutions with contact counts
institutions = get_lead_service().list_institutions()
# Get all contacts with institution names
all_contacts = get_lead_service().list_all_contacts()
# Calculate totals
total_institutions = len(institutions)
total_contacts = len(all_contacts)
return render_template('leads/lead_management.html',
total_institutions=total_institutions,
total_contacts=total_contacts,
institutions=institutions,
all_contacts=all_contacts)
except Exception as e:
import traceback
print(f"Error in lead_management: {str(e)}")
print(traceback.format_exc())
flash(f'Fehler beim Laden des Dashboards: {str(e)}', 'error')
current_user = flask_session.get('username', 'System')
return render_template('leads/lead_management.html',
total_institutions=0,
total_contacts=0,
institutions=[],
all_contacts=[])
@leads_bp.route('/')
@login_required
def institutions():
"""List all institutions"""
try:
institutions = get_lead_service().list_institutions()
return render_template('leads/institutions.html', institutions=institutions)
except Exception as e:
flash(f'Fehler beim Laden der Institutionen: {str(e)}', 'error')
return render_template('leads/institutions.html', institutions=[])
@leads_bp.route('/institution/add', methods=['POST'])
@login_required
def add_institution():
"""Add new institution from form"""
try:
name = request.form.get('name')
if not name:
flash('Name ist erforderlich', 'error')
return redirect(url_for('leads.lead_management'))
# Add institution
get_lead_service().create_institution(name, flask_session.get('username', 'System'))
flash(f'Institution "{name}" wurde erfolgreich hinzugefügt', 'success')
except Exception as e:
flash(f'Fehler beim Hinzufügen der Institution: {str(e)}', 'error')
return redirect(url_for('leads.lead_management'))
@leads_bp.route('/contact/add', methods=['POST'])
@login_required
def add_contact():
"""Add new contact from form"""
try:
data = {
'institution_id': request.form.get('institution_id'),
'first_name': request.form.get('first_name'),
'last_name': request.form.get('last_name'),
'position': request.form.get('position')
}
# Validate required fields
if not data['institution_id'] or not data['first_name'] or not data['last_name']:
flash('Institution, Vorname und Nachname sind erforderlich', 'error')
return redirect(url_for('leads.lead_management'))
# Create contact
contact = get_lead_service().create_contact(data, flask_session.get('username', 'System'))
# Add email if provided
email = request.form.get('email')
if email:
get_lead_service().add_email(contact['id'], email, 'Primär', flask_session.get('username', 'System'))
# Add phone if provided
phone = request.form.get('phone')
if phone:
get_lead_service().add_phone(contact['id'], phone, 'Primär', flask_session.get('username', 'System'))
flash(f'Kontakt "{data["first_name"]} {data["last_name"]}" wurde erfolgreich hinzugefügt', 'success')
except Exception as e:
flash(f'Fehler beim Hinzufügen des Kontakts: {str(e)}', 'error')
return redirect(url_for('leads.lead_management'))
@leads_bp.route('/institution/<uuid:institution_id>')
@login_required
def institution_detail(institution_id):
"""Show institution with all contacts"""
try:
# Get institution through repository
service = get_lead_service()
institution = service.repo.get_institution_by_id(institution_id)
if not institution:
flash('Institution nicht gefunden', 'error')
return redirect(url_for('leads.institutions'))
contacts = get_lead_service().list_contacts_by_institution(institution_id)
return render_template('leads/institution_detail.html',
institution=institution,
contacts=contacts)
except Exception as e:
flash(f'Fehler beim Laden der Institution: {str(e)}', 'error')
return redirect(url_for('leads.institutions'))
@leads_bp.route('/contact/<uuid:contact_id>')
@login_required
def contact_detail(contact_id):
"""Show contact details with notes"""
try:
contact = get_lead_service().get_contact_details(contact_id)
return render_template('leads/contact_detail.html', contact=contact)
except Exception as e:
flash(f'Fehler beim Laden des Kontakts: {str(e)}', 'error')
return redirect(url_for('leads.institutions'))
@leads_bp.route('/contacts')
@login_required
def all_contacts():
"""Show all contacts across all institutions"""
try:
contacts = get_lead_service().list_all_contacts()
return render_template('leads/all_contacts.html', contacts=contacts)
except Exception as e:
flash(f'Fehler beim Laden der Kontakte: {str(e)}', 'error')
return render_template('leads/all_contacts.html', contacts=[])
# API Routes
@leads_bp.route('/api/institutions', methods=['POST'])
@login_required
def create_institution():
"""Create new institution"""
try:
data = request.get_json()
institution = get_lead_service().create_institution(
data['name'],
flask_session.get('username')
)
return jsonify({'success': True, 'institution': institution})
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@leads_bp.route('/api/institutions/<uuid:institution_id>', methods=['PUT'])
@login_required
def update_institution(institution_id):
"""Update institution"""
try:
data = request.get_json()
institution = get_lead_service().update_institution(
institution_id,
data['name'],
flask_session.get('username')
)
return jsonify({'success': True, 'institution': institution})
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@leads_bp.route('/api/contacts', methods=['POST'])
@login_required
def create_contact():
"""Create new contact"""
try:
data = request.get_json()
contact = get_lead_service().create_contact(data, flask_session.get('username'))
return jsonify({'success': True, 'contact': contact})
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@leads_bp.route('/api/contacts/<uuid:contact_id>', methods=['PUT'])
@login_required
def update_contact(contact_id):
"""Update contact"""
try:
data = request.get_json()
contact = get_lead_service().update_contact(
contact_id,
data,
flask_session.get('username')
)
return jsonify({'success': True, 'contact': contact})
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@leads_bp.route('/api/contacts/<uuid:contact_id>/phones', methods=['POST'])
@login_required
def add_phone(contact_id):
"""Add phone to contact"""
try:
data = request.get_json()
detail = get_lead_service().add_phone(
contact_id,
data['phone_number'],
data.get('phone_type'),
flask_session.get('username')
)
return jsonify({'success': True, 'detail': detail})
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@leads_bp.route('/api/contacts/<uuid:contact_id>/emails', methods=['POST'])
@login_required
def add_email(contact_id):
"""Add email to contact"""
try:
data = request.get_json()
detail = get_lead_service().add_email(
contact_id,
data['email'],
data.get('email_type'),
flask_session.get('username')
)
return jsonify({'success': True, 'detail': detail})
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@leads_bp.route('/api/details/<uuid:detail_id>', methods=['PUT'])
@login_required
def update_detail(detail_id):
"""Update contact detail (phone/email)"""
try:
data = request.get_json()
detail = get_lead_service().update_contact_detail(
detail_id,
data['detail_value'],
data.get('detail_label'),
flask_session.get('username')
)
return jsonify({'success': True, 'detail': detail})
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@leads_bp.route('/api/details/<uuid:detail_id>', methods=['DELETE'])
@login_required
def delete_detail(detail_id):
"""Delete contact detail"""
try:
success = get_lead_service().delete_contact_detail(
detail_id,
flask_session.get('username')
)
return jsonify({'success': success})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@leads_bp.route('/api/contacts/<uuid:contact_id>/notes', methods=['POST'])
@login_required
def add_note(contact_id):
"""Add note to contact"""
try:
data = request.get_json()
note = get_lead_service().add_note(
contact_id,
data['note_text'],
flask_session.get('username')
)
return jsonify({'success': True, 'note': note})
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@leads_bp.route('/api/notes/<uuid:note_id>', methods=['PUT'])
@login_required
def update_note(note_id):
"""Update note"""
try:
data = request.get_json()
note = get_lead_service().update_note(
note_id,
data['note_text'],
flask_session.get('username')
)
return jsonify({'success': True, 'note': note})
except ValueError as e:
return jsonify({'success': False, 'error': str(e)}), 400
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@leads_bp.route('/api/notes/<uuid:note_id>', methods=['DELETE'])
@login_required
def delete_note(note_id):
"""Delete note"""
try:
success = get_lead_service().delete_note(
note_id,
flask_session.get('username')
)
return jsonify({'success': success})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# Export Routes
@leads_bp.route('/export')
@login_required
def export_leads():
"""Export leads data as Excel/CSV"""
from utils.export import create_excel_export, create_csv_export
try:
conn = get_db_connection()
cur = conn.cursor()
# Query institutions with contact counts
cur.execute("""
SELECT
i.id,
i.name,
i.type,
i.website,
i.address,
i.created_at,
i.created_by,
COUNT(DISTINCT c.id) as contact_count,
COUNT(DISTINCT cd.id) as contact_detail_count,
COUNT(DISTINCT n.id) as note_count
FROM lead_institutions i
LEFT JOIN lead_contacts c ON i.id = c.institution_id
LEFT JOIN lead_contact_details cd ON c.id = cd.contact_id
LEFT JOIN lead_notes n ON i.id = n.institution_id
GROUP BY i.id, i.name, i.type, i.website, i.address, i.created_at, i.created_by
ORDER BY i.name
""")
# Prepare data for export
data = []
columns = ['ID', 'Institution', 'Typ', 'Website', 'Adresse',
'Erstellt am', 'Erstellt von', 'Anzahl Kontakte',
'Anzahl Kontaktdetails', 'Anzahl Notizen']
for row in cur.fetchall():
data.append(list(row))
# Check format parameter
format_type = request.args.get('format', 'excel').lower()
if format_type == 'csv':
return create_csv_export(data, columns, 'leads')
else:
return create_excel_export(data, columns, 'leads')
except Exception as e:
flash(f'Fehler beim Export: {str(e)}', 'error')
return redirect(url_for('leads.institutions'))
finally:
cur.close()
conn.close()

Datei anzeigen

@@ -0,0 +1,171 @@
# Business Logic Service for Lead Management
from typing import List, Dict, Any, Optional
from uuid import UUID
from datetime import datetime
from .repositories import LeadRepository
class LeadService:
def __init__(self, repository: LeadRepository):
self.repo = repository
# Institution Services
def list_institutions(self) -> List[Dict[str, Any]]:
"""Get all institutions with contact counts"""
return self.repo.get_institutions_with_counts()
def create_institution(self, name: str, user: str) -> Dict[str, Any]:
"""Create a new institution"""
# Validation
if not name or len(name.strip()) == 0:
raise ValueError("Institution name cannot be empty")
# Create institution
institution = self.repo.create_institution(name.strip(), user)
# Note: Audit logging removed as it requires different implementation
# Can be added later with proper audit system integration
return institution
def update_institution(self, institution_id: UUID, name: str, user: str) -> Dict[str, Any]:
"""Update institution name"""
# Validation
if not name or len(name.strip()) == 0:
raise ValueError("Institution name cannot be empty")
# Get current institution
current = self.repo.get_institution_by_id(institution_id)
if not current:
raise ValueError("Institution not found")
# Update
institution = self.repo.update_institution(institution_id, name.strip())
return institution
# Contact Services
def list_contacts_by_institution(self, institution_id: UUID) -> List[Dict[str, Any]]:
"""Get all contacts for an institution"""
return self.repo.get_contacts_by_institution(institution_id)
def create_contact(self, data: Dict[str, Any], user: str) -> Dict[str, Any]:
"""Create a new contact"""
# Validation
if not data.get('first_name') or not data.get('last_name'):
raise ValueError("First and last name are required")
if not data.get('institution_id'):
raise ValueError("Institution ID is required")
# Create contact
contact = self.repo.create_contact(data)
return contact
def get_contact_details(self, contact_id: UUID) -> Dict[str, Any]:
"""Get full contact information including details and notes"""
contact = self.repo.get_contact_with_details(contact_id)
if not contact:
raise ValueError("Contact not found")
# Group details by type
contact['phones'] = [d for d in contact.get('details', []) if d['detail_type'] == 'phone']
contact['emails'] = [d for d in contact.get('details', []) if d['detail_type'] == 'email']
return contact
def update_contact(self, contact_id: UUID, data: Dict[str, Any], user: str) -> Dict[str, Any]:
"""Update contact information"""
# Validation
if not data.get('first_name') or not data.get('last_name'):
raise ValueError("First and last name are required")
# Update contact
contact = self.repo.update_contact(contact_id, data)
return contact
# Contact Details Services
def add_phone(self, contact_id: UUID, phone_number: str,
phone_type: str = None, user: str = None) -> Dict[str, Any]:
"""Add phone number to contact"""
if not phone_number:
raise ValueError("Phone number is required")
detail = self.repo.add_contact_detail(
contact_id, 'phone', phone_number, phone_type
)
return detail
def add_email(self, contact_id: UUID, email: str,
email_type: str = None, user: str = None) -> Dict[str, Any]:
"""Add email to contact"""
if not email:
raise ValueError("Email is required")
# Basic email validation
if '@' not in email:
raise ValueError("Invalid email format")
detail = self.repo.add_contact_detail(
contact_id, 'email', email, email_type
)
return detail
def update_contact_detail(self, detail_id: UUID, detail_value: str,
detail_label: str = None, user: str = None) -> Dict[str, Any]:
"""Update a contact detail (phone/email)"""
if not detail_value or len(detail_value.strip()) == 0:
raise ValueError("Detail value cannot be empty")
# Get current detail to check type
current_detail = self.repo.get_contact_detail_by_id(detail_id)
if not current_detail:
raise ValueError("Contact detail not found")
# Validation based on type
if current_detail['detail_type'] == 'email' and '@' not in detail_value:
raise ValueError("Invalid email format")
detail = self.repo.update_contact_detail(
detail_id, detail_value.strip(), detail_label
)
return detail
def delete_contact_detail(self, detail_id: UUID, user: str) -> bool:
"""Delete a contact detail (phone/email)"""
success = self.repo.delete_contact_detail(detail_id)
return success
# Note Services
def add_note(self, contact_id: UUID, note_text: str, user: str) -> Dict[str, Any]:
"""Add a note to contact"""
if not note_text or len(note_text.strip()) == 0:
raise ValueError("Note text cannot be empty")
note = self.repo.create_note(contact_id, note_text.strip(), user)
return note
def update_note(self, note_id: UUID, note_text: str, user: str) -> Dict[str, Any]:
"""Update a note (creates new version)"""
if not note_text or len(note_text.strip()) == 0:
raise ValueError("Note text cannot be empty")
note = self.repo.update_note(note_id, note_text.strip(), user)
return note
def delete_note(self, note_id: UUID, user: str) -> bool:
"""Delete a note (soft delete)"""
success = self.repo.delete_note(note_id)
return success
def list_all_contacts(self) -> List[Dict[str, Any]]:
"""Get all contacts across all institutions with summary info"""
return self.repo.get_all_contacts_with_institutions()

Datei anzeigen

@@ -0,0 +1,239 @@
{% extends "base.html" %}
{% block title %}Alle Kontakte{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row mb-4">
<div class="col-md-8">
<h1 class="h2 mb-0">
<i class="bi bi-people"></i> Alle Kontakte
</h1>
<p class="text-muted mb-0">Übersicht aller Kontakte aus allen Institutionen</p>
</div>
<div class="col-md-4 text-end">
<a href="{{ url_for('leads.institutions') }}" class="btn btn-secondary">
<i class="bi bi-building"></i> Zu Institutionen
</a>
</div>
</div>
<!-- Search and Filter Bar -->
<div class="row mb-4">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="searchInput"
placeholder="Nach Name, Institution oder Position suchen..."
onkeyup="filterContacts()">
</div>
</div>
<div class="col-md-3">
<select class="form-select" id="institutionFilter" onchange="filterContacts()">
<option value="">Alle Institutionen</option>
{% set institutions = contacts | map(attribute='institution_name') | unique | sort %}
{% for institution in institutions %}
<option value="{{ institution }}">{{ institution }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary" onclick="sortContacts('name')">
<i class="bi bi-sort-alpha-down"></i> Name
</button>
<button type="button" class="btn btn-outline-secondary" onclick="sortContacts('institution')">
<i class="bi bi-building"></i> Institution
</button>
<button type="button" class="btn btn-outline-secondary" onclick="sortContacts('updated')">
<i class="bi bi-clock"></i> Aktualisiert
</button>
</div>
</div>
</div>
<!-- Contacts Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="contactsTable">
<thead>
<tr>
<th>Name</th>
<th>Institution</th>
<th>Position</th>
<th>Kontaktdaten</th>
<th>Notizen</th>
<th>Zuletzt aktualisiert</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for contact in contacts %}
<tr data-name="{{ contact.last_name }} {{ contact.first_name }}"
data-institution="{{ contact.institution_name }}"
data-updated="{{ contact.updated_at or contact.created_at }}">
<td>
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}"
class="text-decoration-none">
<strong>{{ contact.last_name }}, {{ contact.first_name }}</strong>
</a>
</td>
<td>
<a href="{{ url_for('leads.institution_detail', institution_id=contact.institution_id) }}"
class="text-decoration-none text-muted">
{{ contact.institution_name }}
</a>
</td>
<td>{{ contact.position or '-' }}</td>
<td>
{% if contact.phone_count > 0 %}
<span class="badge bg-info me-1" title="Telefonnummern">
<i class="bi bi-telephone"></i> {{ contact.phone_count }}
</span>
{% endif %}
{% if contact.email_count > 0 %}
<span class="badge bg-primary" title="E-Mail-Adressen">
<i class="bi bi-envelope"></i> {{ contact.email_count }}
</span>
{% endif %}
{% if contact.phone_count == 0 and contact.email_count == 0 %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if contact.note_count > 0 %}
<span class="badge bg-secondary">
<i class="bi bi-sticky"></i> {{ contact.note_count }}
</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{{ (contact.updated_at or contact.created_at).strftime('%d.%m.%Y %H:%M') }}
</td>
<td>
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Details
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not contacts %}
<div class="text-center py-4">
<p class="text-muted">Noch keine Kontakte vorhanden.</p>
<a href="{{ url_for('leads.institutions') }}" class="btn btn-primary">
<i class="bi bi-building"></i> Zu Institutionen
</a>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Contact Count -->
{% if contacts %}
<div class="row mt-3">
<div class="col-12">
<p class="text-muted">
<span id="visibleCount">{{ contacts|length }}</span> von {{ contacts|length }} Kontakten angezeigt
</p>
</div>
</div>
{% endif %}
</div>
<script>
// Current sort order
let currentSort = { field: 'name', ascending: true };
// Filter contacts
function filterContacts() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const institutionFilter = document.getElementById('institutionFilter').value.toLowerCase();
const rows = document.querySelectorAll('#contactsTable tbody tr');
let visibleCount = 0;
rows.forEach(row => {
const text = row.textContent.toLowerCase();
const institution = row.getAttribute('data-institution').toLowerCase();
const matchesSearch = searchTerm === '' || text.includes(searchTerm);
const matchesInstitution = institutionFilter === '' || institution === institutionFilter;
if (matchesSearch && matchesInstitution) {
row.style.display = '';
visibleCount++;
} else {
row.style.display = 'none';
}
});
// Update visible count
const countElement = document.getElementById('visibleCount');
if (countElement) {
countElement.textContent = visibleCount;
}
}
// Sort contacts
function sortContacts(field) {
const tbody = document.querySelector('#contactsTable tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
// Toggle sort order if same field
if (currentSort.field === field) {
currentSort.ascending = !currentSort.ascending;
} else {
currentSort.field = field;
currentSort.ascending = true;
}
// Sort rows
rows.sort((a, b) => {
let aValue, bValue;
switch(field) {
case 'name':
aValue = a.getAttribute('data-name');
bValue = b.getAttribute('data-name');
break;
case 'institution':
aValue = a.getAttribute('data-institution');
bValue = b.getAttribute('data-institution');
break;
case 'updated':
aValue = new Date(a.getAttribute('data-updated'));
bValue = new Date(b.getAttribute('data-updated'));
break;
}
if (field === 'updated') {
return currentSort.ascending ? aValue - bValue : bValue - aValue;
} else {
const comparison = aValue.localeCompare(bValue);
return currentSort.ascending ? comparison : -comparison;
}
});
// Re-append sorted rows
rows.forEach(row => tbody.appendChild(row));
// Update button states
document.querySelectorAll('.btn-group button').forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
// Set initial sort
sortContacts('name');
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,622 @@
{% extends "base.html" %}
{% block title %}{{ contact.first_name }} {{ contact.last_name }} - Kontakt-Details{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row mb-4">
<div class="col-md-8">
<h1 class="h2 mb-0">
<i class="bi bi-person"></i> {{ contact.first_name }} {{ contact.last_name }}
</h1>
<p class="mb-0">
<span class="text-muted">{{ contact.position or 'Keine Position' }}</span>
<span class="mx-2"></span>
<a href="{{ url_for('leads.institution_detail', institution_id=contact.institution_id) }}"
class="text-decoration-none">
{{ contact.institution_name }}
</a>
</p>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-outline-primary" onclick="editContact()">
<i class="bi bi-pencil"></i> Bearbeiten
</button>
<a href="{{ url_for('leads.institution_detail', institution_id=contact.institution_id) }}"
class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Zurück
</a>
</div>
</div>
<div class="row">
<!-- Contact Details -->
<div class="col-md-6">
<!-- Phone Numbers -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-telephone"></i> Telefonnummern</h5>
<button class="btn btn-sm btn-primary" onclick="showAddPhoneModal()">
<i class="bi bi-plus"></i>
</button>
</div>
<div class="card-body">
{% if contact.phones %}
<ul class="list-group list-group-flush">
{% for phone in contact.phones %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ phone.detail_value }}</strong>
{% if phone.detail_label %}
<span class="badge bg-secondary">{{ phone.detail_label }}</span>
{% endif %}
</div>
<div>
<button class="btn btn-sm btn-outline-primary"
onclick="editDetail('{{ phone.id }}', '{{ phone.detail_value }}', '{{ phone.detail_label or '' }}', 'phone')">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger"
onclick="deleteDetail('{{ phone.id }}')">
<i class="bi bi-trash"></i>
</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted mb-0">Keine Telefonnummern hinterlegt.</p>
{% endif %}
</div>
</div>
<!-- Email Addresses -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-envelope"></i> E-Mail-Adressen</h5>
<button class="btn btn-sm btn-primary" onclick="showAddEmailModal()">
<i class="bi bi-plus"></i>
</button>
</div>
<div class="card-body">
{% if contact.emails %}
<ul class="list-group list-group-flush">
{% for email in contact.emails %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<a href="mailto:{{ email.detail_value }}">{{ email.detail_value }}</a>
{% if email.detail_label %}
<span class="badge bg-secondary">{{ email.detail_label }}</span>
{% endif %}
</div>
<div>
<button class="btn btn-sm btn-outline-primary"
onclick="editDetail('{{ email.id }}', '{{ email.detail_value }}', '{{ email.detail_label or '' }}', 'email')">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger"
onclick="deleteDetail('{{ email.id }}')">
<i class="bi bi-trash"></i>
</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted mb-0">Keine E-Mail-Adressen hinterlegt.</p>
{% endif %}
</div>
</div>
</div>
<!-- Notes -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-journal-text"></i> Notizen</h5>
</div>
<div class="card-body">
<!-- New Note Form -->
<div class="mb-3">
<textarea class="form-control" id="newNoteText" rows="3"
placeholder="Neue Notiz hinzufügen..."></textarea>
<button class="btn btn-primary btn-sm mt-2" onclick="addNote()">
<i class="bi bi-plus"></i> Notiz speichern
</button>
</div>
<!-- Notes List -->
<div id="notesList">
{% for note in contact.notes %}
<div class="card mb-2" id="note-{{ note.id }}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<small class="text-muted">
<i class="bi bi-clock"></i>
{{ note.created_at.strftime('%d.%m.%Y %H:%M') }}
{% if note.created_by %} • {{ note.created_by }}{% endif %}
{% if note.version > 1 %}
<span class="badge bg-info">v{{ note.version }}</span>
{% endif %}
</small>
<div>
<button class="btn btn-sm btn-link p-0 mx-1"
onclick="editNote('{{ note.id }}')">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-link text-danger p-0 mx-1"
onclick="deleteNote('{{ note.id }}')">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="note-content" id="note-content-{{ note.id }}">
{{ note.note_text|nl2br|safe }}
</div>
<div class="note-edit d-none" id="note-edit-{{ note.id }}">
<textarea class="form-control mb-2" id="note-edit-text-{{ note.id }}">{{ note.note_text }}</textarea>
<button class="btn btn-sm btn-primary" onclick="saveNote('{{ note.id }}')">
Speichern
</button>
<button class="btn btn-sm btn-secondary" onclick="cancelEdit('{{ note.id }}')">
Abbrechen
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% if not contact.notes %}
<p class="text-muted">Noch keine Notizen vorhanden.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
<!-- Edit Contact Modal -->
<div class="modal fade" id="editContactModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Kontakt bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editContactForm">
<div class="row">
<div class="col-md-6 mb-3">
<label for="editFirstName" class="form-label">Vorname</label>
<input type="text" class="form-control" id="editFirstName"
value="{{ contact.first_name }}" required>
</div>
<div class="col-md-6 mb-3">
<label for="editLastName" class="form-label">Nachname</label>
<input type="text" class="form-control" id="editLastName"
value="{{ contact.last_name }}" required>
</div>
</div>
<div class="mb-3">
<label for="editPosition" class="form-label">Position</label>
<input type="text" class="form-control" id="editPosition"
value="{{ contact.position or '' }}">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" onclick="updateContact()">Speichern</button>
</div>
</div>
</div>
</div>
<!-- Add Phone Modal -->
<div class="modal fade" id="addPhoneModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Telefonnummer hinzufügen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addPhoneForm">
<div class="mb-3">
<label for="phoneNumber" class="form-label">Telefonnummer</label>
<input type="tel" class="form-control" id="phoneNumber" required>
</div>
<div class="mb-3">
<label for="phoneType" class="form-label">Typ</label>
<select class="form-select" id="phoneType">
<option value="">Bitte wählen...</option>
<option value="Mobil">Mobil</option>
<option value="Geschäftlich">Geschäftlich</option>
<option value="Privat">Privat</option>
<option value="Fax">Fax</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" onclick="savePhone()">Speichern</button>
</div>
</div>
</div>
</div>
<!-- Add Email Modal -->
<div class="modal fade" id="addEmailModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">E-Mail-Adresse hinzufügen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addEmailForm">
<div class="mb-3">
<label for="emailAddress" class="form-label">E-Mail-Adresse</label>
<input type="email" class="form-control" id="emailAddress" required>
</div>
<div class="mb-3">
<label for="emailType" class="form-label">Typ</label>
<select class="form-select" id="emailType">
<option value="">Bitte wählen...</option>
<option value="Geschäftlich">Geschäftlich</option>
<option value="Privat">Privat</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" onclick="saveEmail()">Speichern</button>
</div>
</div>
</div>
</div>
<!-- Edit Detail Modal -->
<div class="modal fade" id="editDetailModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editDetailTitle">Detail bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editDetailForm">
<input type="hidden" id="editDetailId">
<input type="hidden" id="editDetailType">
<div class="mb-3">
<label for="editDetailValue" class="form-label" id="editDetailValueLabel">Wert</label>
<input type="text" class="form-control" id="editDetailValue" required>
</div>
<div class="mb-3">
<label for="editDetailLabel" class="form-label">Typ</label>
<select class="form-select" id="editDetailLabel">
<option value="">Bitte wählen...</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" onclick="updateDetail()">Speichern</button>
</div>
</div>
</div>
</div>
<script>
const contactId = '{{ contact.id }}';
// Contact functions
function editContact() {
new bootstrap.Modal(document.getElementById('editContactModal')).show();
}
async function updateContact() {
const firstName = document.getElementById('editFirstName').value.trim();
const lastName = document.getElementById('editLastName').value.trim();
const position = document.getElementById('editPosition').value.trim();
if (!firstName || !lastName) {
alert('Bitte geben Sie Vor- und Nachname ein.');
return;
}
try {
const response = await fetch(`/leads/api/contacts/${contactId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: firstName,
last_name: lastName,
position: position
})
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (error) {
alert('Fehler beim Speichern: ' + error.message);
}
}
// Phone functions
function showAddPhoneModal() {
document.getElementById('addPhoneForm').reset();
new bootstrap.Modal(document.getElementById('addPhoneModal')).show();
}
async function savePhone() {
const phoneNumber = document.getElementById('phoneNumber').value.trim();
const phoneType = document.getElementById('phoneType').value;
if (!phoneNumber) {
alert('Bitte geben Sie eine Telefonnummer ein.');
return;
}
try {
const response = await fetch(`/leads/api/contacts/${contactId}/phones`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone_number: phoneNumber,
phone_type: phoneType
})
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (error) {
alert('Fehler beim Speichern: ' + error.message);
}
}
// Email functions
function showAddEmailModal() {
document.getElementById('addEmailForm').reset();
new bootstrap.Modal(document.getElementById('addEmailModal')).show();
}
async function saveEmail() {
const email = document.getElementById('emailAddress').value.trim();
const emailType = document.getElementById('emailType').value;
if (!email) {
alert('Bitte geben Sie eine E-Mail-Adresse ein.');
return;
}
try {
const response = await fetch(`/leads/api/contacts/${contactId}/emails`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email,
email_type: emailType
})
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (error) {
alert('Fehler beim Speichern: ' + error.message);
}
}
// Edit detail
function editDetail(detailId, detailValue, detailLabel, detailType) {
document.getElementById('editDetailId').value = detailId;
document.getElementById('editDetailType').value = detailType;
document.getElementById('editDetailValue').value = detailValue;
// Set appropriate input type and options based on detail type
const valueInput = document.getElementById('editDetailValue');
const labelSelect = document.getElementById('editDetailLabel');
const valueLabel = document.getElementById('editDetailValueLabel');
labelSelect.innerHTML = '<option value="">Bitte wählen...</option>';
if (detailType === 'phone') {
valueInput.type = 'tel';
valueLabel.textContent = 'Telefonnummer';
document.getElementById('editDetailTitle').textContent = 'Telefonnummer bearbeiten';
// Add phone type options
['Mobil', 'Geschäftlich', 'Privat', 'Fax'].forEach(type => {
const option = document.createElement('option');
option.value = type;
option.textContent = type;
if (type === detailLabel) option.selected = true;
labelSelect.appendChild(option);
});
} else if (detailType === 'email') {
valueInput.type = 'email';
valueLabel.textContent = 'E-Mail-Adresse';
document.getElementById('editDetailTitle').textContent = 'E-Mail-Adresse bearbeiten';
// Add email type options
['Geschäftlich', 'Privat'].forEach(type => {
const option = document.createElement('option');
option.value = type;
option.textContent = type;
if (type === detailLabel) option.selected = true;
labelSelect.appendChild(option);
});
}
new bootstrap.Modal(document.getElementById('editDetailModal')).show();
}
// Update detail
async function updateDetail() {
const detailId = document.getElementById('editDetailId').value;
const detailValue = document.getElementById('editDetailValue').value.trim();
const detailLabel = document.getElementById('editDetailLabel').value;
if (!detailValue) {
alert('Bitte geben Sie einen Wert ein.');
return;
}
try {
const response = await fetch(`/leads/api/details/${detailId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
detail_value: detailValue,
detail_label: detailLabel
})
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (error) {
alert('Fehler beim Speichern: ' + error.message);
}
}
// Delete detail
async function deleteDetail(detailId) {
if (!confirm('Möchten Sie diesen Eintrag wirklich löschen?')) {
return;
}
try {
const response = await fetch(`/leads/api/details/${detailId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler beim Löschen');
}
} catch (error) {
alert('Fehler: ' + error.message);
}
}
// Note functions
async function addNote() {
const noteText = document.getElementById('newNoteText').value.trim();
if (!noteText) {
alert('Bitte geben Sie eine Notiz ein.');
return;
}
try {
const response = await fetch(`/leads/api/contacts/${contactId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note_text: noteText })
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (error) {
alert('Fehler beim Speichern: ' + error.message);
}
}
function editNote(noteId) {
// Text aus dem angezeigten Content holen
const contentDiv = document.getElementById(`note-content-${noteId}`);
const textarea = document.getElementById(`note-edit-text-${noteId}`);
// Der Text ist bereits im Textarea durch das Template
document.getElementById(`note-content-${noteId}`).classList.add('d-none');
document.getElementById(`note-edit-${noteId}`).classList.remove('d-none');
// Fokus auf Textarea setzen
textarea.focus();
}
function cancelEdit(noteId) {
document.getElementById(`note-content-${noteId}`).classList.remove('d-none');
document.getElementById(`note-edit-${noteId}`).classList.add('d-none');
}
async function saveNote(noteId) {
const noteText = document.getElementById(`note-edit-text-${noteId}`).value.trim();
if (!noteText) {
alert('Notiz darf nicht leer sein.');
return;
}
try {
const response = await fetch(`/leads/api/notes/${noteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note_text: noteText })
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (error) {
alert('Fehler beim Speichern: ' + error.message);
}
}
async function deleteNote(noteId) {
if (!confirm('Möchten Sie diese Notiz wirklich löschen?')) {
return;
}
try {
const response = await fetch(`/leads/api/notes/${noteId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler beim Löschen');
}
} catch (error) {
alert('Fehler: ' + error.message);
}
}
</script>
<style>
.note-content {
white-space: pre-wrap;
}
</style>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block title %}{{ institution.name }} - Lead-Details{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row mb-4">
<div class="col-md-8">
<h1 class="h2 mb-0">
<i class="bi bi-building"></i> {{ institution.name }}
</h1>
<small class="text-muted">
Erstellt am {{ institution.created_at.strftime('%d.%m.%Y') }}
{% if institution.created_by %}von {{ institution.created_by }}{% endif %}
</small>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-primary" onclick="showCreateContactModal()">
<i class="bi bi-person-plus"></i> Neuer Kontakt
</button>
<a href="{{ url_for('leads.institutions') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Zurück
</a>
</div>
</div>
<!-- Contacts Table -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-people"></i> Kontakte
<span class="badge bg-secondary">{{ contacts|length }}</span>
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Position</th>
<th>Erstellt am</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for contact in contacts %}
<tr>
<td>
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}"
class="text-decoration-none">
<strong>{{ contact.first_name }} {{ contact.last_name }}</strong>
</a>
</td>
<td>{{ contact.position or '-' }}</td>
<td>{{ contact.created_at.strftime('%d.%m.%Y') }}</td>
<td>
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Details
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not contacts %}
<div class="text-center py-4">
<p class="text-muted">Noch keine Kontakte für diese Institution.</p>
<button class="btn btn-primary" onclick="showCreateContactModal()">
<i class="bi bi-person-plus"></i> Ersten Kontakt anlegen
</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Create Contact Modal -->
<div class="modal fade" id="contactModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Neuer Kontakt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="contactForm">
<div class="row">
<div class="col-md-6 mb-3">
<label for="firstName" class="form-label">Vorname</label>
<input type="text" class="form-control" id="firstName" required>
</div>
<div class="col-md-6 mb-3">
<label for="lastName" class="form-label">Nachname</label>
<input type="text" class="form-control" id="lastName" required>
</div>
</div>
<div class="mb-3">
<label for="position" class="form-label">Position</label>
<input type="text" class="form-control" id="position"
placeholder="z.B. Geschäftsführer, Vertriebsleiter">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" onclick="saveContact()">Speichern</button>
</div>
</div>
</div>
</div>
<script>
// Show create contact modal
function showCreateContactModal() {
document.getElementById('contactForm').reset();
new bootstrap.Modal(document.getElementById('contactModal')).show();
}
// Save contact
async function saveContact() {
const firstName = document.getElementById('firstName').value.trim();
const lastName = document.getElementById('lastName').value.trim();
const position = document.getElementById('position').value.trim();
if (!firstName || !lastName) {
alert('Bitte geben Sie Vor- und Nachname ein.');
return;
}
try {
const response = await fetch('/leads/api/contacts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
institution_id: '{{ institution.id }}',
first_name: firstName,
last_name: lastName,
position: position
})
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (error) {
alert('Fehler beim Speichern: ' + error.message);
}
}
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,189 @@
{% extends "base.html" %}
{% block title %}Lead-Verwaltung - Institutionen{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row mb-4">
<div class="col-md-8">
<h1 class="h2 mb-0">
<i class="bi bi-building"></i> Lead-Institutionen
</h1>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-primary" onclick="showCreateInstitutionModal()">
<i class="bi bi-plus-circle"></i> Neue Institution
</button>
<a href="{{ url_for('leads.all_contacts') }}" class="btn btn-outline-primary">
<i class="bi bi-people"></i> Alle Kontakte
</a>
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Zurück zu Kunden
</a>
</div>
</div>
<!-- Search Bar -->
<div class="row mb-4">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="searchInput"
placeholder="Institution suchen..." onkeyup="filterInstitutions()">
</div>
</div>
<div class="col-md-6 text-end">
<a href="{{ url_for('leads.export_leads', format='excel') }}" class="btn btn-outline-success">
<i class="bi bi-file-excel"></i> Excel Export
</a>
<a href="{{ url_for('leads.export_leads', format='csv') }}" class="btn btn-outline-info">
<i class="bi bi-file-text"></i> CSV Export
</a>
</div>
</div>
<!-- Institutions Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="institutionsTable">
<thead>
<tr>
<th>Institution</th>
<th>Anzahl Kontakte</th>
<th>Erstellt am</th>
<th>Erstellt von</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for institution in institutions %}
<tr>
<td>
<a href="{{ url_for('leads.institution_detail', institution_id=institution.id) }}"
class="text-decoration-none">
<strong>{{ institution.name }}</strong>
</a>
</td>
<td>
<span class="badge bg-secondary">{{ institution.contact_count }}</span>
</td>
<td>{{ institution.created_at.strftime('%d.%m.%Y') }}</td>
<td>{{ institution.created_by or '-' }}</td>
<td>
<a href="{{ url_for('leads.institution_detail', institution_id=institution.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Details
</a>
<button class="btn btn-sm btn-outline-secondary"
onclick="editInstitution('{{ institution.id }}', '{{ institution.name }}')">
<i class="bi bi-pencil"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not institutions %}
<div class="text-center py-4">
<p class="text-muted">Noch keine Institutionen vorhanden.</p>
<button class="btn btn-primary" onclick="showCreateInstitutionModal()">
<i class="bi bi-plus-circle"></i> Erste Institution anlegen
</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Create/Edit Institution Modal -->
<div class="modal fade" id="institutionModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="institutionModalTitle">Neue Institution</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="institutionForm">
<input type="hidden" id="institutionId">
<div class="mb-3">
<label for="institutionName" class="form-label">Name der Institution</label>
<input type="text" class="form-control" id="institutionName" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" onclick="saveInstitution()">Speichern</button>
</div>
</div>
</div>
</div>
<script>
// Filter institutions
function filterInstitutions() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const rows = document.querySelectorAll('#institutionsTable tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(searchTerm) ? '' : 'none';
});
}
// Show create institution modal
function showCreateInstitutionModal() {
document.getElementById('institutionModalTitle').textContent = 'Neue Institution';
document.getElementById('institutionId').value = '';
document.getElementById('institutionName').value = '';
new bootstrap.Modal(document.getElementById('institutionModal')).show();
}
// Edit institution
function editInstitution(id, name) {
document.getElementById('institutionModalTitle').textContent = 'Institution bearbeiten';
document.getElementById('institutionId').value = id;
document.getElementById('institutionName').value = name;
new bootstrap.Modal(document.getElementById('institutionModal')).show();
}
// Save institution
async function saveInstitution() {
const id = document.getElementById('institutionId').value;
const name = document.getElementById('institutionName').value.trim();
if (!name) {
alert('Bitte geben Sie einen Namen ein.');
return;
}
try {
const url = id
? `/leads/api/institutions/${id}`
: '/leads/api/institutions';
const method = id ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: name })
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
}
} catch (error) {
alert('Fehler beim Speichern: ' + error.message);
}
}
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,367 @@
{% extends "base.html" %}
{% block title %}Lead Management{% endblock %}
{% block extra_css %}
<style>
.nav-tabs .nav-link {
color: #495057;
border: 1px solid transparent;
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
}
.nav-tabs .nav-link.active {
color: #495057;
background-color: #fff;
border-color: #dee2e6 #dee2e6 #fff;
}
.tab-content {
border: 1px solid #dee2e6;
border-top: none;
padding: 1.5rem;
background-color: #fff;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="mb-4">
<h2>📊 Lead Management</h2>
</div>
<!-- Overview Stats -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-body text-center">
<h3 class="mb-0">{{ total_institutions }}</h3>
<small class="text-muted">Institutionen</small>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body text-center">
<h3 class="mb-0">{{ total_contacts }}</h3>
<small class="text-muted">Kontakte</small>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title">Schnellaktionen</h5>
<div class="d-flex gap-2">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addInstitutionModal">
<i class="bi bi-building-add"></i> Institution hinzufügen
</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addContactModal">
<i class="bi bi-person-plus"></i> Kontakt hinzufügen
</button>
<a href="{{ url_for('leads.export_leads') }}" class="btn btn-outline-secondary">
<i class="bi bi-download"></i> Exportieren
</a>
</div>
</div>
</div>
<!-- Tabbed View -->
<div class="card">
<div class="card-body p-0">
<!-- Tabs -->
<ul class="nav nav-tabs" id="leadTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="institutions-tab" data-bs-toggle="tab" data-bs-target="#institutions" type="button" role="tab">
<i class="bi bi-building"></i> Institutionen
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="contacts-tab" data-bs-toggle="tab" data-bs-target="#contacts" type="button" role="tab">
<i class="bi bi-people"></i> Kontakte
</button>
</li>
</ul>
<!-- Tab Content -->
<div class="tab-content" id="leadTabContent">
<!-- Institutions Tab -->
<div class="tab-pane fade show active" id="institutions" role="tabpanel">
<!-- Filter for Institutions -->
<div class="row mb-3">
<div class="col-md-6">
<input type="text" class="form-control" id="institutionSearch" placeholder="Institution suchen...">
</div>
<div class="col-md-3">
<select class="form-select" id="institutionSort">
<option value="name">Alphabetisch</option>
<option value="date">Datum hinzugefügt</option>
<option value="contacts">Anzahl Kontakte</option>
</select>
</div>
<div class="col-md-3">
<button class="btn btn-outline-primary w-100" id="filterNoContacts">
<i class="bi bi-funnel"></i> Ohne Kontakte
</button>
</div>
</div>
<!-- Institutions List -->
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Anzahl Kontakte</th>
<th>Erstellt am</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for institution in institutions %}
<tr>
<td>
<a href="{{ url_for('leads.institution_detail', institution_id=institution.id) }}">
{{ institution.name }}
</a>
</td>
<td>{{ institution.contact_count }}</td>
<td>{{ institution.created_at.strftime('%d.%m.%Y') }}</td>
<td>
<a href="{{ url_for('leads.institution_detail', institution_id=institution.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Details
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not institutions %}
<p class="text-muted text-center py-3">Keine Institutionen vorhanden.</p>
{% endif %}
</div>
</div>
<!-- Contacts Tab -->
<div class="tab-pane fade" id="contacts" role="tabpanel">
<!-- Filter for Contacts -->
<div class="row mb-3">
<div class="col-md-4">
<input type="text" class="form-control" id="contactSearch" placeholder="Kontakt suchen...">
</div>
<div class="col-md-3">
<select class="form-select" id="institutionFilter">
<option value="">Alle Institutionen</option>
{% for institution in institutions %}
<option value="{{ institution.id }}">{{ institution.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-outline-primary w-100" id="filterNoEmail">
<i class="bi bi-envelope-slash"></i> Ohne E-Mail
</button>
</div>
<div class="col-md-3">
<button class="btn btn-outline-primary w-100" id="filterNoPhone">
<i class="bi bi-telephone-x"></i> Ohne Telefon
</button>
</div>
</div>
<!-- Contacts List -->
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Position</th>
<th>Institution</th>
<th>E-Mail</th>
<th>Telefon</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for contact in all_contacts %}
<tr>
<td>
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}">
{{ contact.first_name }} {{ contact.last_name }}
</a>
</td>
<td>{{ contact.position or '-' }}</td>
<td>{{ contact.institution_name }}</td>
<td>
{% if contact.emails %}
<small>{{ contact.emails[0] }}</small>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if contact.phones %}
<small>{{ contact.phones[0] }}</small>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Details
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not all_contacts %}
<p class="text-muted text-center py-3">Keine Kontakte vorhanden.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add Institution Modal -->
<div class="modal fade" id="addInstitutionModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('leads.add_institution') }}">
<div class="modal-header">
<h5 class="modal-title">Neue Institution hinzufügen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="name" class="form-label">Name der Institution</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add Contact Modal -->
<div class="modal fade" id="addContactModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" action="{{ url_for('leads.add_contact') }}">
<div class="modal-header">
<h5 class="modal-title">Neuen Kontakt hinzufügen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="institution_id" class="form-label">Institution</label>
<select class="form-select" id="institution_id" name="institution_id" required>
<option value="">Bitte wählen...</option>
{% for institution in institutions %}
<option value="{{ institution.id }}">{{ institution.name }}</option>
{% endfor %}
</select>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="first_name" class="form-label">Vorname</label>
<input type="text" class="form-control" id="first_name" name="first_name" required>
</div>
<div class="col-md-6 mb-3">
<label for="last_name" class="form-label">Nachname</label>
<input type="text" class="form-control" id="last_name" name="last_name" required>
</div>
</div>
<div class="mb-3">
<label for="position" class="form-label">Position</label>
<input type="text" class="form-control" id="position" name="position">
</div>
<div class="mb-3">
<label for="email" class="form-label">E-Mail</label>
<input type="email" class="form-control" id="email" name="email">
</div>
<div class="mb-3">
<label for="phone" class="form-label">Telefon</label>
<input type="tel" class="form-control" id="phone" name="phone">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Filter functionality
document.addEventListener('DOMContentLoaded', function() {
// Institution search
const institutionSearch = document.getElementById('institutionSearch');
if (institutionSearch) {
institutionSearch.addEventListener('input', function() {
filterTable('institutions', this.value.toLowerCase(), 0);
});
}
// Contact search
const contactSearch = document.getElementById('contactSearch');
if (contactSearch) {
contactSearch.addEventListener('input', function() {
filterTable('contacts', this.value.toLowerCase(), [0, 1, 2]);
});
}
// Filter table function
function filterTable(tabId, searchTerm, columnIndices) {
const table = document.querySelector(`#${tabId} table tbody`);
const rows = table.getElementsByTagName('tr');
Array.from(rows).forEach(row => {
let match = false;
const indices = Array.isArray(columnIndices) ? columnIndices : [columnIndices];
indices.forEach(index => {
const cell = row.getElementsByTagName('td')[index];
if (cell && cell.textContent.toLowerCase().includes(searchTerm)) {
match = true;
}
});
row.style.display = match || searchTerm === '' ? '' : 'none';
});
}
// Institution filter
const institutionFilter = document.getElementById('institutionFilter');
if (institutionFilter) {
institutionFilter.addEventListener('change', function() {
const selectedInstitution = this.value;
const table = document.querySelector('#contacts table tbody');
const rows = table.getElementsByTagName('tr');
Array.from(rows).forEach(row => {
const institutionCell = row.getElementsByTagName('td')[2];
if (selectedInstitution === '' || institutionCell.textContent === this.options[this.selectedIndex].text) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
});
}
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1 @@
from .error_middleware import ErrorHandlingMiddleware

Datei anzeigen

@@ -0,0 +1,54 @@
import time
import uuid
from typing import Optional
from flask import request, g
from werkzeug.exceptions import HTTPException
from core.exceptions import BaseApplicationException
from core.monitoring import track_error
from core.logging_config import get_logger
logger = get_logger(__name__)
class ErrorHandlingMiddleware:
def __init__(self, app=None):
self.app = app
if app:
self.init_app(app)
def init_app(self, app):
app.before_request(self._before_request)
app.teardown_appcontext(self._teardown_request)
def _before_request(self):
g.request_id = request.headers.get('X-Request-ID', str(uuid.uuid4()))
g.start_time = time.time()
g.errors = []
def _teardown_request(self, exception=None):
if exception:
self._handle_exception(exception)
if hasattr(g, 'errors') and g.errors:
for error in g.errors:
if isinstance(error, BaseApplicationException):
track_error(error)
def _handle_exception(self, exception):
if isinstance(exception, BaseApplicationException):
track_error(exception)
elif isinstance(exception, HTTPException):
pass
else:
logger.error(
f"Unhandled exception: {type(exception).__name__}",
exc_info=True,
extra={
'request_id': getattr(g, 'request_id', 'unknown'),
'endpoint': request.endpoint,
'method': request.method,
'path': request.path
}
)

178
v2_adminpanel/models.py Normale Datei
Datei anzeigen

@@ -0,0 +1,178 @@
# Temporary models file - will be expanded in Phase 3
from db import execute_query, get_db_connection, get_db_cursor
import logging
logger = logging.getLogger(__name__)
def get_user_by_username(username):
"""Get user from database by username"""
result = execute_query(
"""
SELECT id, username, password_hash, email, totp_secret, totp_enabled,
backup_codes, last_password_change, failed_2fa_attempts
FROM users WHERE username = %s
""",
(username,),
fetch_one=True
)
if result:
return {
'id': result[0],
'username': result[1],
'password_hash': result[2],
'email': result[3],
'totp_secret': result[4],
'totp_enabled': result[5],
'backup_codes': result[6],
'last_password_change': result[7],
'failed_2fa_attempts': result[8]
}
return None
def get_licenses(show_fake=False):
"""Get all licenses from database"""
try:
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
if show_fake:
cur.execute("""
SELECT l.*, c.name as customer_name
FROM licenses l
LEFT JOIN customers c ON l.customer_id = c.id
ORDER BY l.created_at DESC
""")
else:
cur.execute("""
SELECT l.*, c.name as customer_name
FROM licenses l
LEFT JOIN customers c ON l.customer_id = c.id
WHERE l.is_fake = false
ORDER BY l.created_at DESC
""")
columns = [desc[0] for desc in cur.description]
licenses = []
for row in cur.fetchall():
license_dict = dict(zip(columns, row))
licenses.append(license_dict)
return licenses
except Exception as e:
logger.error(f"Error fetching licenses: {str(e)}")
return []
def get_license_by_id(license_id):
"""Get a specific license by ID"""
try:
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
SELECT l.*, c.name as customer_name
FROM licenses l
LEFT JOIN customers c ON l.customer_id = c.id
WHERE l.id = %s
""", (license_id,))
row = cur.fetchone()
if row:
columns = [desc[0] for desc in cur.description]
return dict(zip(columns, row))
return None
except Exception as e:
logger.error(f"Error fetching license {license_id}: {str(e)}")
return None
def get_customers(show_fake=False, search=None):
"""Get all customers from database"""
try:
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
query = """
SELECT c.*,
COUNT(DISTINCT l.id) as license_count,
COUNT(DISTINCT CASE WHEN l.is_active THEN l.id END) as active_licenses
FROM customers c
LEFT JOIN licenses l ON c.id = l.customer_id
"""
where_clauses = []
params = []
if not show_fake:
where_clauses.append("c.is_fake = false")
if search:
where_clauses.append("(LOWER(c.name) LIKE LOWER(%s) OR LOWER(c.email) LIKE LOWER(%s))")
search_pattern = f'%{search}%'
params.extend([search_pattern, search_pattern])
if where_clauses:
query += " WHERE " + " AND ".join(where_clauses)
query += " GROUP BY c.id ORDER BY c.name"
cur.execute(query, params)
columns = [desc[0] for desc in cur.description]
customers = []
for row in cur.fetchall():
customer_dict = dict(zip(columns, row))
customers.append(customer_dict)
return customers
except Exception as e:
logger.error(f"Error fetching customers: {str(e)}")
return []
def get_customer_by_id(customer_id):
"""Get a specific customer by ID"""
try:
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
SELECT c.*,
COUNT(DISTINCT l.id) as license_count,
COUNT(DISTINCT CASE WHEN l.is_active THEN l.id END) as active_licenses
FROM customers c
LEFT JOIN licenses l ON c.id = l.customer_id
WHERE c.id = %s
GROUP BY c.id
""", (customer_id,))
row = cur.fetchone()
if row:
columns = [desc[0] for desc in cur.description]
return dict(zip(columns, row))
return None
except Exception as e:
logger.error(f"Error fetching customer {customer_id}: {str(e)}")
return None
def get_active_sessions():
"""Get all is_active sessions"""
try:
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
SELECT s.*, l.license_key, c.name as customer_name
FROM sessions s
JOIN licenses l ON s.license_id = l.id
LEFT JOIN customers c ON l.customer_id = c.id
WHERE s.is_active = true
ORDER BY s.started_at DESC
""")
columns = [desc[0] for desc in cur.description]
sessions = []
for row in cur.fetchall():
session_dict = dict(zip(columns, row))
sessions.append(session_dict)
return sessions
except Exception as e:
logger.error(f"Error fetching is_active sessions: {str(e)}")
return []

Datei anzeigen

@@ -0,0 +1,17 @@
flask
flask-session
psycopg2-binary
python-dotenv
pyopenssl
pandas
openpyxl
cryptography
apscheduler
requests
python-dateutil
bcrypt
pyotp
qrcode[pil]
PyJWT
prometheus-flask-exporter
prometheus-client

Datei anzeigen

@@ -0,0 +1,2 @@
# Routes module initialization
# This module contains all Flask blueprints organized by functionality

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

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

Datei anzeigen

@@ -0,0 +1,439 @@
import os
import logging
import secrets
import string
from datetime import datetime, timedelta
from pathlib import Path
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file
import config
from auth.decorators import login_required
from utils.audit import log_audit
from utils.network import get_client_ip
from utils.export import create_batch_export
from db import get_connection, get_db_connection, get_db_cursor
from models import get_customers
# Create Blueprint
batch_bp = Blueprint('batch', __name__)
def generate_license_key():
"""Generiert einen zufälligen Lizenzschlüssel"""
chars = string.ascii_uppercase + string.digits
return '-'.join([''.join(secrets.choice(chars) for _ in range(4)) for _ in range(4)])
@batch_bp.route("/batch", methods=["GET", "POST"])
@login_required
def batch_create():
"""Batch-Erstellung von Lizenzen"""
customers = get_customers()
if request.method == "POST":
conn = get_connection()
cur = conn.cursor()
try:
# Form data
customer_id = int(request.form['customer_id'])
license_type = request.form['license_type']
count = int(request.form['quantity']) # Korrigiert von 'count' zu 'quantity'
valid_from = request.form['valid_from']
valid_until = request.form['valid_until']
device_limit = int(request.form['device_limit'])
# Resource allocation parameters
domain_count = int(request.form.get('domain_count', 0))
ipv4_count = int(request.form.get('ipv4_count', 0))
phone_count = int(request.form.get('phone_count', 0))
# Validierung
if count < 1 or count > 100:
flash('Anzahl muss zwischen 1 und 100 liegen!', 'error')
return redirect(url_for('batch.batch_create'))
# Hole Kundendaten inkl. is_fake Status
cur.execute("SELECT name, email, is_fake FROM customers WHERE id = %s", (customer_id,))
customer = cur.fetchone()
if not customer:
flash('Kunde nicht gefunden!', 'error')
return redirect(url_for('batch.batch_create'))
# Lizenz erbt immer den is_fake Status vom Kunden
is_fake = customer[2]
created_licenses = []
# Erstelle Lizenzen
for i in range(count):
license_key = generate_license_key()
# Prüfe ob Schlüssel bereits existiert
while True:
cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,))
if not cur.fetchone():
break
license_key = generate_license_key()
# Erstelle Lizenz
cur.execute("""
INSERT INTO licenses (
license_key, customer_id,
license_type, valid_from, valid_until, device_limit,
is_fake, created_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
license_key, customer_id,
license_type, valid_from, valid_until, device_limit,
is_fake, datetime.now()
))
license_id = cur.fetchone()[0]
created_licenses.append({
'id': license_id,
'license_key': license_key
})
# Allocate resources if requested
if domain_count > 0 or ipv4_count > 0 or phone_count > 0:
# Allocate domains
if domain_count > 0:
cur.execute("""
UPDATE resource_pool
SET status = 'allocated',
license_id = %s,
allocated_at = NOW()
WHERE id IN (
SELECT id FROM resource_pool
WHERE type = 'domain'
AND status = 'available'
AND is_fake = %s
ORDER BY id
LIMIT %s
)
""", (license_id, is_fake, domain_count))
# Allocate IPv4s
if ipv4_count > 0:
cur.execute("""
UPDATE resource_pool
SET status = 'allocated',
license_id = %s,
allocated_at = NOW()
WHERE id IN (
SELECT id FROM resource_pool
WHERE type = 'ipv4'
AND status = 'available'
AND is_fake = %s
ORDER BY id
LIMIT %s
)
""", (license_id, is_fake, ipv4_count))
# Allocate phones
if phone_count > 0:
cur.execute("""
UPDATE resource_pool
SET status = 'allocated',
license_id = %s,
allocated_at = NOW()
WHERE id IN (
SELECT id FROM resource_pool
WHERE type = 'phone'
AND status = 'available'
AND is_fake = %s
ORDER BY id
LIMIT %s
)
""", (license_id, is_fake, phone_count))
# Audit-Log
log_audit('CREATE', 'license', license_id,
new_values={
'license_key': license_key,
'customer_name': customer[0],
'batch_creation': True
})
conn.commit()
# Speichere erstellte Lizenzen in Session für Export
session['batch_created_licenses'] = created_licenses
session['batch_customer_name'] = customer[0]
session['batch_customer_email'] = customer[1]
flash(f'{count} Lizenzen erfolgreich erstellt!', 'success')
# Weiterleitung zum Export
return redirect(url_for('batch.batch_export'))
except Exception as e:
conn.rollback()
logging.error(f"Fehler bei Batch-Erstellung: {str(e)}")
flash('Fehler bei der Batch-Erstellung!', 'error')
finally:
cur.close()
conn.close()
return render_template("batch_form.html", customers=customers)
@batch_bp.route("/batch/export")
@login_required
def batch_export():
"""Exportiert die zuletzt erstellten Batch-Lizenzen"""
created_licenses = session.get('batch_created_licenses', [])
if not created_licenses:
flash('Keine Lizenzen zum Exportieren gefunden!', 'error')
return redirect(url_for('batch.batch_create'))
conn = get_connection()
cur = conn.cursor()
try:
# Hole vollständige Lizenzdaten
license_ids = [l['id'] for l in created_licenses]
cur.execute("""
SELECT
l.license_key, c.name, c.email,
l.license_type, l.valid_from, l.valid_until,
l.device_limit, l.is_fake, l.created_at
FROM licenses l
JOIN customers c ON l.customer_id = c.id
WHERE l.id = ANY(%s)
ORDER BY l.id
""", (license_ids,))
licenses = []
for row in cur.fetchall():
licenses.append({
'license_key': row[0],
'customer_name': row[1],
'customer_email': row[2],
'license_type': row[3],
'valid_from': row[4],
'valid_until': row[5],
'device_limit': row[6],
'is_fake': row[7],
'created_at': row[8]
})
# Lösche aus Session
session.pop('batch_created_licenses', None)
session.pop('batch_customer_name', None)
session.pop('batch_customer_email', None)
# Erstelle und sende Excel-Export
return create_batch_export(licenses)
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
flash('Fehler beim Exportieren der Lizenzen!', 'error')
return redirect(url_for('batch.batch_create'))
finally:
cur.close()
conn.close()
@batch_bp.route("/batch/update", methods=["GET", "POST"])
@login_required
def batch_update():
"""Batch-Update von Lizenzen"""
if request.method == "POST":
conn = get_connection()
cur = conn.cursor()
try:
# Form data
license_keys = request.form.get('license_keys', '').strip().split('\n')
license_keys = [key.strip() for key in license_keys if key.strip()]
if not license_keys:
flash('Keine Lizenzschlüssel angegeben!', 'error')
return redirect(url_for('batch.batch_update'))
# Update-Parameter
updates = []
params = []
if 'update_valid_until' in request.form and request.form['valid_until']:
updates.append("valid_until = %s")
params.append(request.form['valid_until'])
if 'update_device_limit' in request.form and request.form['device_limit']:
updates.append("device_limit = %s")
params.append(int(request.form['device_limit']))
if 'update_active' in request.form:
updates.append("is_active = %s")
params.append('is_active' in request.form)
if not updates:
flash('Keine Änderungen angegeben!', 'error')
return redirect(url_for('batch.batch_update'))
# Führe Updates aus
updated_count = 0
not_found = []
for license_key in license_keys:
# Prüfe ob Lizenz existiert
cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,))
result = cur.fetchone()
if not result:
not_found.append(license_key)
continue
license_id = result[0]
# Update ausführen
update_params = params + [license_id]
cur.execute(f"""
UPDATE licenses
SET {', '.join(updates)}
WHERE id = %s
""", update_params)
# Audit-Log
log_audit('BATCH_UPDATE', 'license', license_id,
additional_info=f"Batch-Update: {', '.join(updates)}")
updated_count += 1
conn.commit()
# Feedback
flash(f'{updated_count} Lizenzen erfolgreich aktualisiert!', 'success')
if not_found:
flash(f'{len(not_found)} Lizenzen nicht gefunden: {", ".join(not_found[:5])}{"..." if len(not_found) > 5 else ""}', 'warning')
except Exception as e:
conn.rollback()
logging.error(f"Fehler bei Batch-Update: {str(e)}")
flash('Fehler beim Batch-Update!', 'error')
finally:
cur.close()
conn.close()
return render_template("batch_update.html")
@batch_bp.route("/batch/import", methods=["GET", "POST"])
@login_required
def batch_import():
"""Import von Lizenzen aus CSV/Excel"""
if request.method == "POST":
if 'file' not in request.files:
flash('Keine Datei ausgewählt!', 'error')
return redirect(url_for('batch.batch_import'))
file = request.files['file']
if file.filename == '':
flash('Keine Datei ausgewählt!', 'error')
return redirect(url_for('batch.batch_import'))
# Verarbeite Datei
try:
import pandas as pd
# Lese Datei
if file.filename.endswith('.csv'):
df = pd.read_csv(file)
elif file.filename.endswith(('.xlsx', '.xls')):
df = pd.read_excel(file)
else:
flash('Ungültiges Dateiformat! Nur CSV und Excel erlaubt.', 'error')
return redirect(url_for('batch.batch_import'))
# Validiere Spalten
required_columns = ['customer_email', 'license_type', 'valid_from', 'valid_until', 'device_limit']
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
flash(f'Fehlende Spalten: {", ".join(missing_columns)}', 'error')
return redirect(url_for('batch.batch_import'))
conn = get_connection()
cur = conn.cursor()
imported_count = 0
errors = []
for index, row in df.iterrows():
try:
# Finde oder erstelle Kunde
email = row['customer_email']
cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,))
customer = cur.fetchone()
if not customer:
# Erstelle neuen Kunden
name = row.get('customer_name', email.split('@')[0])
# Neue Kunden werden immer als Fake erstellt in der Testphase
# TODO: Nach Testphase muss hier die Business-Logik angepasst werden
is_fake = True
cur.execute("""
INSERT INTO customers (name, email, is_fake, created_at)
VALUES (%s, %s, %s, %s)
RETURNING id
""", (name, email, is_fake, datetime.now()))
customer_id = cur.fetchone()[0]
customer_name = name
else:
customer_id = customer[0]
customer_name = customer[1]
# Hole is_fake Status vom existierenden Kunden
cur.execute("SELECT is_fake FROM customers WHERE id = %s", (customer_id,))
is_fake = cur.fetchone()[0]
# Generiere Lizenzschlüssel
license_key = row.get('license_key', generate_license_key())
# Erstelle Lizenz - is_fake wird vom Kunden geerbt
cur.execute("""
INSERT INTO licenses (
license_key, customer_id,
license_type, valid_from, valid_until, device_limit,
is_fake, created_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
license_key, customer_id,
row['license_type'], row['valid_from'], row['valid_until'],
int(row['device_limit']), is_fake,
datetime.now()
))
license_id = cur.fetchone()[0]
imported_count += 1
# Audit-Log
log_audit('IMPORT', 'license', license_id,
additional_info=f"Importiert aus {file.filename}")
except Exception as e:
errors.append(f"Zeile {index + 2}: {str(e)}")
conn.commit()
# Feedback
flash(f'{imported_count} Lizenzen erfolgreich importiert!', 'success')
if errors:
flash(f'{len(errors)} Fehler aufgetreten. Erste Fehler: {"; ".join(errors[:3])}', 'warning')
except Exception as e:
logging.error(f"Fehler beim Import: {str(e)}")
flash(f'Fehler beim Import: {str(e)}', 'error')
finally:
if 'conn' in locals():
cur.close()
conn.close()
return render_template("batch_import.html")

Datei anzeigen

@@ -0,0 +1,466 @@
import os
import logging
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 utils.audit import log_audit
from db import get_connection, get_db_connection, get_db_cursor
from models import get_customers, get_customer_by_id
# Create Blueprint
customer_bp = Blueprint('customers', __name__)
# Test route
@customer_bp.route("/test-customers")
def test_customers():
return "Customer blueprint is working!"
@customer_bp.route("/customers")
@login_required
def customers():
show_fake = request.args.get('show_fake', 'false').lower() == 'true'
search = request.args.get('search', '').strip()
page = request.args.get('page', 1, type=int)
per_page = 20
sort = request.args.get('sort', 'name')
order = request.args.get('order', 'asc')
customers_list = get_customers(show_fake=show_fake, search=search)
# Sortierung
if sort == 'name':
customers_list.sort(key=lambda x: x['name'].lower(), reverse=(order == 'desc'))
elif sort == 'email':
customers_list.sort(key=lambda x: x['email'].lower(), reverse=(order == 'desc'))
elif sort == 'created_at':
customers_list.sort(key=lambda x: x['created_at'], reverse=(order == 'desc'))
# Paginierung
total_customers = len(customers_list)
total_pages = (total_customers + per_page - 1) // per_page
start = (page - 1) * per_page
end = start + per_page
paginated_customers = customers_list[start:end]
return render_template("customers.html",
customers=paginated_customers,
show_fake=show_fake,
search=search,
page=page,
per_page=per_page,
total_pages=total_pages,
total_customers=total_customers,
sort=sort,
order=order,
current_order=order)
@customer_bp.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
@login_required
def edit_customer(customer_id):
if request.method == "POST":
try:
# Get current customer data for comparison
current_customer = get_customer_by_id(customer_id)
if not current_customer:
flash('Kunde nicht gefunden!', 'error')
return redirect(url_for('customers.customers_licenses'))
with get_db_connection() as conn:
cur = conn.cursor()
try:
# Update customer data
new_values = {
'name': request.form['name'],
'email': request.form['email'],
'is_fake': 'is_fake' in request.form
}
cur.execute("""
UPDATE customers
SET name = %s, email = %s, is_fake = %s
WHERE id = %s
""", (
new_values['name'],
new_values['email'],
new_values['is_fake'],
customer_id
))
conn.commit()
# Log changes
log_audit('UPDATE', 'customer', customer_id,
old_values={
'name': current_customer['name'],
'email': current_customer['email'],
'is_fake': current_customer.get('is_fake', False)
},
new_values=new_values)
flash('Kunde erfolgreich aktualisiert!', 'success')
# Redirect mit show_fake Parameter wenn nötig
redirect_url = url_for('customers.customers_licenses')
if request.form.get('show_fake') == 'true':
redirect_url += '?show_fake=true'
return redirect(redirect_url)
finally:
cur.close()
except Exception as e:
logging.error(f"Fehler beim Aktualisieren des Kunden: {str(e)}")
flash('Fehler beim Aktualisieren des Kunden!', 'error')
return redirect(url_for('customers.customers_licenses'))
# GET request
customer_data = get_customer_by_id(customer_id)
if not customer_data:
flash('Kunde nicht gefunden!', 'error')
return redirect(url_for('customers.customers_licenses'))
return render_template("edit_customer.html", customer=customer_data)
@customer_bp.route("/customer/create", methods=["GET", "POST"])
@login_required
def create_customer():
if request.method == "POST":
conn = get_connection()
cur = conn.cursor()
try:
# Insert new customer
name = request.form['name']
email = request.form['email']
is_fake = 'is_fake' in request.form # Checkbox ist nur vorhanden wenn angekreuzt
cur.execute("""
INSERT INTO customers (name, email, is_fake, created_at)
VALUES (%s, %s, %s, %s)
RETURNING id
""", (name, email, is_fake, datetime.now()))
customer_id = cur.fetchone()[0]
conn.commit()
# Log creation
log_audit('CREATE', 'customer', customer_id,
new_values={
'name': name,
'email': email,
'is_fake': is_fake
})
if is_fake:
flash(f'Fake-Kunde {name} erfolgreich erstellt!', 'success')
else:
flash(f'Kunde {name} erfolgreich erstellt!', 'success')
# Redirect mit show_fake=true wenn Fake-Kunde erstellt wurde
if is_fake:
return redirect(url_for('customers.customers_licenses', show_fake='true'))
else:
return redirect(url_for('customers.customers_licenses'))
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Erstellen des Kunden: {str(e)}")
flash('Fehler beim Erstellen des Kunden!', 'error')
finally:
cur.close()
conn.close()
return render_template("create_customer.html")
@customer_bp.route("/customer/delete/<int:customer_id>", methods=["POST"])
@login_required
def delete_customer(customer_id):
conn = get_connection()
cur = conn.cursor()
try:
# Get customer data before deletion
customer_data = get_customer_by_id(customer_id)
if not customer_data:
flash('Kunde nicht gefunden!', 'error')
return redirect(url_for('customers.customers_licenses'))
# Check if customer has licenses
cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,))
license_count = cur.fetchone()[0]
if license_count > 0:
flash(f'Kunde kann nicht gelöscht werden - hat noch {license_count} Lizenz(en)!', 'error')
return redirect(url_for('customers.customers_licenses'))
# Delete the customer
cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
conn.commit()
# Log deletion
log_audit('DELETE', 'customer', customer_id,
old_values={
'name': customer_data['name'],
'email': customer_data['email']
})
flash(f'Kunde {customer_data["name"]} erfolgreich gelöscht!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Löschen des Kunden: {str(e)}")
flash('Fehler beim Löschen des Kunden!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('customers.customers_licenses'))
@customer_bp.route("/customers-licenses")
@login_required
def customers_licenses():
"""Zeigt die Übersicht von Kunden und deren Lizenzen"""
import logging
import psycopg2
logging.info("=== CUSTOMERS-LICENSES ROUTE CALLED ===")
# Get show_fake parameter from URL
show_fake = request.args.get('show_fake', 'false').lower() == 'true'
logging.info(f"show_fake parameter: {show_fake}")
try:
# Direkte Verbindung ohne Helper-Funktionen
conn = psycopg2.connect(
host=os.getenv("POSTGRES_HOST", "postgres"),
port=os.getenv("POSTGRES_PORT", "5432"),
dbname=os.getenv("POSTGRES_DB"),
user=os.getenv("POSTGRES_USER"),
password=os.getenv("POSTGRES_PASSWORD")
)
conn.set_client_encoding('UTF8')
cur = conn.cursor()
try:
# Hole alle Kunden mit ihren Lizenzen
# Wenn show_fake=false, zeige nur Nicht-Test-Kunden
query = """
SELECT
c.id,
c.name,
c.email,
c.created_at,
COUNT(l.id),
COUNT(CASE WHEN l.is_active = true THEN 1 END),
COUNT(CASE WHEN l.is_fake = true THEN 1 END),
MAX(l.created_at),
c.is_fake
FROM customers c
LEFT JOIN licenses l ON c.id = l.customer_id
WHERE (%s OR c.is_fake = false)
GROUP BY c.id, c.name, c.email, c.created_at, c.is_fake
ORDER BY c.name
"""
cur.execute(query, (show_fake,))
customers = []
results = cur.fetchall()
logging.info(f"=== QUERY RETURNED {len(results)} ROWS ===")
for idx, row in enumerate(results):
logging.info(f"Row {idx}: Type={type(row)}, Length={len(row) if hasattr(row, '__len__') else 'N/A'}")
customers.append({
'id': row[0],
'name': row[1],
'email': row[2],
'created_at': row[3],
'license_count': row[4],
'active_licenses': row[5],
'test_licenses': row[6],
'last_license_created': row[7],
'is_fake': row[8]
})
return render_template("customers_licenses.html",
customers=customers,
show_fake=show_fake)
finally:
cur.close()
conn.close()
except Exception as e:
import traceback
error_details = f"Fehler beim Laden der Kunden-Lizenz-Übersicht: {str(e)}\nType: {type(e)}\nTraceback: {traceback.format_exc()}"
logging.error(error_details)
flash(f'Datenbankfehler: {str(e)}', 'error')
return redirect(url_for('admin.dashboard'))
@customer_bp.route("/api/customer/<int:customer_id>/licenses")
@login_required
def api_customer_licenses(customer_id):
"""API-Endpunkt für die Lizenzen eines Kunden"""
conn = get_connection()
cur = conn.cursor()
try:
# Hole Kundeninformationen
customer = get_customer_by_id(customer_id)
if not customer:
return jsonify({'error': 'Kunde nicht gefunden'}), 404
# Hole alle Lizenzen des Kunden - vereinfachte Version ohne komplexe Subqueries
cur.execute("""
SELECT
l.id,
l.license_key,
l.license_type,
l.is_active,
l.is_fake,
l.valid_from,
l.valid_until,
l.device_limit,
l.created_at,
CASE
WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
WHEN l.is_active = false THEN 'inaktiv'
ELSE 'aktiv'
END as status,
COALESCE(l.domain_count, 0) as domain_count,
COALESCE(l.ipv4_count, 0) as ipv4_count,
COALESCE(l.phone_count, 0) as 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
conn2 = get_connection()
cur2 = conn2.cursor()
cur2.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 cur2.fetchall():
resource_data = {
'id': res_row[0],
'value': res_row[2],
'assigned_at': res_row[3].strftime('%Y-%m-%d %H:%M:%S') if res_row[3] else None
}
if res_row[1] == 'domain':
resources['domains'].append(resource_data)
elif res_row[1] == 'ipv4':
resources['ipv4s'].append(resource_data)
elif res_row[1] == 'phone':
resources['phones'].append(resource_data)
cur2.close()
conn2.close()
licenses.append({
'id': row[0],
'license_key': row[1],
'license_type': row[2],
'is_active': row[3],
'is_fake': row[4],
'valid_from': row[5].strftime('%Y-%m-%d') if row[5] else None,
'valid_until': row[6].strftime('%Y-%m-%d') if row[6] else None,
'device_limit': row[7],
'created_at': row[8].strftime('%Y-%m-%d %H:%M:%S') if row[8] else None,
'status': row[9],
'domain_count': row[10],
'ipv4_count': row[11],
'phone_count': row[12],
'active_sessions': 0, # Platzhalter
'registered_devices': 0, # Platzhalter
'active_devices': 0, # Platzhalter
'actual_domain_count': len(resources['domains']),
'actual_ipv4_count': len(resources['ipv4s']),
'actual_phone_count': len(resources['phones']),
'resources': resources,
# License Server Data (Platzhalter bis Implementation)
'recent_heartbeats': 0,
'last_heartbeat': None,
'active_server_devices': 0,
'unresolved_anomalies': 0
})
return jsonify({
'success': True, # Wichtig: Frontend erwartet dieses Feld
'customer': {
'id': customer['id'],
'name': customer['name'],
'email': customer['email'],
'is_fake': customer.get('is_fake', False) # Include the is_fake field
},
'licenses': licenses
})
except Exception as e:
import traceback
error_msg = f"Fehler beim Laden der Kundenlizenzen: {str(e)}\nTraceback: {traceback.format_exc()}"
logging.error(error_msg)
return jsonify({'error': f'Fehler beim Laden der Daten: {str(e)}', 'details': error_msg}), 500
finally:
cur.close()
conn.close()
@customer_bp.route("/api/customer/<int:customer_id>/quick-stats")
@login_required
def api_customer_quick_stats(customer_id):
"""Schnelle Statistiken für einen Kunden"""
conn = get_connection()
cur = conn.cursor()
try:
cur.execute("""
SELECT
COUNT(l.id) as total_licenses,
COUNT(CASE WHEN l.is_active = true THEN 1 END) as active_licenses,
COUNT(CASE WHEN l.is_fake = true THEN 1 END) as test_licenses,
SUM(l.device_limit) as total_device_limit
FROM licenses l
WHERE l.customer_id = %s
""", (customer_id,))
row = cur.fetchone()
return jsonify({
'total_licenses': row[0] or 0,
'active_licenses': row[1] or 0,
'test_licenses': row[2] or 0,
'total_device_limit': row[3] or 0
})
except Exception as e:
logging.error(f"Fehler beim Laden der Kundenstatistiken: {str(e)}")
return jsonify({'error': 'Fehler beim Laden der Daten'}), 500
finally:
cur.close()
conn.close()

Datei anzeigen

@@ -0,0 +1,495 @@
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Blueprint, request, send_file
import config
from auth.decorators import login_required
from utils.export import create_excel_export, create_csv_export, prepare_audit_export_data, format_datetime_for_export
from db import get_connection
# Create Blueprint
export_bp = Blueprint('export', __name__, url_prefix='/export')
@export_bp.route("/licenses")
@login_required
def export_licenses():
"""Exportiert Lizenzen als Excel-Datei"""
conn = get_connection()
cur = conn.cursor()
try:
# Nur reale Daten exportieren - keine Fake-Daten
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.device_limit,
l.created_at,
l.is_fake,
CASE
WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen'
WHEN l.is_active = false THEN 'Deaktiviert'
ELSE 'Aktiv'
END as status,
(SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.is_active = true) as active_sessions,
(SELECT COUNT(DISTINCT hardware_id) FROM sessions s WHERE s.license_key = l.license_key) as registered_devices
FROM licenses l
LEFT JOIN customers c ON l.customer_id = c.id
WHERE l.is_fake = false
ORDER BY l.created_at DESC
"""
cur.execute(query)
# Daten für Export vorbereiten
data = []
columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', 'Gültig von',
'Gültig bis', 'Aktiv', 'Gerätelimit', 'Erstellt am', 'Fake-Lizenz',
'Status', 'Aktive Sessions', 'Registrierte Geräte']
for row in cur.fetchall():
row_data = list(row)
# Format datetime fields
if row_data[5]: # valid_from
row_data[5] = format_datetime_for_export(row_data[5])
if row_data[6]: # valid_until
row_data[6] = format_datetime_for_export(row_data[6])
if row_data[9]: # created_at
row_data[9] = format_datetime_for_export(row_data[9])
data.append(row_data)
# Format prüfen
format_type = request.args.get('format', 'excel').lower()
if format_type == 'csv':
# CSV-Datei erstellen
return create_csv_export(data, columns, 'lizenzen')
else:
# Excel-Datei erstellen
return create_excel_export(data, columns, 'lizenzen')
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
return "Fehler beim Exportieren der Lizenzen", 500
finally:
cur.close()
conn.close()
@export_bp.route("/audit")
@login_required
def export_audit():
"""Exportiert Audit-Logs als Excel-Datei"""
conn = get_connection()
cur = conn.cursor()
try:
# Filter aus Request
days = int(request.args.get('days', 30))
action_filter = request.args.get('action', '')
entity_type_filter = request.args.get('entity_type', '')
# Query aufbauen
query = """
SELECT
id, timestamp, username, action, entity_type, entity_id,
ip_address, user_agent, old_values, new_values, additional_info
FROM audit_log
WHERE timestamp >= CURRENT_TIMESTAMP - INTERVAL '%s days'
"""
params = [days]
if action_filter:
query += " AND action = %s"
params.append(action_filter)
if entity_type_filter:
query += " AND entity_type = %s"
params.append(entity_type_filter)
query += " ORDER BY timestamp DESC"
cur.execute(query, params)
# Daten in Dictionary-Format umwandeln
audit_logs = []
for row in cur.fetchall():
audit_logs.append({
'id': row[0],
'timestamp': row[1],
'username': row[2],
'action': row[3],
'entity_type': row[4],
'entity_id': row[5],
'ip_address': row[6],
'user_agent': row[7],
'old_values': row[8],
'new_values': row[9],
'additional_info': row[10]
})
# Daten für Export vorbereiten
data = prepare_audit_export_data(audit_logs)
# Excel-Datei erstellen
columns = ['ID', 'Zeitstempel', 'Benutzer', 'Aktion', 'Entität', 'Entität ID',
'IP-Adresse', 'User Agent', 'Alte Werte', 'Neue Werte', 'Zusatzinfo']
# Format prüfen
format_type = request.args.get('format', 'excel').lower()
if format_type == 'csv':
# CSV-Datei erstellen
return create_csv_export(data, columns, 'audit_log')
else:
# Excel-Datei erstellen
return create_excel_export(data, columns, 'audit_log')
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
return "Fehler beim Exportieren der Audit-Logs", 500
finally:
cur.close()
conn.close()
@export_bp.route("/customers")
@login_required
def export_customers():
"""Exportiert Kunden als Excel-Datei"""
conn = get_connection()
cur = conn.cursor()
try:
# SQL Query - nur reale Kunden exportieren
cur.execute("""
SELECT
c.id,
c.name,
c.email,
c.phone,
c.address,
c.created_at,
c.is_fake,
COUNT(l.id) as license_count,
COUNT(CASE WHEN l.is_active = true 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
WHERE c.is_fake = false
GROUP BY c.id, c.name, c.email, c.phone, c.address, c.created_at, c.is_fake
ORDER BY c.name
""")
# Daten für Export vorbereiten
data = []
columns = ['ID', 'Name', 'E-Mail', 'Telefon', 'Adresse', 'Erstellt am',
'Test-Kunde', 'Anzahl Lizenzen', 'Aktive Lizenzen', 'Abgelaufene Lizenzen']
for row in cur.fetchall():
# Format datetime fields (created_at ist Spalte 5)
row_data = list(row)
if row_data[5]: # created_at
row_data[5] = format_datetime_for_export(row_data[5])
data.append(row_data)
# Format prüfen
format_type = request.args.get('format', 'excel').lower()
if format_type == 'csv':
# CSV-Datei erstellen
return create_csv_export(data, columns, 'kunden')
else:
# Excel-Datei erstellen
return create_excel_export(data, columns, 'kunden')
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
return "Fehler beim Exportieren der Kunden", 500
finally:
cur.close()
conn.close()
@export_bp.route("/sessions")
@login_required
def export_sessions():
"""Exportiert Sessions als Excel-Datei"""
conn = get_connection()
cur = conn.cursor()
try:
# Filter aus Request
days = int(request.args.get('days', 7))
active_only = request.args.get('active_only', 'false') == 'true'
# SQL Query
if active_only:
query = """
SELECT
s.id,
s.license_key,
l.customer_name,
s.username,
s.hardware_id,
s.started_at,
s.ended_at,
s.last_heartbeat,
s.is_active,
l.license_type,
l.is_fake
FROM sessions s
LEFT JOIN licenses l ON s.license_key = l.license_key
WHERE s.is_active = true AND l.is_fake = false
ORDER BY s.started_at DESC
"""
cur.execute(query)
else:
query = """
SELECT
s.id,
s.license_key,
l.customer_name,
s.username,
s.hardware_id,
s.started_at,
s.ended_at,
s.last_heartbeat,
s.is_active,
l.license_type,
l.is_fake
FROM sessions s
LEFT JOIN licenses l ON s.license_key = l.license_key
WHERE s.started_at >= CURRENT_TIMESTAMP - INTERVAL '%s days' AND l.is_fake = false
ORDER BY s.started_at DESC
"""
cur.execute(query, (days,))
# Daten für Export vorbereiten
data = []
columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'Benutzer', 'Geräte-ID',
'Login-Zeit', 'Logout-Zeit', 'Letzte Aktivität', 'Aktiv',
'Lizenztyp', 'Fake-Lizenz']
for row in cur.fetchall():
row_data = list(row)
# Format datetime fields
if row_data[5]: # started_at
row_data[5] = format_datetime_for_export(row_data[5])
if row_data[6]: # ended_at
row_data[6] = format_datetime_for_export(row_data[6])
if row_data[7]: # last_heartbeat
row_data[7] = format_datetime_for_export(row_data[7])
data.append(row_data)
# Format prüfen
format_type = request.args.get('format', 'excel').lower()
if format_type == 'csv':
# CSV-Datei erstellen
return create_csv_export(data, columns, 'sessions')
else:
# Excel-Datei erstellen
return create_excel_export(data, columns, 'sessions')
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
return "Fehler beim Exportieren der Sessions", 500
finally:
cur.close()
conn.close()
@export_bp.route("/resources")
@login_required
def export_resources():
"""Exportiert Ressourcen als Excel-Datei"""
conn = get_connection()
cur = conn.cursor()
try:
# Filter aus Request
resource_type = request.args.get('type', 'all')
status_filter = request.args.get('status', 'all')
# SQL Query aufbauen
query = """
SELECT
rp.id,
rp.resource_type,
rp.resource_value,
rp.status,
rp.is_fake,
l.license_key,
c.name as customer_name,
rp.created_at,
rp.created_by,
rp.status_changed_at,
rp.status_changed_by,
rp.quarantine_reason
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 1=1
"""
params = []
if resource_type != 'all':
query += " AND rp.resource_type = %s"
params.append(resource_type)
if status_filter != 'all':
query += " AND rp.status = %s"
params.append(status_filter)
# Immer nur reale Ressourcen exportieren
query += " AND rp.is_fake = false"
query += " ORDER BY rp.resource_type, rp.resource_value"
cur.execute(query, params)
# Daten für Export vorbereiten
data = []
columns = ['ID', 'Typ', 'Wert', 'Status', 'Test-Ressource', 'Lizenzschlüssel',
'Kunde', 'Erstellt am', 'Erstellt von', 'Status geändert am',
'Status geändert von', 'Quarantäne-Grund']
for row in cur.fetchall():
row_data = list(row)
# Format datetime fields
if row_data[7]: # created_at
row_data[7] = format_datetime_for_export(row_data[7])
if row_data[9]: # status_changed_at
row_data[9] = format_datetime_for_export(row_data[9])
data.append(row_data)
# Format prüfen
format_type = request.args.get('format', 'excel').lower()
if format_type == 'csv':
# CSV-Datei erstellen
return create_csv_export(data, columns, 'ressourcen')
else:
# Excel-Datei erstellen
return create_excel_export(data, columns, 'ressourcen')
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
return "Fehler beim Exportieren der Ressourcen", 500
finally:
cur.close()
conn.close()
@export_bp.route("/monitoring")
@login_required
def export_monitoring():
"""Exportiert Monitoring-Daten als Excel/CSV-Datei"""
conn = get_connection()
cur = conn.cursor()
try:
# Zeitraum aus Request
hours = int(request.args.get('hours', 24))
# Monitoring-Daten sammeln
data = []
columns = ['Zeitstempel', 'Lizenz-ID', 'Lizenzschlüssel', 'Kunde', 'Hardware-ID',
'IP-Adresse', 'Ereignis-Typ', 'Schweregrad', 'Beschreibung']
# Query für Heartbeats und optionale Anomalien
query = """
WITH monitoring_data AS (
-- Lizenz-Heartbeats
SELECT
lh.timestamp,
lh.license_id,
l.license_key,
c.name as customer_name,
lh.hardware_id,
lh.ip_address,
'Heartbeat' as event_type,
'Normal' as severity,
'License validation' as description
FROM license_heartbeats lh
JOIN licenses l ON l.id = lh.license_id
JOIN customers c ON c.id = l.customer_id
WHERE lh.timestamp > CURRENT_TIMESTAMP - INTERVAL '%s hours'
AND l.is_fake = false
"""
# Check if anomaly_detections table exists
cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'anomaly_detections'
)
""")
has_anomalies = cur.fetchone()[0]
if has_anomalies:
query += """
UNION ALL
-- Anomalien
SELECT
ad.detected_at as timestamp,
ad.license_id,
l.license_key,
c.name as customer_name,
ad.details->>'hardware_id' as hardware_id,
ad.details->>'ip_address' as ip_address,
ad.anomaly_type as event_type,
ad.severity,
ad.details->>'description' as description
FROM anomaly_detections ad
LEFT JOIN licenses l ON l.id = ad.license_id
LEFT JOIN customers c ON c.id = l.customer_id
WHERE ad.detected_at > CURRENT_TIMESTAMP - INTERVAL '%s hours'
AND (l.is_fake = false OR l.is_fake IS NULL)
"""
params = [hours, hours]
else:
params = [hours]
query += """
)
SELECT * FROM monitoring_data
ORDER BY timestamp DESC
"""
cur.execute(query, params)
for row in cur.fetchall():
row_data = list(row)
# Format datetime field (timestamp ist Spalte 0)
if row_data[0]: # timestamp
row_data[0] = format_datetime_for_export(row_data[0])
data.append(row_data)
# Format prüfen
format_type = request.args.get('format', 'excel').lower()
if format_type == 'csv':
# CSV-Datei erstellen
return create_csv_export(data, columns, 'monitoring')
else:
# Excel-Datei erstellen
return create_excel_export(data, columns, 'monitoring')
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
return "Fehler beim Exportieren der Monitoring-Daten", 500
finally:
cur.close()
conn.close()

Datei anzeigen

@@ -0,0 +1,506 @@
import os
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from dateutil.relativedelta import relativedelta
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
import config
from auth.decorators import login_required
from utils.audit import log_audit
from utils.network import get_client_ip
from utils.license import validate_license_key
from db import get_connection, get_db_connection, get_db_cursor
from models import get_licenses, get_license_by_id
# Create Blueprint
license_bp = Blueprint('licenses', __name__)
@license_bp.route("/licenses")
@login_required
def licenses():
from datetime import datetime, timedelta
# Get filter parameters
search = request.args.get('search', '').strip()
data_source = request.args.get('data_source', 'real') # real, fake, all
license_type = request.args.get('license_type', '') # '', full, test
license_status = request.args.get('license_status', '') # '', active, expiring, expired, inactive
sort = request.args.get('sort', 'created_at')
order = request.args.get('order', 'desc')
page = request.args.get('page', 1, type=int)
per_page = 50
# Get licenses based on data source
if data_source == 'fake':
licenses_list = get_licenses(show_fake=True)
licenses_list = [l for l in licenses_list if l.get('is_fake')]
elif data_source == 'all':
licenses_list = get_licenses(show_fake=True)
else: # real
licenses_list = get_licenses(show_fake=False)
# Type filtering
if license_type:
if license_type == 'full':
licenses_list = [l for l in licenses_list if l.get('license_type') == 'full']
elif license_type == 'test':
licenses_list = [l for l in licenses_list if l.get('license_type') == 'test']
# Status filtering
if license_status:
now = datetime.now().date()
filtered_licenses = []
for license in licenses_list:
if license_status == 'active' and license.get('is_active'):
# Active means is_active=true, regardless of expiration date
filtered_licenses.append(license)
elif license_status == 'expired' and license.get('valid_until') and license.get('valid_until') <= now:
# Expired means past valid_until date, regardless of is_active
filtered_licenses.append(license)
elif license_status == 'inactive' and not license.get('is_active'):
# Inactive means is_active=false, regardless of date
filtered_licenses.append(license)
licenses_list = filtered_licenses
# Search filtering
if search:
search_lower = search.lower()
licenses_list = [l for l in licenses_list if
search_lower in str(l.get('license_key', '')).lower() or
search_lower in str(l.get('customer_name', '')).lower() or
search_lower in str(l.get('customer_email', '')).lower()]
# Calculate pagination
total = len(licenses_list)
total_pages = (total + per_page - 1) // per_page
start = (page - 1) * per_page
end = start + per_page
licenses_list = licenses_list[start:end]
return render_template("licenses.html",
licenses=licenses_list,
search=search,
data_source=data_source,
license_type=license_type,
license_status=license_status,
sort=sort,
order=order,
page=page,
total=total,
total_pages=total_pages,
per_page=per_page,
now=datetime.now,
timedelta=timedelta)
@license_bp.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
@login_required
def edit_license(license_id):
conn = get_connection()
cur = conn.cursor()
if request.method == "POST":
try:
# Get current license data for comparison
current_license = get_license_by_id(license_id)
if not current_license:
flash('Lizenz nicht gefunden!', 'error')
return redirect(url_for('licenses.licenses'))
# Update license data
new_values = {
'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': 'is_active' in request.form,
'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, device_limit = %s
WHERE id = %s
""", (
new_values['license_key'],
new_values['license_type'],
new_values['valid_from'],
new_values['valid_until'],
new_values['is_active'],
new_values['device_limit'],
license_id
))
conn.commit()
# Log changes
log_audit('UPDATE', 'license', license_id,
old_values={
'license_key': current_license.get('license_key'),
'license_type': current_license.get('license_type'),
'valid_from': str(current_license.get('valid_from', '')),
'valid_until': str(current_license.get('valid_until', '')),
'is_active': current_license.get('is_active'),
'device_limit': current_license.get('device_limit', 3)
},
new_values=new_values)
flash('Lizenz erfolgreich aktualisiert!', 'success')
# Preserve show_test parameter if present
show_test = request.args.get('show_test', 'false')
return redirect(url_for('licenses.licenses', show_test=show_test))
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Aktualisieren der Lizenz: {str(e)}")
flash('Fehler beim Aktualisieren der Lizenz!', 'error')
finally:
cur.close()
conn.close()
# GET request
license_data = get_license_by_id(license_id)
if not license_data:
flash('Lizenz nicht gefunden!', 'error')
return redirect(url_for('licenses.licenses'))
return render_template("edit_license.html", license=license_data)
@license_bp.route("/license/delete/<int:license_id>", methods=["POST"])
@login_required
def delete_license(license_id):
# Check for force parameter
force_delete = request.form.get('force', 'false').lower() == 'true'
conn = get_connection()
cur = conn.cursor()
try:
# Get license data before deletion
license_data = get_license_by_id(license_id)
if not license_data:
flash('Lizenz nicht gefunden!', 'error')
return redirect(url_for('licenses.licenses'))
# Safety check: Don't delete active licenses unless forced
if license_data.get('is_active') and not force_delete:
flash(f'Lizenz {license_data["license_key"]} ist noch aktiv! Bitte deaktivieren Sie die Lizenz zuerst oder nutzen Sie "Erzwungenes Löschen".', 'warning')
return redirect(url_for('licenses.licenses'))
# Check for recent activity (heartbeats in last 24 hours)
try:
cur.execute("""
SELECT COUNT(*)
FROM license_heartbeats
WHERE license_id = %s
AND timestamp > NOW() - INTERVAL '24 hours'
""", (license_id,))
recent_heartbeats = cur.fetchone()[0]
if recent_heartbeats > 0 and not force_delete:
flash(f'Lizenz {license_data["license_key"]} hatte in den letzten 24 Stunden {recent_heartbeats} Aktivitäten! '
f'Die Lizenz wird möglicherweise noch aktiv genutzt. Bitte prüfen Sie dies vor dem Löschen.', 'danger')
return redirect(url_for('licenses.licenses'))
except Exception as e:
# If heartbeats table doesn't exist, continue
logging.warning(f"Could not check heartbeats: {str(e)}")
# Check for active devices/activations
try:
cur.execute("""
SELECT COUNT(*)
FROM activations
WHERE license_id = %s
AND is_active = true
""", (license_id,))
active_devices = cur.fetchone()[0]
if active_devices > 0 and not force_delete:
flash(f'Lizenz {license_data["license_key"]} hat {active_devices} aktive Geräte! '
f'Bitte deaktivieren Sie alle Geräte vor dem Löschen.', 'danger')
return redirect(url_for('licenses.licenses'))
except Exception as e:
# If activations table doesn't exist, continue
logging.warning(f"Could not check activations: {str(e)}")
# Delete from sessions first
cur.execute("DELETE FROM sessions WHERE license_key = %s", (license_data['license_key'],))
# Delete from license_heartbeats if exists
try:
cur.execute("DELETE FROM license_heartbeats WHERE license_id = %s", (license_id,))
except:
pass
# Delete from activations if exists
try:
cur.execute("DELETE FROM activations WHERE license_id = %s", (license_id,))
except:
pass
# Delete the license
cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,))
conn.commit()
# Log deletion with force flag
log_audit('DELETE', 'license', license_id,
old_values={
'license_key': license_data['license_key'],
'customer_name': license_data['customer_name'],
'customer_email': license_data['customer_email'],
'was_active': license_data.get('is_active'),
'forced': force_delete
},
additional_info=f"{'Forced deletion' if force_delete else 'Normal deletion'}")
flash(f'Lizenz {license_data["license_key"]} erfolgreich gelöscht!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Löschen der Lizenz: {str(e)}")
flash('Fehler beim Löschen der Lizenz!', 'error')
finally:
cur.close()
conn.close()
# Preserve show_test parameter if present
show_test = request.args.get('show_test', 'false')
return redirect(url_for('licenses.licenses', show_test=show_test))
@license_bp.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_fake wird später vom Kunden geerbt
# Berechne valid_until basierend auf Laufzeit
duration = int(request.form.get("duration", 1))
duration_type = request.form.get("duration_type", "years")
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('licenses.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('licenses.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('licenses.create_license'))
# Neuer Kunde wird immer als Fake erstellt, da wir in der Testphase sind
# TODO: Nach Testphase muss hier die Business-Logik angepasst werden
is_fake = True
cur.execute("""
INSERT INTO customers (name, email, is_fake, created_at)
VALUES (%s, %s, %s, NOW())
RETURNING id
""", (name, email, is_fake))
customer_id = cur.fetchone()[0]
customer_info = {'name': name, 'email': email, 'is_fake': is_fake}
# Audit-Log für neuen Kunden
log_audit('CREATE', 'customer', customer_id,
new_values={'name': name, 'email': email, 'is_fake': is_fake})
else:
# Bestehender Kunde - hole Infos für Audit-Log
cur.execute("SELECT name, email, is_fake FROM customers WHERE id = %s", (customer_id,))
customer_data = cur.fetchone()
if not customer_data:
flash('Kunde nicht gefunden!', 'error')
return redirect(url_for('licenses.create_license'))
customer_info = {'name': customer_data[0], 'email': customer_data[1]}
# Lizenz erbt immer den is_fake Status vom Kunden
is_fake = customer_data[2]
# 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_fake)
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_fake))
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_fake = %s) as domains,
(SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_fake = %s) as ipv4s,
(SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_fake = %s) as phones
""", (is_fake, is_fake, is_fake))
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_fake = %s
LIMIT %s FOR UPDATE
""", (is_fake, 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_fake = %s
LIMIT %s FOR UPDATE
""", (is_fake, 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_fake = %s
LIMIT %s FOR UPDATE
""", (is_fake, 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('licenses.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_fake': is_fake
})
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 = url_for('licenses.create_license')
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)

Datei anzeigen

@@ -0,0 +1,428 @@
from flask import Blueprint, render_template, jsonify, request, session, redirect, url_for
from functools import wraps
import psycopg2
from psycopg2.extras import RealDictCursor
import os
import requests
from datetime import datetime, timedelta
import logging
from utils.partition_helper import ensure_partition_exists, check_table_exists
# Configure logging
logger = logging.getLogger(__name__)
# Create a function to get database connection
def get_db_connection():
return psycopg2.connect(
host=os.environ.get('POSTGRES_HOST', 'postgres'),
database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'),
user=os.environ.get('POSTGRES_USER', 'postgres'),
password=os.environ.get('POSTGRES_PASSWORD', 'postgres')
)
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
# Create Blueprint
monitoring_bp = Blueprint('monitoring', __name__)
@monitoring_bp.route('/monitoring')
@login_required
def unified_monitoring():
"""Unified monitoring dashboard combining live activity and anomaly detection"""
try:
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
# Initialize default values
system_status = 'normal'
status_color = 'success'
active_alerts = 0
live_metrics = {
'active_licenses': 0,
'total_validations': 0,
'unique_devices': 0,
'unique_ips': 0,
'avg_response_time': 0
}
trend_data = []
activity_stream = []
geo_data = []
top_licenses = []
anomaly_distribution = []
performance_data = []
# Check if tables exist before querying
has_heartbeats = check_table_exists(conn, 'license_heartbeats')
has_anomalies = check_table_exists(conn, 'anomaly_detections')
if has_anomalies:
# Get active alerts count
cur.execute("""
SELECT COUNT(*) as count
FROM anomaly_detections
WHERE resolved = false
AND detected_at > NOW() - INTERVAL '24 hours'
""")
active_alerts = cur.fetchone()['count'] or 0
# Determine system status based on alerts
if active_alerts == 0:
system_status = 'normal'
status_color = 'success'
elif active_alerts < 5:
system_status = 'warning'
status_color = 'warning'
else:
system_status = 'critical'
status_color = 'danger'
if has_heartbeats:
# Ensure current month partition exists
ensure_partition_exists(conn, 'license_heartbeats', datetime.now())
# Executive summary metrics
cur.execute("""
SELECT
COUNT(DISTINCT license_id) as active_licenses,
COUNT(*) as total_validations,
COUNT(DISTINCT hardware_id) as unique_devices,
COUNT(DISTINCT ip_address) as unique_ips,
0 as avg_response_time
FROM license_heartbeats
WHERE timestamp > NOW() - INTERVAL '5 minutes'
""")
result = cur.fetchone()
if result:
live_metrics = result
# Get 24h trend data for metrics
cur.execute("""
SELECT
DATE_TRUNC('hour', timestamp) as hour,
COUNT(DISTINCT license_id) as licenses,
COUNT(*) as validations
FROM license_heartbeats
WHERE timestamp > NOW() - INTERVAL '24 hours'
GROUP BY hour
ORDER BY hour
""")
trend_data = cur.fetchall()
# Activity stream - just validations if no anomalies table
if has_anomalies:
cur.execute("""
WITH combined_events AS (
-- Normal validations
SELECT
lh.timestamp,
'validation' as event_type,
'normal' as severity,
l.license_key,
c.name as customer_name,
lh.ip_address,
lh.hardware_id,
NULL as anomaly_type,
NULL as description
FROM license_heartbeats lh
JOIN licenses l ON l.id = lh.license_id
JOIN customers c ON c.id = l.customer_id
WHERE lh.timestamp > NOW() - INTERVAL '1 hour'
UNION ALL
-- Anomalies
SELECT
ad.detected_at as timestamp,
'anomaly' as event_type,
ad.severity,
l.license_key,
c.name as customer_name,
ad.details->>'ip_address' as ip_address,
ad.details->>'hardware_id' as hardware_id,
ad.anomaly_type,
ad.details->>'description' as description
FROM anomaly_detections ad
LEFT JOIN licenses l ON l.id = ad.license_id
LEFT JOIN customers c ON c.id = l.customer_id
WHERE ad.detected_at > NOW() - INTERVAL '1 hour'
)
SELECT * FROM combined_events
ORDER BY timestamp DESC
LIMIT 100
""")
else:
# Just show validations
cur.execute("""
SELECT
lh.timestamp,
'validation' as event_type,
'normal' as severity,
l.license_key,
c.name as customer_name,
lh.ip_address,
lh.hardware_id,
NULL as anomaly_type,
NULL as description
FROM license_heartbeats lh
JOIN licenses l ON l.id = lh.license_id
JOIN customers c ON c.id = l.customer_id
WHERE lh.timestamp > NOW() - INTERVAL '1 hour'
ORDER BY lh.timestamp DESC
LIMIT 100
""")
activity_stream = cur.fetchall()
# Geographic distribution
cur.execute("""
SELECT
ip_address,
COUNT(*) as request_count,
COUNT(DISTINCT license_id) as license_count
FROM license_heartbeats
WHERE timestamp > NOW() - INTERVAL '1 hour'
GROUP BY ip_address
ORDER BY request_count DESC
LIMIT 20
""")
geo_data = cur.fetchall()
# Top active licenses
if has_anomalies:
cur.execute("""
SELECT
l.id,
l.license_key,
c.name as customer_name,
COUNT(DISTINCT lh.hardware_id) as device_count,
COUNT(lh.*) as validation_count,
MAX(lh.timestamp) as last_seen,
COUNT(DISTINCT ad.id) as anomaly_count
FROM licenses l
JOIN customers c ON c.id = l.customer_id
LEFT JOIN license_heartbeats lh ON l.id = lh.license_id
AND lh.timestamp > NOW() - INTERVAL '1 hour'
LEFT JOIN anomaly_detections ad ON l.id = ad.license_id
AND ad.detected_at > NOW() - INTERVAL '24 hours'
WHERE lh.license_id IS NOT NULL
GROUP BY l.id, l.license_key, c.name
ORDER BY validation_count DESC
LIMIT 10
""")
else:
cur.execute("""
SELECT
l.id,
l.license_key,
c.name as customer_name,
COUNT(DISTINCT lh.hardware_id) as device_count,
COUNT(lh.*) as validation_count,
MAX(lh.timestamp) as last_seen,
0 as anomaly_count
FROM licenses l
JOIN customers c ON c.id = l.customer_id
LEFT JOIN license_heartbeats lh ON l.id = lh.license_id
AND lh.timestamp > NOW() - INTERVAL '1 hour'
WHERE lh.license_id IS NOT NULL
GROUP BY l.id, l.license_key, c.name
ORDER BY validation_count DESC
LIMIT 10
""")
top_licenses = cur.fetchall()
# Performance metrics
cur.execute("""
SELECT
DATE_TRUNC('minute', timestamp) as minute,
0 as avg_response_time,
0 as max_response_time,
COUNT(*) as request_count
FROM license_heartbeats
WHERE timestamp > NOW() - INTERVAL '30 minutes'
GROUP BY minute
ORDER BY minute DESC
""")
performance_data = cur.fetchall()
if has_anomalies:
# Anomaly distribution
cur.execute("""
SELECT
anomaly_type,
COUNT(*) as count,
MAX(severity) as max_severity
FROM anomaly_detections
WHERE detected_at > NOW() - INTERVAL '24 hours'
GROUP BY anomaly_type
ORDER BY count DESC
""")
anomaly_distribution = cur.fetchall()
cur.close()
conn.close()
return render_template('monitoring/unified_monitoring.html',
system_status=system_status,
status_color=status_color,
active_alerts=active_alerts,
live_metrics=live_metrics,
trend_data=trend_data,
activity_stream=activity_stream,
geo_data=geo_data,
top_licenses=top_licenses,
anomaly_distribution=anomaly_distribution,
performance_data=performance_data)
except Exception as e:
logger.error(f"Error in unified monitoring: {str(e)}")
return render_template('error.html',
error='Fehler beim Laden des Monitorings',
details=str(e))
@monitoring_bp.route('/live-dashboard')
@login_required
def live_dashboard():
"""Redirect to unified monitoring dashboard"""
return redirect(url_for('monitoring.unified_monitoring'))
@monitoring_bp.route('/alerts')
@login_required
def alerts():
"""Show active alerts from Alertmanager"""
alerts = []
try:
# Get alerts from Alertmanager
response = requests.get('http://alertmanager:9093/api/v1/alerts', timeout=2)
if response.status_code == 200:
alerts = response.json()
except:
# Fallback to database anomalies if table exists
conn = get_db_connection()
if check_table_exists(conn, 'anomaly_detections'):
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT
ad.*,
l.license_key,
c.name as company_name
FROM anomaly_detections ad
LEFT JOIN licenses l ON l.id = ad.license_id
LEFT JOIN customers c ON c.id = l.customer_id
WHERE ad.resolved = false
ORDER BY ad.detected_at DESC
LIMIT 50
""")
alerts = cur.fetchall()
cur.close()
conn.close()
return render_template('monitoring/alerts.html', alerts=alerts)
@monitoring_bp.route('/analytics')
@login_required
def analytics():
"""Combined analytics and license server status page"""
try:
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
# Initialize default values
live_stats = [0, 0, 0, 0]
validation_rates = []
if check_table_exists(conn, 'license_heartbeats'):
# Get live statistics for the top cards
cur.execute("""
SELECT
COUNT(DISTINCT license_id) as active_licenses,
COUNT(*) as total_validations,
COUNT(DISTINCT hardware_id) as unique_devices,
COUNT(DISTINCT ip_address) as unique_ips
FROM license_heartbeats
WHERE timestamp > NOW() - INTERVAL '5 minutes'
""")
live_stats_data = cur.fetchone()
live_stats = [
live_stats_data['active_licenses'] or 0,
live_stats_data['total_validations'] or 0,
live_stats_data['unique_devices'] or 0,
live_stats_data['unique_ips'] or 0
]
# Get validation rates for the chart (last 30 minutes, aggregated by minute)
cur.execute("""
SELECT
DATE_TRUNC('minute', timestamp) as minute,
COUNT(*) as count
FROM license_heartbeats
WHERE timestamp > NOW() - INTERVAL '30 minutes'
GROUP BY minute
ORDER BY minute DESC
LIMIT 30
""")
validation_rates = [(row['minute'].isoformat(), row['count']) for row in cur.fetchall()]
cur.close()
conn.close()
return render_template('monitoring/analytics.html',
live_stats=live_stats,
validation_rates=validation_rates)
except Exception as e:
logger.error(f"Error in analytics: {str(e)}")
return render_template('error.html',
error='Fehler beim Laden der Analytics',
details=str(e))
@monitoring_bp.route('/analytics/stream')
@login_required
def analytics_stream():
"""Server-sent event stream for live analytics updates"""
def generate():
while True:
try:
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
data = {'active_licenses': 0, 'total_validations': 0,
'unique_devices': 0, 'unique_ips': 0}
if check_table_exists(conn, 'license_heartbeats'):
cur.execute("""
SELECT
COUNT(DISTINCT license_id) as active_licenses,
COUNT(*) as total_validations,
COUNT(DISTINCT hardware_id) as unique_devices,
COUNT(DISTINCT ip_address) as unique_ips
FROM license_heartbeats
WHERE timestamp > NOW() - INTERVAL '5 minutes'
""")
result = cur.fetchone()
if result:
data = dict(result)
cur.close()
conn.close()
yield f"data: {jsonify(data).get_data(as_text=True)}\n\n"
except Exception as e:
logger.error(f"Error in analytics stream: {str(e)}")
yield f"data: {jsonify({'error': str(e)}).get_data(as_text=True)}\n\n"
import time
time.sleep(5) # Update every 5 seconds
from flask import Response
return Response(generate(), mimetype="text/event-stream")

Datei anzeigen

@@ -0,0 +1,721 @@
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify, send_file
import config
from auth.decorators import login_required
from utils.audit import log_audit
from utils.network import get_client_ip
from db import get_connection, get_db_connection, get_db_cursor
# Create Blueprint
resource_bp = Blueprint('resources', __name__)
@resource_bp.route('/resources')
@login_required
def resources():
"""Zeigt die Ressourcenpool-Übersicht"""
import logging
logging.info("=== RESOURCES ROUTE CALLED ===")
conn = get_connection()
cur = conn.cursor()
try:
# Filter aus Query-Parametern
resource_type = request.args.get('type', 'all')
status_filter = request.args.get('status', 'all')
search_query = request.args.get('search', '')
show_fake = request.args.get('show_fake', 'false') == 'true'
logging.info(f"Filters: type={resource_type}, status={status_filter}, search={search_query}, show_fake={show_fake}")
# Basis-Query
query = """
SELECT
rp.id,
rp.resource_type,
rp.resource_value,
rp.status,
rp.is_fake,
rp.allocated_to_license,
rp.created_at,
rp.status_changed_at,
rp.status_changed_by,
c.name as customer_name,
l.license_type
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 1=1
"""
params = []
# Filter anwenden
if resource_type != 'all':
query += " AND rp.resource_type = %s"
params.append(resource_type)
if status_filter != 'all':
query += " AND rp.status = %s"
params.append(status_filter)
if search_query:
query += " AND (rp.resource_value ILIKE %s OR c.name ILIKE %s)"
params.extend([f'%{search_query}%', f'%{search_query}%'])
if not show_fake:
query += " AND rp.is_fake = false"
query += " ORDER BY rp.resource_type, rp.resource_value"
cur.execute(query, params)
resources_list = []
rows = cur.fetchall()
logging.info(f"Query returned {len(rows)} rows")
for row in rows:
resources_list.append({
'id': row[0],
'resource_type': row[1],
'resource_value': row[2],
'status': row[3],
'is_fake': row[4],
'allocated_to_license': row[5],
'created_at': row[6],
'status_changed_at': row[7],
'status_changed_by': row[8],
'customer_name': row[9],
'license_type': row[10]
})
# Statistiken
stats_query = """
SELECT
resource_type,
status,
is_fake,
COUNT(*) as count
FROM resource_pools
"""
# Apply test filter to statistics as well
if not show_fake:
stats_query += " WHERE is_fake = false"
stats_query += " GROUP BY resource_type, status, is_fake"
cur.execute(stats_query)
stats = {}
for row in cur.fetchall():
res_type = row[0]
status = row[1]
is_fake = row[2]
count = row[3]
if res_type not in stats:
stats[res_type] = {
'total': 0,
'available': 0,
'allocated': 0,
'quarantined': 0,
'test': 0,
'prod': 0,
'available_percent': 0
}
stats[res_type]['total'] += count
stats[res_type][status] = stats[res_type].get(status, 0) + count
if is_fake:
stats[res_type]['test'] += count
else:
stats[res_type]['prod'] += count
# Calculate percentages
for res_type in stats:
if stats[res_type]['total'] > 0:
stats[res_type]['available_percent'] = int((stats[res_type]['available'] / stats[res_type]['total']) * 100)
# Pagination parameters (simple defaults for now)
try:
page = int(request.args.get('page', '1') or '1')
except (ValueError, TypeError):
page = 1
per_page = 50
total = len(resources_list)
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
# Sort parameters
sort_by = request.args.get('sort', 'id')
sort_order = request.args.get('order', 'asc')
return render_template('resources.html',
resources=resources_list,
stats=stats,
resource_type=resource_type,
status_filter=status_filter,
search=search_query, # Changed from search_query to search
show_fake=show_fake,
total=total,
page=page,
total_pages=total_pages,
sort_by=sort_by,
sort_order=sort_order,
recent_activities=[], # Empty for now
datetime=datetime) # For template datetime usage
except Exception as e:
import traceback
logging.error(f"Fehler beim Laden der Ressourcen: {str(e)}")
logging.error(f"Traceback: {traceback.format_exc()}")
flash('Fehler beim Laden der Ressourcen!', 'error')
return redirect(url_for('admin.dashboard'))
finally:
cur.close()
conn.close()
# Old add_resource function removed - using add_resources instead
@resource_bp.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
@login_required
def quarantine(resource_id):
"""Ressource in Quarantäne versetzen"""
conn = get_connection()
cur = conn.cursor()
try:
reason = request.form.get('reason', '')
# Hole aktuelle Ressourcen-Info
cur.execute("""
SELECT resource_value, status, allocated_to_license
FROM resource_pools WHERE id = %s
""", (resource_id,))
resource = cur.fetchone()
if not resource:
flash('Ressource nicht gefunden!', 'error')
return redirect(url_for('resources.resources'))
# Setze Status auf quarantined
cur.execute("""
UPDATE resource_pools
SET status = 'quarantined',
allocated_to_license = NULL,
status_changed_at = CURRENT_TIMESTAMP,
status_changed_by = %s,
quarantine_reason = %s
WHERE id = %s
""", (session['username'], reason, resource_id))
# Wenn die Ressource zugewiesen war, entferne die Zuweisung
if resource[2]: # allocated_to_license
cur.execute("""
DELETE FROM license_resources
WHERE license_id = %s AND resource_id = %s
""", (resource[2], resource_id))
# History-Eintrag
cur.execute("""
INSERT INTO resource_history (resource_id, license_id, action, action_by, notes, ip_address)
VALUES (%s, %s, 'quarantined', %s, %s, %s)
""", (resource_id, resource[2], session['username'], reason, get_client_ip()))
conn.commit()
# Audit-Log
log_audit('QUARANTINE', 'resource', resource_id,
old_values={'status': resource[1]},
new_values={'status': 'quarantined', 'reason': reason})
flash(f'Ressource {resource[0]} in Quarantäne versetzt!', 'warning')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Quarantänisieren der Ressource: {str(e)}")
flash('Fehler beim Quarantänisieren der Ressource!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('resources.resources'))
@resource_bp.route('/resources/release', methods=['POST'])
@login_required
def release():
"""Ressourcen aus Quarantäne freigeben oder von Lizenz entfernen"""
conn = get_connection()
cur = conn.cursor()
try:
resource_ids = request.form.getlist('resource_ids[]')
action = request.form.get('action', 'release')
if not resource_ids:
flash('Keine Ressourcen ausgewählt!', 'error')
return redirect(url_for('resources.resources'))
for resource_id in resource_ids:
# Hole aktuelle Ressourcen-Info
cur.execute("""
SELECT resource_value, status, allocated_to_license
FROM resource_pools WHERE id = %s
""", (resource_id,))
resource = cur.fetchone()
if resource:
# Setze Status auf available
cur.execute("""
UPDATE resource_pools
SET status = 'available',
allocated_to_license = NULL,
status_changed_at = CURRENT_TIMESTAMP,
status_changed_by = %s,
quarantine_reason = NULL
WHERE id = %s
""", (session['username'], resource_id))
# Entferne Lizenz-Zuweisung wenn vorhanden
if resource[2]: # allocated_to_license
cur.execute("""
DELETE FROM license_resources
WHERE license_id = %s AND resource_id = %s
""", (resource[2], resource_id))
# History-Eintrag
cur.execute("""
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
VALUES (%s, %s, 'released', %s, %s)
""", (resource_id, resource[2], session['username'], get_client_ip()))
# Audit-Log
log_audit('RELEASE', 'resource', resource_id,
old_values={'status': resource[1]},
new_values={'status': 'available'})
conn.commit()
flash(f'{len(resource_ids)} Ressource(n) freigegeben!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Freigeben der Ressourcen: {str(e)}")
flash('Fehler beim Freigeben der Ressourcen!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('resources.resources'))
@resource_bp.route('/resources/history/<int:resource_id>')
@login_required
def resource_history(resource_id):
"""Zeigt die Historie einer Ressource"""
conn = get_connection()
cur = conn.cursor()
try:
# Hole Ressourcen-Info
cur.execute("""
SELECT resource_type, resource_value, status, is_fake
FROM resource_pools WHERE id = %s
""", (resource_id,))
resource = cur.fetchone()
if not resource:
flash('Ressource nicht gefunden!', 'error')
return redirect(url_for('resources.resources'))
# Hole Historie
cur.execute("""
SELECT
rh.action,
rh.action_timestamp,
rh.action_by,
rh.notes,
rh.ip_address,
l.license_key,
c.name as customer_name
FROM resource_history rh
LEFT JOIN licenses l ON rh.license_id = l.id
LEFT JOIN customers c ON l.customer_id = c.id
WHERE rh.resource_id = %s
ORDER BY rh.action_timestamp DESC
""", (resource_id,))
history = []
for row in cur.fetchall():
history.append({
'action': row[0],
'timestamp': row[1],
'by': row[2],
'notes': row[3],
'ip_address': row[4],
'license_key': row[5],
'customer_name': row[6]
})
return render_template('resource_history.html',
resource={
'id': resource_id,
'type': resource[0],
'value': resource[1],
'status': resource[2],
'is_fake': resource[3]
},
history=history)
except Exception as e:
logging.error(f"Fehler beim Laden der Ressourcen-Historie: {str(e)}")
flash('Fehler beim Laden der Historie!', 'error')
return redirect(url_for('resources.resources'))
finally:
cur.close()
conn.close()
@resource_bp.route('/resources/metrics')
@login_required
def resource_metrics():
"""Zeigt Metriken und Statistiken zu Ressourcen"""
conn = get_connection()
cur = conn.cursor()
try:
# Allgemeine Statistiken
cur.execute("""
SELECT
resource_type,
status,
is_fake,
COUNT(*) as count
FROM resource_pools
GROUP BY resource_type, status, is_fake
ORDER BY resource_type, status
""")
general_stats = {}
for row in cur.fetchall():
res_type = row[0]
if res_type not in general_stats:
general_stats[res_type] = {
'total': 0,
'available': 0,
'allocated': 0,
'quarantined': 0,
'test': 0,
'production': 0
}
general_stats[res_type]['total'] += row[3]
general_stats[res_type][row[1]] += row[3]
if row[2]:
general_stats[res_type]['test'] += row[3]
else:
general_stats[res_type]['production'] += row[3]
# Zuweisungs-Statistiken
cur.execute("""
SELECT
rp.resource_type,
COUNT(DISTINCT l.customer_id) as unique_customers,
COUNT(DISTINCT rp.allocated_to_license) as unique_licenses
FROM resource_pools rp
JOIN licenses l ON rp.allocated_to_license = l.id
WHERE rp.status = 'allocated'
GROUP BY rp.resource_type
""")
allocation_stats = {}
for row in cur.fetchall():
allocation_stats[row[0]] = {
'unique_customers': row[1],
'unique_licenses': row[2]
}
# Historische Daten (letzte 30 Tage)
cur.execute("""
SELECT
DATE(action_timestamp) as date,
action,
COUNT(*) as count
FROM resource_history
WHERE action_timestamp >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY DATE(action_timestamp), action
ORDER BY date, action
""")
historical_data = {}
for row in cur.fetchall():
date_str = row[0].strftime('%Y-%m-%d')
if date_str not in historical_data:
historical_data[date_str] = {}
historical_data[date_str][row[1]] = row[2]
# Top-Kunden nach Ressourcennutzung
cur.execute("""
SELECT
c.name,
rp.resource_type,
COUNT(*) as count
FROM resource_pools rp
JOIN licenses l ON rp.allocated_to_license = l.id
JOIN customers c ON l.customer_id = c.id
WHERE rp.status = 'allocated'
GROUP BY c.name, rp.resource_type
ORDER BY count DESC
LIMIT 20
""")
top_customers = []
for row in cur.fetchall():
top_customers.append({
'customer': row[0],
'resource_type': row[1],
'count': row[2]
})
return render_template('resource_metrics.html',
general_stats=general_stats,
allocation_stats=allocation_stats,
historical_data=historical_data,
top_customers=top_customers)
except Exception as e:
logging.error(f"Fehler beim Laden der Ressourcen-Metriken: {str(e)}")
flash('Fehler beim Laden der Metriken!', 'error')
return redirect(url_for('resources.resources'))
finally:
cur.close()
conn.close()
@resource_bp.route('/resources/report', methods=['GET'])
@login_required
def resources_report():
"""Generiert einen Ressourcen-Report"""
from io import BytesIO
import xlsxwriter
conn = get_connection()
cur = conn.cursor()
try:
# Erstelle Excel-Datei im Speicher
output = BytesIO()
workbook = xlsxwriter.Workbook(output)
# Formatierungen
header_format = workbook.add_format({
'bold': True,
'bg_color': '#4CAF50',
'font_color': 'white',
'border': 1
})
date_format = workbook.add_format({'num_format': 'dd.mm.yyyy hh:mm'})
# Sheet 1: Übersicht
overview_sheet = workbook.add_worksheet('Übersicht')
# Header
headers = ['Ressourcen-Typ', 'Gesamt', 'Verfügbar', 'Zugewiesen', 'Quarantäne', 'Test', 'Produktion']
for col, header in enumerate(headers):
overview_sheet.write(0, col, header, header_format)
# Daten
cur.execute("""
SELECT
resource_type,
COUNT(*) as total,
COUNT(CASE WHEN status = 'available' THEN 1 END) as available,
COUNT(CASE WHEN status = 'allocated' THEN 1 END) as allocated,
COUNT(CASE WHEN status = 'quarantined' THEN 1 END) as quarantined,
COUNT(CASE WHEN is_fake = true THEN 1 END) as test,
COUNT(CASE WHEN is_fake = false THEN 1 END) as production
FROM resource_pools
GROUP BY resource_type
ORDER BY resource_type
""")
row = 1
for data in cur.fetchall():
for col, value in enumerate(data):
overview_sheet.write(row, col, value)
row += 1
# Sheet 2: Detailliste
detail_sheet = workbook.add_worksheet('Detailliste')
# Header
headers = ['Typ', 'Wert', 'Status', 'Test', 'Kunde', 'Lizenz', 'Zugewiesen am', 'Zugewiesen von']
for col, header in enumerate(headers):
detail_sheet.write(0, col, header, header_format)
# Daten
cur.execute("""
SELECT
rp.resource_type,
rp.resource_value,
rp.status,
rp.is_fake,
c.name as customer_name,
l.license_key,
rp.status_changed_at,
rp.status_changed_by
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
ORDER BY rp.resource_type, rp.resource_value
""")
row = 1
for data in cur.fetchall():
for col, value in enumerate(data):
if col == 6 and value: # Datum
detail_sheet.write_datetime(row, col, value, date_format)
else:
detail_sheet.write(row, col, value if value is not None else '')
row += 1
# Spaltenbreiten anpassen
overview_sheet.set_column('A:A', 20)
overview_sheet.set_column('B:G', 12)
detail_sheet.set_column('A:A', 15)
detail_sheet.set_column('B:B', 30)
detail_sheet.set_column('C:D', 12)
detail_sheet.set_column('E:F', 25)
detail_sheet.set_column('G:H', 20)
workbook.close()
output.seek(0)
# Sende Datei
filename = f"ressourcen_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
logging.error(f"Fehler beim Generieren des Reports: {str(e)}")
flash('Fehler beim Generieren des Reports!', 'error')
return redirect(url_for('resources.resources'))
finally:
cur.close()
conn.close()
@resource_bp.route('/resources/add', methods=['GET', 'POST'])
@login_required
def add_resources():
"""Fügt neue Ressourcen zum Pool hinzu"""
if request.method == 'POST':
conn = get_connection()
cur = conn.cursor()
try:
resource_type = request.form.get('resource_type')
resources_text = request.form.get('resources_text', '')
is_fake = request.form.get('is_fake', 'false') == 'true'
if not resource_type or not resources_text.strip():
flash('Bitte Ressourcentyp und Ressourcen angeben!', 'error')
return redirect(url_for('resources.add_resources'))
# Parse resources (one per line)
resources = [r.strip() for r in resources_text.strip().split('\n') if r.strip()]
# Validate resources based on type
valid_resources = []
invalid_resources = []
for resource in resources:
if resource_type == 'domain':
# Basic domain validation
import re
if re.match(r'^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.[a-zA-Z]{2,}$', resource):
valid_resources.append(resource)
else:
invalid_resources.append(resource)
elif resource_type == 'ipv4':
# IPv4 validation
parts = resource.split('.')
if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
valid_resources.append(resource)
else:
invalid_resources.append(resource)
elif resource_type == 'phone':
# Phone number validation (basic)
import re
if re.match(r'^\+?[0-9]{7,15}$', resource.replace(' ', '').replace('-', '')):
valid_resources.append(resource)
else:
invalid_resources.append(resource)
else:
invalid_resources.append(resource)
# Check for duplicates
existing_resources = []
if valid_resources:
placeholders = ','.join(['%s'] * len(valid_resources))
cur.execute(f"""
SELECT resource_value
FROM resource_pools
WHERE resource_type = %s
AND resource_value IN ({placeholders})
""", [resource_type] + valid_resources)
existing_resources = [row[0] for row in cur.fetchall()]
# Filter out existing resources
new_resources = [r for r in valid_resources if r not in existing_resources]
# Insert new resources
added_count = 0
for resource in new_resources:
cur.execute("""
INSERT INTO resource_pools
(resource_type, resource_value, status, is_fake, created_by)
VALUES (%s, %s, 'available', %s, %s)
""", (resource_type, resource, is_fake, session['username']))
added_count += 1
conn.commit()
# Log audit
if added_count > 0:
log_audit('BULK_CREATE', 'resource',
additional_info=f"Added {added_count} {resource_type} resources")
# Flash messages
if added_count > 0:
flash(f'{added_count} neue Ressourcen erfolgreich hinzugefügt!', 'success')
if existing_resources:
flash(f'⚠️ {len(existing_resources)} Ressourcen existierten bereits und wurden übersprungen.', 'warning')
if invalid_resources:
flash(f'{len(invalid_resources)} ungültige Ressourcen wurden ignoriert.', 'error')
return redirect(url_for('resources.resources', show_fake=request.form.get('show_fake', 'false')))
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Hinzufügen von Ressourcen: {str(e)}")
flash('Fehler beim Hinzufügen der Ressourcen!', 'error')
finally:
cur.close()
conn.close()
# GET request - show form
show_fake = request.args.get('show_fake', 'false') == 'true'
return render_template('add_resources.html', show_fake=show_fake)

Datei anzeigen

@@ -0,0 +1,429 @@
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Blueprint, render_template, request, redirect, session, url_for, flash
import config
from auth.decorators import login_required
from utils.audit import log_audit
from utils.network import get_client_ip
from db import get_connection, get_db_connection, get_db_cursor
from models import get_active_sessions
# Create Blueprint
session_bp = Blueprint('sessions', __name__)
@session_bp.route("/sessions")
@login_required
def sessions():
conn = get_connection()
cur = conn.cursor()
try:
# Get is_active sessions with calculated inactive time
cur.execute("""
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 s.last_heartbeat DESC
""")
active_sessions = cur.fetchall()
# Get recent ended sessions
cur.execute("""
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 s.ended_at DESC
LIMIT 50
""")
recent_sessions = cur.fetchall()
return render_template("sessions.html",
active_sessions=active_sessions,
recent_sessions=recent_sessions)
except Exception as e:
logging.error(f"Error loading sessions: {str(e)}")
flash('Fehler beim Laden der Sessions!', 'error')
return redirect(url_for('admin.dashboard'))
finally:
cur.close()
conn.close()
@session_bp.route("/sessions/history")
@login_required
def session_history():
"""Zeigt die Session-Historie"""
conn = get_connection()
cur = conn.cursor()
try:
# Query parameters
license_key = request.args.get('license_key', '')
username = request.args.get('username', '')
days = int(request.args.get('days', 7))
# Base query
query = """
SELECT
s.id,
s.license_key,
s.username,
s.hardware_id,
s.started_at,
s.ended_at,
s.last_heartbeat,
s.is_active,
l.customer_name,
l.license_type,
l.is_test
FROM sessions s
LEFT JOIN licenses l ON s.license_key = l.license_key
WHERE 1=1
"""
params = []
# Apply filters
if license_key:
query += " AND s.license_key = %s"
params.append(license_key)
if username:
query += " AND s.username ILIKE %s"
params.append(f'%{username}%')
# Time filter
query += " AND s.started_at >= CURRENT_TIMESTAMP - INTERVAL '%s days'"
params.append(days)
query += " ORDER BY s.started_at DESC LIMIT 1000"
cur.execute(query, params)
sessions_list = []
for row in cur.fetchall():
session_duration = None
if row[4] and row[5]: # started_at and ended_at
duration = row[5] - row[4]
hours = int(duration.total_seconds() // 3600)
minutes = int((duration.total_seconds() % 3600) // 60)
session_duration = f"{hours}h {minutes}m"
elif row[4] and row[7]: # started_at and is_active
duration = datetime.now(ZoneInfo("UTC")) - row[4]
hours = int(duration.total_seconds() // 3600)
minutes = int((duration.total_seconds() % 3600) // 60)
session_duration = f"{hours}h {minutes}m (aktiv)"
sessions_list.append({
'id': row[0],
'license_key': row[1],
'username': row[2],
'hardware_id': row[3],
'started_at': row[4],
'ended_at': row[5],
'last_heartbeat': row[6],
'is_active': row[7],
'customer_name': row[8],
'license_type': row[9],
'is_test': row[10],
'duration': session_duration
})
# Get unique license keys for filter dropdown
cur.execute("""
SELECT DISTINCT s.license_key, l.customer_name
FROM sessions s
LEFT JOIN licenses l ON s.license_key = l.license_key
WHERE s.started_at >= CURRENT_TIMESTAMP - INTERVAL '30 days'
ORDER BY l.customer_name, s.license_key
""")
available_licenses = []
for row in cur.fetchall():
available_licenses.append({
'license_key': row[0],
'customer_name': row[1] or 'Unbekannt'
})
return render_template("session_history.html",
sessions=sessions_list,
available_licenses=available_licenses,
filters={
'license_key': license_key,
'username': username,
'days': days
})
except Exception as e:
logging.error(f"Fehler beim Laden der Session-Historie: {str(e)}")
flash('Fehler beim Laden der Session-Historie!', 'error')
return redirect(url_for('sessions.sessions'))
finally:
cur.close()
conn.close()
@session_bp.route("/session/end/<int:session_id>", methods=["POST"])
@login_required
def terminate_session(session_id):
"""Beendet eine aktive Session"""
conn = get_connection()
cur = conn.cursor()
try:
# Get session info
cur.execute("""
SELECT license_key, username, hardware_id
FROM sessions
WHERE id = %s AND is_active = true
""", (session_id,))
session_info = cur.fetchone()
if not session_info:
flash('Session nicht gefunden oder bereits beendet!', 'error')
return redirect(url_for('sessions.sessions'))
# Terminate session
cur.execute("""
UPDATE sessions
SET is_active = false, ended_at = CURRENT_TIMESTAMP
WHERE id = %s
""", (session_id,))
conn.commit()
# Audit log
log_audit('SESSION_TERMINATE', 'session', session_id,
additional_info=f"Session beendet für {session_info[1]} auf Lizenz {session_info[0]}")
flash('Session erfolgreich beendet!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Beenden der Session: {str(e)}")
flash('Fehler beim Beenden der Session!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('sessions.sessions'))
@session_bp.route("/sessions/terminate-all/<license_key>", methods=["POST"])
@login_required
def terminate_all_sessions(license_key):
"""Beendet alle aktiven Sessions einer Lizenz"""
conn = get_connection()
cur = conn.cursor()
try:
# Count is_active sessions
cur.execute("""
SELECT COUNT(*) FROM sessions
WHERE license_key = %s AND is_active = true
""", (license_key,))
active_count = cur.fetchone()[0]
if active_count == 0:
flash('Keine aktiven Sessions gefunden!', 'info')
return redirect(url_for('sessions.sessions'))
# Terminate all sessions
cur.execute("""
UPDATE sessions
SET is_active = false, ended_at = CURRENT_TIMESTAMP
WHERE license_key = %s AND is_active = true
""", (license_key,))
conn.commit()
# Audit log
log_audit('SESSION_TERMINATE_ALL', 'license', None,
additional_info=f"{active_count} Sessions beendet für Lizenz {license_key}")
flash(f'{active_count} Sessions erfolgreich beendet!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Beenden der Sessions: {str(e)}")
flash('Fehler beim Beenden der Sessions!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('sessions.sessions'))
@session_bp.route("/sessions/cleanup", methods=["POST"])
@login_required
def cleanup_sessions():
"""Bereinigt alte inaktive Sessions"""
conn = get_connection()
cur = conn.cursor()
try:
days = int(request.form.get('days', 30))
# Delete old inactive sessions
cur.execute("""
DELETE FROM sessions
WHERE is_active = false
AND ended_at < CURRENT_TIMESTAMP - INTERVAL '%s days'
RETURNING id
""", (days,))
deleted_ids = [row[0] for row in cur.fetchall()]
deleted_count = len(deleted_ids)
conn.commit()
# Audit log
if deleted_count > 0:
log_audit('SESSION_CLEANUP', 'system', None,
additional_info=f"{deleted_count} Sessions älter als {days} Tage gelöscht")
flash(f'{deleted_count} alte Sessions bereinigt!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Bereinigen der Sessions: {str(e)}")
flash('Fehler beim Bereinigen der Sessions!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('sessions.session_history'))
@session_bp.route("/sessions/statistics")
@login_required
def session_statistics():
"""Zeigt Session-Statistiken"""
conn = get_connection()
cur = conn.cursor()
try:
# Aktuelle Statistiken
cur.execute("""
SELECT
COUNT(DISTINCT s.license_key) as active_licenses,
COUNT(DISTINCT s.username) as unique_users,
COUNT(DISTINCT s.hardware_id) as unique_devices,
COUNT(*) as total_active_sessions
FROM sessions s
WHERE s.is_active = true
""")
current_stats = cur.fetchone()
# Sessions nach Lizenztyp
cur.execute("""
SELECT
l.license_type,
COUNT(*) as session_count
FROM sessions s
JOIN licenses l ON s.license_key = l.license_key
WHERE s.is_active = true
GROUP BY l.license_type
ORDER BY session_count DESC
""")
sessions_by_type = []
for row in cur.fetchall():
sessions_by_type.append({
'license_type': row[0],
'count': row[1]
})
# Top 10 Lizenzen nach aktiven Sessions
cur.execute("""
SELECT
s.license_key,
l.customer_name,
COUNT(*) as session_count,
l.device_limit
FROM sessions s
JOIN licenses l ON s.license_key = l.license_key
WHERE s.is_active = true
GROUP BY s.license_key, l.customer_name, l.device_limit
ORDER BY session_count DESC
LIMIT 10
""")
top_licenses = []
for row in cur.fetchall():
top_licenses.append({
'license_key': row[0],
'customer_name': row[1],
'session_count': row[2],
'device_limit': row[3]
})
# Session-Verlauf (letzte 7 Tage)
cur.execute("""
SELECT
DATE(started_at) as date,
COUNT(*) as login_count,
COUNT(DISTINCT license_key) as unique_licenses,
COUNT(DISTINCT username) as unique_users
FROM sessions
WHERE started_at >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY DATE(started_at)
ORDER BY date
""")
session_history = []
for row in cur.fetchall():
session_history.append({
'date': row[0].strftime('%Y-%m-%d'),
'login_count': row[1],
'unique_licenses': row[2],
'unique_users': row[3]
})
# Durchschnittliche Session-Dauer
cur.execute("""
SELECT
AVG(EXTRACT(EPOCH FROM (ended_at - started_at))/3600) as avg_duration_hours
FROM sessions
WHERE is_active = false
AND ended_at IS NOT NULL
AND ended_at - started_at < INTERVAL '24 hours'
AND started_at >= CURRENT_DATE - INTERVAL '30 days'
""")
avg_duration = cur.fetchone()[0] or 0
return render_template("session_statistics.html",
current_stats={
'active_licenses': current_stats[0],
'unique_users': current_stats[1],
'unique_devices': current_stats[2],
'total_sessions': current_stats[3]
},
sessions_by_type=sessions_by_type,
top_licenses=top_licenses,
session_history=session_history,
avg_duration=round(avg_duration, 1))
except Exception as e:
logging.error(f"Fehler beim Laden der Session-Statistiken: {str(e)}")
flash('Fehler beim Laden der Statistiken!', 'error')
return redirect(url_for('sessions.sessions'))
finally:
cur.close()
conn.close()

58
v2_adminpanel/scheduled_backup.py Ausführbare Datei
Datei anzeigen

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from utils.backup import create_backup_with_github, create_server_backup
from datetime import datetime
import logging
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('/opt/v2-Docker/logs/scheduled_backup.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def run_scheduled_backup():
"""Run scheduled daily backups"""
logger.info("Starting scheduled backup process")
# Create database backup
logger.info("Creating database backup...")
db_success, db_result = create_backup_with_github(
backup_type="scheduled",
created_by="cron",
push_to_github=True,
delete_local=True
)
if db_success:
logger.info(f"Database backup successful: {db_result}")
else:
logger.error(f"Database backup failed: {db_result}")
# Create server backup
logger.info("Creating server backup...")
server_success, server_result = create_server_backup(
created_by="cron",
push_to_github=True,
delete_local=True
)
if server_success:
logger.info(f"Server backup successful: {server_result}")
else:
logger.error(f"Server backup failed: {server_result}")
logger.info("Scheduled backup process completed")
return db_success and server_success
if __name__ == "__main__":
success = run_scheduled_backup()
sys.exit(0 if success else 1)

165
v2_adminpanel/scheduler.py Normale Datei
Datei anzeigen

@@ -0,0 +1,165 @@
"""
Scheduler module for handling background tasks
"""
import logging
from apscheduler.schedulers.background import BackgroundScheduler
import config
from utils.backup import create_backup
from db import get_connection
def scheduled_backup():
"""Führt ein geplantes Backup aus"""
logging.info("Starte geplantes Backup...")
create_backup(backup_type="scheduled", created_by="scheduler")
def cleanup_expired_sessions():
"""Clean up expired license sessions"""
try:
conn = get_connection()
cur = conn.cursor()
# Get client config for timeout value
cur.execute("""
SELECT session_timeout
FROM client_configs
WHERE client_name = 'Account Forger'
""")
result = cur.fetchone()
timeout_seconds = result[0] if result else 60
# Find expired sessions
cur.execute("""
SELECT id, license_id, hardware_id, ip_address, client_version, started_at
FROM license_sessions
WHERE last_heartbeat < CURRENT_TIMESTAMP - INTERVAL '%s seconds'
""", (timeout_seconds,))
expired_sessions = cur.fetchall()
if expired_sessions:
logging.info(f"Found {len(expired_sessions)} expired sessions to clean up")
for session in expired_sessions:
# Log to history
cur.execute("""
INSERT INTO session_history
(license_id, hardware_id, ip_address, client_version, started_at, ended_at, end_reason)
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP, 'timeout')
""", (session[1], session[2], session[3], session[4], session[5]))
# Delete session
cur.execute("DELETE FROM license_sessions WHERE id = %s", (session[0],))
conn.commit()
logging.info(f"Cleaned up {len(expired_sessions)} expired sessions")
cur.close()
conn.close()
except Exception as e:
logging.error(f"Error cleaning up sessions: {str(e)}")
if 'conn' in locals():
conn.rollback()
def deactivate_expired_licenses():
"""Deactivate licenses that have expired"""
try:
conn = get_connection()
cur = conn.cursor()
# Find active licenses that have expired
# Check valid_until < today (at midnight)
cur.execute("""
SELECT id, license_key, customer_id, valid_until
FROM licenses
WHERE is_active = true
AND valid_until IS NOT NULL
AND valid_until < CURRENT_DATE
AND is_fake = false
""")
expired_licenses = cur.fetchall()
if expired_licenses:
logging.info(f"Found {len(expired_licenses)} expired licenses to deactivate")
for license in expired_licenses:
license_id, license_key, customer_id, valid_until = license
# Deactivate the license
cur.execute("""
UPDATE licenses
SET is_active = false,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""", (license_id,))
# Log to audit trail
cur.execute("""
INSERT INTO audit_log
(timestamp, username, action, entity_type, entity_id,
old_values, new_values, additional_info)
VALUES (CURRENT_TIMESTAMP, 'system', 'DEACTIVATE', 'license', %s,
jsonb_build_object('is_active', true),
jsonb_build_object('is_active', false),
%s)
""", (license_id, f"License automatically deactivated due to expiration. Valid until: {valid_until}"))
logging.info(f"Deactivated expired license: {license_key} (ID: {license_id})")
conn.commit()
logging.info(f"Successfully deactivated {len(expired_licenses)} expired licenses")
else:
logging.debug("No expired licenses found to deactivate")
cur.close()
conn.close()
except Exception as e:
logging.error(f"Error deactivating expired licenses: {str(e)}")
if 'conn' in locals():
conn.rollback()
def init_scheduler():
"""Initialize and configure the scheduler"""
scheduler = BackgroundScheduler()
# Configure daily backup job
scheduler.add_job(
scheduled_backup,
'cron',
hour=config.SCHEDULER_CONFIG['backup_hour'],
minute=config.SCHEDULER_CONFIG['backup_minute'],
id='daily_backup',
replace_existing=True
)
# Configure session cleanup job - runs every 60 seconds
scheduler.add_job(
cleanup_expired_sessions,
'interval',
seconds=60,
id='session_cleanup',
replace_existing=True
)
# Configure license expiration job - runs daily at midnight
scheduler.add_job(
deactivate_expired_licenses,
'cron',
hour=0,
minute=0,
id='license_expiration_check',
replace_existing=True
)
scheduler.start()
logging.info(f"Scheduler started. Daily backup scheduled at {config.SCHEDULER_CONFIG['backup_hour']:02d}:{config.SCHEDULER_CONFIG['backup_minute']:02d}")
logging.info("Session cleanup job scheduled to run every 60 seconds")
logging.info("License expiration check scheduled to run daily at midnight")
return scheduler

Datei anzeigen

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Seite nicht gefunden{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-body text-center">
<h1 class="display-1">404</h1>
<h2>Seite nicht gefunden</h2>
<p>Die angeforderte Seite konnte nicht gefunden werden.</p>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-primary">Zur Startseite</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Serverfehler{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-lg">
<div class="card-body text-center py-5">
<div class="mb-4">
<i class="bi bi-exclamation-octagon text-danger" style="font-size: 4rem;"></i>
</div>
<h1 class="display-1 text-danger">500</h1>
<h2 class="mb-4">Interner Serverfehler</h2>
<p class="lead mb-4">
Es tut uns leid, aber es ist ein unerwarteter Fehler aufgetreten.
Unser Team wurde benachrichtigt und arbeitet an einer Lösung.
</p>
{% if error %}
<div class="alert alert-warning text-start mx-auto" style="max-width: 500px;">
<p class="mb-0"><strong>Fehlermeldung:</strong> {{ error }}</p>
</div>
{% endif %}
<div class="mt-4">
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-primary btn-lg">
<i class="bi bi-house"></i> Zum Dashboard
</a>
<button onclick="location.reload();" class="btn btn-outline-secondary btn-lg ms-2">
<i class="bi bi-arrow-clockwise"></i> Seite neu laden
</button>
</div>
<div class="mt-5 text-muted">
<small>
Fehler-ID: <code>{{ request_id or 'Nicht verfügbar' }}</code><br>
Zeitstempel: {{ timestamp or 'Nicht verfügbar' }}
</small>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,439 @@
{% extends "base.html" %}
{% block title %}Ressourcen hinzufügen{% endblock %}
{% block extra_css %}
<style>
/* Card Styling */
.main-card {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border: none;
}
/* Preview Section */
.preview-card {
background-color: #f8f9fa;
border: 2px dashed #dee2e6;
transition: all 0.3s ease;
}
.preview-card.active {
border-color: #28a745;
background-color: #e8f5e9;
}
/* Format Examples */
.example-card {
height: 100%;
transition: transform 0.2s ease;
}
.example-card:hover {
transform: translateY(-3px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.example-code {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.25rem;
padding: 1rem;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
margin: 0;
}
/* Resource Type Selector */
.resource-type-selector {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.resource-type-option {
padding: 1.5rem;
border: 2px solid #e9ecef;
border-radius: 0.5rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background-color: #fff;
}
.resource-type-option:hover {
border-color: #007bff;
background-color: #f8f9fa;
}
.resource-type-option.selected {
border-color: #007bff;
background-color: #e7f3ff;
}
.resource-type-option .icon {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
/* Textarea Styling */
.resource-input {
font-family: 'Courier New', monospace;
background-color: #f8f9fa;
border: 2px solid #dee2e6;
transition: border-color 0.3s ease;
}
.resource-input:focus {
background-color: #fff;
border-color: #80bdff;
}
/* Stats Display */
.stats-display {
display: flex;
justify-content: space-around;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 0.5rem;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #007bff;
}
.stat-label {
font-size: 0.875rem;
color: #6c757d;
text-transform: uppercase;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row justify-content-center">
<div class="col-lg-10">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-0">Ressourcen hinzufügen</h1>
<p class="text-muted mb-0">Fügen Sie neue Domains, IPs oder Telefonnummern zum Pool hinzu</p>
</div>
<a href="{{ url_for('resources.resources', show_test=show_test) }}" class="btn btn-secondary">
← Zurück zur Übersicht
</a>
</div>
<form method="post" action="{{ url_for('resources.add_resources', show_test=show_test) }}" id="addResourceForm">
<!-- Resource Type Selection -->
<div class="card main-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">1⃣ Ressourcentyp wählen</h5>
</div>
<div class="card-body">
<input type="hidden" name="resource_type" id="resource_type" required>
<div class="resource-type-selector">
<div class="resource-type-option" data-type="domain">
<div class="icon">🌐</div>
<h6 class="mb-0">Domain</h6>
<small class="text-muted">Webseiten-Adressen</small>
</div>
<div class="resource-type-option" data-type="ipv4">
<div class="icon">🖥️</div>
<h6 class="mb-0">IPv4</h6>
<small class="text-muted">IP-Adressen</small>
</div>
<div class="resource-type-option" data-type="phone">
<div class="icon">📱</div>
<h6 class="mb-0">Telefon</h6>
<small class="text-muted">Telefonnummern</small>
</div>
</div>
</div>
</div>
<!-- Resource Input -->
<div class="card main-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">2⃣ Ressourcen eingeben</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="resources_text" class="form-label">
Ressourcen (eine pro Zeile)
</label>
<textarea name="resources_text"
id="resources_text"
class="form-control resource-input"
rows="12"
required
placeholder="Bitte wählen Sie zuerst einen Ressourcentyp aus..."></textarea>
<div class="form-text">
<i class="fas fa-info-circle"></i>
Geben Sie jede Ressource in eine neue Zeile ein. Duplikate werden automatisch übersprungen.
</div>
</div>
<!-- Live Preview -->
<div class="preview-card p-3" id="preview">
<h6 class="mb-3">📊 Live-Vorschau</h6>
<div class="stats-display">
<div class="stat-item">
<div class="stat-value" id="validCount">0</div>
<div class="stat-label">Gültig</div>
</div>
<div class="stat-item">
<div class="stat-value text-warning" id="duplicateCount">0</div>
<div class="stat-label">Duplikate</div>
</div>
<div class="stat-item">
<div class="stat-value text-danger" id="invalidCount">0</div>
<div class="stat-label">Ungültig</div>
</div>
</div>
<div id="errorList" class="mt-3" style="display: none;">
<div class="alert alert-danger">
<strong>Fehler gefunden:</strong>
<ul id="errorMessages" class="mb-0"></ul>
</div>
</div>
</div>
</div>
</div>
<!-- Format Examples -->
<div class="card main-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">💡 Format-Beispiele</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="card example-card h-100">
<div class="card-body">
<h6 class="card-title">
<span class="text-primary">🌐</span> Domains
</h6>
<pre class="example-code">example.com
test-domain.net
meine-seite.de
subdomain.example.org
my-website.io</pre>
<div class="alert alert-info mt-3 mb-0">
<small>
<strong>Format:</strong> Ohne http(s)://<br>
<strong>Erlaubt:</strong> Buchstaben, Zahlen, Punkt, Bindestrich
</small>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card example-card h-100">
<div class="card-body">
<h6 class="card-title">
<span class="text-primary">🖥️</span> IPv4-Adressen
</h6>
<pre class="example-code">192.168.1.10
192.168.1.11
10.0.0.1
172.16.0.5
8.8.8.8</pre>
<div class="alert alert-info mt-3 mb-0">
<small>
<strong>Format:</strong> xxx.xxx.xxx.xxx<br>
<strong>Bereich:</strong> 0-255 pro Oktett
</small>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card example-card h-100">
<div class="card-body">
<h6 class="card-title">
<span class="text-primary">📱</span> Telefonnummern
</h6>
<pre class="example-code">+491701234567
+493012345678
+33123456789
+441234567890
+12125551234</pre>
<div class="alert alert-info mt-3 mb-0">
<small>
<strong>Format:</strong> Mit Ländervorwahl<br>
<strong>Start:</strong> Immer mit +
</small>
</div>
</div>
</div>
</div>
</div>
<!-- Test Data Option -->
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="is_test" name="is_test" {% if show_test %}checked{% endif %}>
<label class="form-check-label" for="is_test">
Als Testdaten markieren
</label>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-secondary" onclick="window.location.href='{{ url_for('resources.resources', show_test=show_test) }}'">
<i class="fas fa-times"></i> Abbrechen
</button>
<button type="submit" class="btn btn-success btn-lg" id="submitBtn" disabled>
<i class="fas fa-plus-circle"></i> Ressourcen hinzufügen
</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const typeOptions = document.querySelectorAll('.resource-type-option');
const typeInput = document.getElementById('resource_type');
const textArea = document.getElementById('resources_text');
const submitBtn = document.getElementById('submitBtn');
const form = document.getElementById('addResourceForm');
// Preview elements
const validCount = document.getElementById('validCount');
const duplicateCount = document.getElementById('duplicateCount');
const invalidCount = document.getElementById('invalidCount');
const errorList = document.getElementById('errorList');
const errorMessages = document.getElementById('errorMessages');
const preview = document.getElementById('preview');
let selectedType = null;
// Placeholder texts for different types
const placeholders = {
domain: `example.com
test-site.net
my-domain.org
subdomain.example.com`,
ipv4: `192.168.1.1
10.0.0.1
172.16.0.1
8.8.8.8`,
phone: `+491234567890
+4930123456
+33123456789
+12125551234`
};
// Resource type selection
typeOptions.forEach(option => {
option.addEventListener('click', function() {
typeOptions.forEach(opt => opt.classList.remove('selected'));
this.classList.add('selected');
selectedType = this.dataset.type;
typeInput.value = selectedType;
textArea.placeholder = placeholders[selectedType] || '';
textArea.disabled = false;
updatePreview();
});
});
// Validation functions
function validateDomain(domain) {
const domainRegex = /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9][a-zA-Z0-9-_]+\.[a-zA-Z]{2,}$/;
return domainRegex.test(domain);
}
function validateIPv4(ip) {
const ipRegex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipRegex.test(ip);
}
function validatePhone(phone) {
const phoneRegex = /^\+[1-9]\d{6,14}$/;
return phoneRegex.test(phone);
}
// Update preview function
function updatePreview() {
if (!selectedType) {
preview.classList.remove('active');
submitBtn.disabled = true;
return;
}
const lines = textArea.value.split('\n').filter(line => line.trim() !== '');
const uniqueResources = new Set();
const errors = [];
let valid = 0;
let duplicates = 0;
let invalid = 0;
lines.forEach((line, index) => {
const trimmed = line.trim();
if (uniqueResources.has(trimmed)) {
duplicates++;
return;
}
let isValid = false;
switch(selectedType) {
case 'domain':
isValid = validateDomain(trimmed);
break;
case 'ipv4':
isValid = validateIPv4(trimmed);
break;
case 'phone':
isValid = validatePhone(trimmed);
break;
}
if (isValid) {
valid++;
uniqueResources.add(trimmed);
} else {
invalid++;
errors.push(`Zeile ${index + 1}: "${trimmed}"`);
}
});
// Update counts
validCount.textContent = valid;
duplicateCount.textContent = duplicates;
invalidCount.textContent = invalid;
// Show/hide error list
if (errors.length > 0) {
errorList.style.display = 'block';
errorMessages.innerHTML = errors.map(err => `<li>${err}</li>`).join('');
} else {
errorList.style.display = 'none';
}
// Enable/disable submit button
submitBtn.disabled = valid === 0 || invalid > 0;
// Update preview appearance
if (lines.length > 0) {
preview.classList.add('active');
} else {
preview.classList.remove('active');
}
}
// Live validation
textArea.addEventListener('input', updatePreview);
// Form submission
form.addEventListener('submit', function(e) {
if (submitBtn.disabled) {
e.preventDefault();
alert('Bitte beheben Sie alle Fehler bevor Sie fortfahren.');
}
});
// Initial state
textArea.disabled = true;
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,391 @@
{% extends "base.html" %}
{% block title %}Log{% endblock %}
{% macro sortable_header(label, field, current_sort, current_order) %}
<th>
{% if current_sort == field %}
<a href="{{ url_for('admin.audit_log', sort=field, order='desc' if current_order == 'asc' else 'asc', user=filter_user, action=action_filter, entity=entity_filter, page=1) }}"
class="server-sortable">
{% else %}
<a href="{{ url_for('admin.audit_log', sort=field, order='asc', user=filter_user, action=action_filter, entity=entity_filter, page=1) }}"
class="server-sortable">
{% endif %}
{{ label }}
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
{% if current_sort == field %}
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
{% else %}
{% endif %}
</span>
</a>
</th>
{% endmacro %}
{% block extra_css %}
<style>
.audit-details {
font-size: 0.85em;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
.json-display {
background-color: #f8f9fa;
padding: 5px;
border-radius: 3px;
font-family: monospace;
font-size: 0.8em;
max-height: 100px;
overflow-y: auto;
}
/* CRUD-Operationen */
.action-CREATE { color: #28a745; }
.action-UPDATE { color: #007bff; }
.action-DELETE { color: #dc3545; }
.action-IMPORT { color: #6f42c1; }
/* Authentifizierung */
.action-LOGIN { color: #17a2b8; }
.action-LOGIN_SUCCESS { color: #28a745; }
.action-LOGIN_FAILED { color: #dc3545; }
.action-LOGIN_BLOCKED { color: #b91c1c; }
.action-LOGOUT { color: #6c757d; }
.action-AUTO_LOGOUT { color: #fd7e14; }
/* 2FA */
.action-LOGIN_2FA_SUCCESS { color: #00a86b; }
.action-LOGIN_2FA_BACKUP { color: #059862; }
.action-LOGIN_2FA_FAILED { color: #e53e3e; }
.action-2FA_ENABLED { color: #38a169; }
.action-2FA_DISABLED { color: #e53e3e; }
/* Lizenzverwaltung */
.action-GENERATE_KEY { color: #20c997; }
.action-CREATE_BATCH { color: #6610f2; }
.action-BATCH_UPDATE { color: #17a2b8; }
.action-TOGGLE { color: #ffc107; }
.action-QUICK_EDIT { color: #00bcd4; }
.action-BULK_ACTIVATE { color: #4caf50; }
.action-BULK_DEACTIVATE { color: #ff5722; }
.action-BULK_DELETE { color: #f44336; }
/* Geräteverwaltung */
.action-DEVICE_REGISTER { color: #2196f3; }
.action-DEVICE_DEACTIVATE { color: #ff9800; }
/* Ressourcenverwaltung */
.action-RESOURCE_ALLOCATE { color: #009688; }
.action-QUARANTINE { color: #ff6b6b; }
.action-RELEASE { color: #51cf66; }
.action-BULK_CREATE { color: #845ef7; }
/* Session-Verwaltung */
.action-SESSION_TERMINATE { color: #e91e63; }
.action-SESSION_TERMINATE_ALL { color: #c2255c; }
.action-SESSION_CLEANUP { color: #868e96; }
/* System-Operationen */
.action-BACKUP { color: #5a67d8; }
.action-BACKUP_DOWNLOAD { color: #4299e1; }
.action-BACKUP_DELETE { color: #e53e3e; }
.action-RESTORE { color: #4299e1; }
.action-EXPORT { color: #ffc107; }
.action-PASSWORD_CHANGE { color: #805ad5; }
.action-UNBLOCK_IP { color: #22b8cf; }
.action-CLEAR_LOGIN_ATTEMPTS { color: #37b24d; }
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="mb-4">
<h2>📝 Log</h2>
</div>
<!-- Filter -->
<div class="card mb-3">
<div class="card-body">
<form method="get" action="{{ url_for('admin.audit_log') }}" id="auditFilterForm">
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label for="user" class="form-label">Benutzer</label>
<select class="form-select" id="user" name="user">
<option value="">Alle Benutzer</option>
<option value="rac00n" {% if filter_user == 'rac00n' %}selected{% endif %}>rac00n</option>
<option value="w@rh@mm3r" {% if filter_user == 'w@rh@mm3r' %}selected{% endif %}>w@rh@mm3r</option>
<option value="system" {% if filter_user == 'system' %}selected{% endif %}>System</option>
</select>
</div>
<div class="col-md-3">
<label for="action" class="form-label">Aktion</label>
<select class="form-select" id="action" name="action">
<option value="">Alle Aktionen</option>
{% for action in actions %}
<option value="{{ action }}" {% if action_filter == action %}selected{% endif %}>
{% if action == 'CREATE' %} Erstellt
{% elif action == 'UPDATE' %}✏️ Bearbeitet
{% elif action == 'DELETE' %}🗑️ Gelöscht
{% elif action == 'IMPORT' %}📤 Import
{% elif action == 'LOGIN' %}🔑 Anmeldung
{% elif action == 'LOGIN_SUCCESS' %}✅ Anmeldung erfolgreich
{% elif action == 'LOGIN_FAILED' %}❌ Anmeldung fehlgeschlagen
{% elif action == 'LOGIN_BLOCKED' %}🚫 Login blockiert
{% elif action == 'LOGOUT' %}🚪 Abmeldung
{% elif action == 'AUTO_LOGOUT' %}⏰ Auto-Logout
{% elif action == 'LOGIN_2FA_SUCCESS' %}🔐 2FA-Anmeldung
{% elif action == 'LOGIN_2FA_FAILED' %}⛔ 2FA fehlgeschlagen
{% elif action == 'LOGIN_2FA_BACKUP' %}🔒 2FA-Backup-Code
{% elif action == '2FA_ENABLED' %}✅ 2FA aktiviert
{% elif action == '2FA_DISABLED' %}❌ 2FA deaktiviert
{% elif action == 'GENERATE_KEY' %}🔑 Key generiert
{% elif action == 'CREATE_BATCH' %}📦 Batch erstellt
{% elif action == 'BATCH_UPDATE' %}🔄 Batch Update
{% elif action == 'TOGGLE' %}🔄 Status geändert
{% elif action == 'QUICK_EDIT' %}⚡ Schnellbearbeitung
{% elif action == 'BULK_ACTIVATE' %}✅ Bulk-Aktivierung
{% elif action == 'BULK_DEACTIVATE' %}❌ Bulk-Deaktivierung
{% elif action == 'BULK_DELETE' %}🗑️ Bulk-Löschung
{% elif action == 'DEVICE_REGISTER' %}📱 Gerät registriert
{% elif action == 'DEVICE_DEACTIVATE' %}📵 Gerät deaktiviert
{% elif action == 'RESOURCE_ALLOCATE' %}📎 Ressource zugewiesen
{% elif action == 'QUARANTINE' %}⚠️ In Quarantäne
{% elif action == 'RELEASE' %}✅ Freigegeben
{% elif action == 'BULK_CREATE' %} Bulk-Erstellung
{% elif action == 'SESSION_TERMINATE' %}🛑 Session beendet
{% elif action == 'SESSION_TERMINATE_ALL' %}🛑 Alle Sessions beendet
{% elif action == 'SESSION_CLEANUP' %}🧹 Session-Bereinigung
{% elif action == 'BACKUP' %}💾 Backup
{% elif action == 'BACKUP_DOWNLOAD' %}⬇️ Backup Download
{% elif action == 'BACKUP_DELETE' %}🗑️ Backup gelöscht
{% elif action == 'RESTORE' %}🔄 Wiederhergestellt
{% elif action == 'EXPORT' %}📥 Export
{% elif action == 'PASSWORD_CHANGE' %}🔐 Passwort geändert
{% elif action == 'UNBLOCK_IP' %}🔓 IP entsperrt
{% elif action == 'CLEAR_LOGIN_ATTEMPTS' %}🧹 Login-Versuche gelöscht
{% else %}{{ action }}
{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="entity" class="form-label">Entität</label>
<select class="form-select" id="entity" name="entity">
<option value="">Alle Entitäten</option>
{% for entity in entities %}
<option value="{{ entity }}" {% if entity_filter == entity %}selected{% endif %}>
{% if entity == 'license' %}Lizenz
{% elif entity == 'customer' %}Kunde
{% elif entity == 'user' %}Benutzer
{% elif entity == 'session' %}Session
{% elif entity == 'resource' %}Ressource
{% elif entity == 'backup' %}Backup
{% elif entity == 'database' %}Datenbank
{% elif entity == 'system' %}System
{% else %}{{ entity|title }}
{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<div class="d-flex gap-2">
<a href="{{ url_for('admin.audit_log') }}" class="btn btn-outline-secondary">Zurücksetzen</a>
<!-- Export Buttons -->
<div class="btn-group" role="group">
<a href="{{ url_for('export.export_audit', format='excel', user=filter_user, action=action_filter, entity=entity_filter) }}" class="btn btn-success btn-sm">
<i class="bi bi-file-earmark-excel"></i> Excel
</a>
<a href="{{ url_for('export.export_audit', format='csv', user=filter_user, action=action_filter, entity=entity_filter) }}" class="btn btn-secondary btn-sm">
<i class="bi bi-file-earmark-text"></i> CSV
</a>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- Audit Log Tabelle -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Zeitstempel</th>
<th>Benutzer</th>
<th>Aktion</th>
<th>Entität</th>
<th>Details</th>
<th>IP-Adresse</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.timestamp.strftime('%d.%m.%Y %H:%M:%S') }}</td>
<td><strong>{{ log.username }}</strong></td>
<td>
<span class="action-{{ log.action }}">
{% if log.action == 'CREATE' %} Erstellt
{% elif log.action == 'UPDATE' %}✏️ Bearbeitet
{% elif log.action == 'DELETE' %}🗑️ Gelöscht
{% elif log.action == 'LOGIN' %}🔑 Anmeldung
{% elif log.action == 'LOGOUT' %}🚪 Abmeldung
{% elif log.action == 'AUTO_LOGOUT' %}⏰ Auto-Logout
{% elif log.action == 'EXPORT' %}📥 Export
{% elif log.action == 'GENERATE_KEY' %}🔑 Key generiert
{% elif log.action == 'CREATE_BATCH' %}🔑 Batch erstellt
{% elif log.action == 'BACKUP' %}💾 Backup erstellt
{% elif log.action == 'LOGIN_2FA_SUCCESS' %}🔐 2FA-Anmeldung
{% elif log.action == 'LOGIN_2FA_BACKUP' %}🔒 2FA-Backup-Code
{% elif log.action == 'LOGIN_2FA_FAILED' %}⛔ 2FA-Fehlgeschlagen
{% elif log.action == 'LOGIN_BLOCKED' %}🚫 Login-Blockiert
{% elif log.action == 'RESTORE' %}🔄 Wiederhergestellt
{% elif log.action == 'PASSWORD_CHANGE' %}🔐 Passwort geändert
{% elif log.action == '2FA_ENABLED' %}✅ 2FA aktiviert
{% elif log.action == '2FA_DISABLED' %}❌ 2FA deaktiviert
{% else %}{{ log.action }}
{% endif %}
</span>
</td>
<td>
{{ log.entity_type }}
{% if log.entity_id %}
<small class="text-muted">#{{ log.entity_id }}</small>
{% endif %}
</td>
<td class="audit-details">
{% if log.additional_info %}
<div class="mb-1"><small class="text-muted">{{ log.additional_info }}</small></div>
{% endif %}
{% if log.old_values and log.action == 'DELETE' %}
<details>
<summary>Gelöschte Werte</summary>
<div class="json-display">
{% for key, value in log.old_values.items() %}
<strong>{{ key }}:</strong> {{ value }}<br>
{% endfor %}
</div>
</details>
{% elif log.old_values and log.new_values and log.action == 'UPDATE' %}
<details>
<summary>Änderungen anzeigen</summary>
<div class="json-display">
<strong>Vorher:</strong><br>
{% for key, value in log.old_values.items() %}
{% if log.new_values[key] != value %}
{{ key }}: {{ value }}<br>
{% endif %}
{% endfor %}
<hr class="my-1">
<strong>Nachher:</strong><br>
{% for key, value in log.new_values.items() %}
{% if log.old_values[key] != value %}
{{ key }}: {{ value }}<br>
{% endif %}
{% endfor %}
</div>
</details>
{% elif log.new_values and log.action == 'CREATE' %}
<details>
<summary>Erstellte Werte</summary>
<div class="json-display">
{% for key, value in log.new_values.items() %}
<strong>{{ key }}:</strong> {{ value }}<br>
{% endfor %}
</div>
</details>
{% endif %}
</td>
<td>
<small class="text-muted">{{ log.ip_address or '-' }}</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not logs %}
<div class="text-center py-5">
<p class="text-muted">Keine Audit-Log-Einträge gefunden.</p>
</div>
{% endif %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav aria-label="Seitennavigation" class="mt-3">
<ul class="pagination justify-content-center">
<!-- Erste Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('admin.audit_log', page=1, user=filter_user, action=action_filter, entity=entity_filter, sort=sort, order=order) }}">Erste</a>
</li>
<!-- Vorherige Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('admin.audit_log', page=page-1, user=filter_user, action=action_filter, entity=entity_filter, sort=sort, order=order) }}"></a>
</li>
<!-- Seitenzahlen -->
{% for p in range(1, total_pages + 1) %}
{% if p >= page - 2 and p <= page + 2 %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('admin.audit_log', page=p, user=filter_user, action=action_filter, entity=entity_filter, sort=sort, order=order) }}">{{ p }}</a>
</li>
{% endif %}
{% endfor %}
<!-- Nächste Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('admin.audit_log', page=page+1, user=filter_user, action=action_filter, entity=entity_filter, sort=sort, order=order) }}"></a>
</li>
<!-- Letzte Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('admin.audit_log', page=total_pages, user=filter_user, action=action_filter, entity=entity_filter, sort=sort, order=order) }}">Letzte</a>
</li>
</ul>
<p class="text-center text-muted">
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Einträge
</p>
</nav>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Live Filtering für Audit Log
document.addEventListener('DOMContentLoaded', function() {
const filterForm = document.getElementById('auditFilterForm');
const userInput = document.getElementById('user');
const actionSelect = document.getElementById('action');
const entitySelect = document.getElementById('entity');
// Debounce timer für Textfelder
let searchTimeout;
// Live-Filter für Benutzer-Dropdown (sofort)
userInput.addEventListener('change', function() {
filterForm.submit();
});
// Live-Filter für Dropdowns (sofort)
actionSelect.addEventListener('change', function() {
filterForm.submit();
});
entitySelect.addEventListener('change', function() {
filterForm.submit();
});
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,228 @@
{% extends "base.html" %}
{% block title %}Backup-Codes{% endblock %}
{% block extra_css %}
<style>
.success-icon {
font-size: 5rem;
animation: bounce 0.5s ease-in-out;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-20px); }
}
.backup-code {
font-family: 'Courier New', monospace;
font-size: 1.3rem;
font-weight: bold;
letter-spacing: 2px;
padding: 8px 15px;
background-color: #f8f9fa;
border: 2px dashed #dee2e6;
border-radius: 5px;
display: inline-block;
margin: 5px;
}
.backup-codes-container {
background-color: #fff;
border: 2px solid #dee2e6;
border-radius: 10px;
padding: 30px;
margin: 20px 0;
}
.action-buttons .btn {
margin: 5px;
}
@media print {
.no-print {
display: none !important;
}
.backup-codes-container {
border: 1px solid #000;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row">
<div class="col-md-8 offset-md-2">
<!-- Success Message -->
<div class="text-center mb-4">
<div class="success-icon text-success"></div>
<h1 class="mt-3">2FA erfolgreich aktiviert!</h1>
<p class="lead text-muted">Ihre Zwei-Faktor-Authentifizierung ist jetzt aktiv.</p>
</div>
<!-- Backup Codes Card -->
<div class="card shadow">
<div class="card-header bg-warning text-dark">
<h4 class="mb-0">
<span style="font-size: 1.5rem; vertical-align: middle;">⚠️</span>
Wichtig: Ihre Backup-Codes
</h4>
</div>
<div class="card-body">
<div class="alert alert-info mb-4">
<strong>Was sind Backup-Codes?</strong><br>
Diese Codes ermöglichen Ihnen den Zugang zu Ihrem Account, falls Sie keinen Zugriff auf Ihre Authenticator-App haben.
<strong>Jeder Code kann nur einmal verwendet werden.</strong>
</div>
<!-- Backup Codes Display -->
<div class="backup-codes-container text-center">
<h5 class="mb-4">Ihre 8 Backup-Codes:</h5>
<div class="row justify-content-center">
{% for code in backup_codes %}
<div class="col-md-6 col-lg-4 mb-3">
<div class="backup-code">{{ code }}</div>
</div>
{% endfor %}
</div>
</div>
<!-- Action Buttons -->
<div class="text-center action-buttons no-print">
<button type="button" class="btn btn-primary btn-lg" onclick="downloadCodes()">
💾 Als Datei speichern
</button>
<button type="button" class="btn btn-secondary btn-lg" onclick="printCodes()">
🖨️ Drucken
</button>
<button type="button" class="btn btn-info btn-lg" onclick="copyCodes()">
📋 Alle kopieren
</button>
</div>
<hr class="my-4">
<!-- Security Tips -->
<div class="row">
<div class="col-md-6">
<div class="alert alert-danger">
<h6>❌ Nicht empfohlen:</h6>
<ul class="mb-0 small">
<li>Im selben Passwort-Manager wie Ihr Passwort</li>
<li>Als Foto auf Ihrem Handy</li>
<li>In einer unverschlüsselten Datei</li>
<li>Per E-Mail an sich selbst</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-success">
<h6>✅ Empfohlen:</h6>
<ul class="mb-0 small">
<li>Ausgedruckt in einem Safe</li>
<li>In einem separaten Passwort-Manager</li>
<li>Verschlüsselt auf einem USB-Stick</li>
<li>An einem sicheren Ort zu Hause</li>
</ul>
</div>
</div>
</div>
<!-- Confirmation -->
<div class="text-center mt-4 no-print">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="confirmSaved" onchange="checkConfirmation()">
<label class="form-check-label" for="confirmSaved">
Ich habe die Backup-Codes sicher gespeichert
</label>
</div>
<a href="{{ url_for('auth.profile') }}" class="btn btn-lg btn-success" id="continueBtn" style="display: none;">
✅ Weiter zum Profil
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const backupCodes = {{ backup_codes | tojson }};
function checkConfirmation() {
const checkbox = document.getElementById('confirmSaved');
const continueBtn = document.getElementById('continueBtn');
continueBtn.style.display = checkbox.checked ? 'inline-block' : 'none';
}
function downloadCodes() {
const content = `V2 Admin Panel - Backup Codes
=====================================
Generiert am: ${new Date().toLocaleString('de-DE')}
WICHTIG: Bewahren Sie diese Codes sicher auf!
Jeder Code kann nur einmal verwendet werden.
Ihre 8 Backup-Codes:
--------------------
${backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}
Sicherheitshinweise:
-------------------
✓ Bewahren Sie diese Codes getrennt von Ihrem Passwort auf
✓ Speichern Sie sie an einem sicheren physischen Ort
✓ Teilen Sie diese Codes niemals mit anderen
✓ Jeder Code funktioniert nur einmal
Bei Verlust Ihrer Authenticator-App:
------------------------------------
1. Gehen Sie zur Login-Seite
2. Geben Sie Benutzername und Passwort ein
3. Klicken Sie auf "Backup-Code verwenden"
4. Geben Sie einen dieser Codes ein
Nach Verwendung eines Codes sollten Sie neue Backup-Codes generieren.`;
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `v2-backup-codes-${new Date().toISOString().split('T')[0]}.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Visual feedback
const btn = event.target;
const originalText = btn.innerHTML;
btn.innerHTML = '✅ Heruntergeladen!';
btn.classList.remove('btn-primary');
btn.classList.add('btn-success');
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove('btn-success');
btn.classList.add('btn-primary');
}, 2000);
}
function printCodes() {
window.print();
}
function copyCodes() {
const codesText = backupCodes.join('\n');
navigator.clipboard.writeText(codesText).then(function() {
// Visual feedback
const btn = event.target;
const originalText = btn.innerHTML;
btn.innerHTML = '✅ Kopiert!';
btn.classList.remove('btn-info');
btn.classList.add('btn-success');
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove('btn-success');
btn.classList.add('btn-info');
}, 2000);
}).catch(function(err) {
alert('Fehler beim Kopieren. Bitte manuell kopieren.');
});
}
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,494 @@
{% extends "base.html" %}
{% block title %}Backup-Verwaltung{% endblock %}
{% block extra_css %}
<style>
.status-success { color: #28a745; }
.status-failed { color: #dc3545; }
.status-in_progress { color: #ffc107; }
.backup-actions { white-space: nowrap; }
.backup-tabs { margin-bottom: 20px; }
.github-indicator {
display: inline-block;
margin-left: 10px;
}
.github-indicator.uploaded { color: #28a745; }
.github-indicator.not-uploaded { color: #6c757d; }
.backup-type-badge {
margin-left: 5px;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>💾 Backup-Verwaltung</h2>
<div>
<span class="text-muted">Automatische Backups: Täglich um 03:00 Uhr</span>
</div>
</div>
<!-- Backup-Info -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">📅 Letztes DB-Backup</h5>
{% set last_db_backup = backups | selectattr('backup_type', 'ne', 'server') | selectattr('status', 'eq', 'success') | first %}
{% if last_db_backup %}
<p class="mb-1"><strong>Zeitpunkt:</strong> {{ last_db_backup.created_at.strftime('%d.%m.%Y %H:%M:%S') }}</p>
<p class="mb-1"><strong>Größe:</strong> {{ (last_db_backup.filesize / 1024 / 1024)|round(2) }} MB</p>
{% if last_db_backup.github_uploaded %}
<p class="mb-0"><span class="badge bg-success">✅ GitHub gesichert</span></p>
{% endif %}
{% else %}
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">🖥️ Letztes Server-Backup</h5>
{% set last_server_backup = backups | selectattr('is_server_backup', 'eq', True) | selectattr('status', 'eq', 'success') | first %}
{% if last_server_backup %}
<p class="mb-1"><strong>Zeitpunkt:</strong> {{ last_server_backup.created_at.strftime('%d.%m.%Y %H:%M:%S') }}</p>
<p class="mb-1"><strong>Größe:</strong> {{ (last_server_backup.filesize / 1024 / 1024)|round(2) }} MB</p>
{% if last_server_backup.github_uploaded %}
<p class="mb-0"><span class="badge bg-success">✅ GitHub gesichert</span></p>
{% endif %}
{% else %}
<p class="text-muted mb-0">Noch kein Server-Backup vorhanden</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">🔧 Backup-Aktionen</h5>
<div class="d-grid gap-2">
<button class="btn btn-primary" onclick="createBackup('database')">
💾 Datenbank-Backup
</button>
<button class="btn btn-success" onclick="createBackup('server')">
🖥️ Server-Backup
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Tabs für lokale und GitHub Backups -->
<ul class="nav nav-tabs backup-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="local-tab" data-bs-toggle="tab" data-bs-target="#local" type="button">
📁 Lokale Backups
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="github-tab" data-bs-toggle="tab" data-bs-target="#github" type="button">
<i class="fab fa-github"></i> GitHub Backups
</button>
</li>
</ul>
<div class="tab-content">
<!-- Lokale Backups Tab -->
<div class="tab-pane fade show active" id="local" role="tabpanel">
<div class="card">
<div class="card-header">
<h5 class="mb-0">📋 Backup-Historie</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover sortable-table">
<thead>
<tr>
<th class="sortable" data-type="date">Zeitstempel</th>
<th class="sortable">Dateiname</th>
<th class="sortable" data-type="numeric">Größe</th>
<th class="sortable">Typ</th>
<th class="sortable">Status</th>
<th class="sortable">Erstellt von</th>
<th>GitHub</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for backup in backups %}
<tr>
<td>{{ backup.created_at.strftime('%d.%m.%Y %H:%M:%S') }}</td>
<td>
<small>{{ backup.filename or 'N/A' }}</small>
{% if backup.is_encrypted %}
<span class="badge bg-info ms-1">🔒</span>
{% endif %}
{% if backup.is_server_backup %}
<span class="badge bg-success backup-type-badge">Server</span>
{% else %}
<span class="badge bg-primary backup-type-badge">DB</span>
{% endif %}
</td>
<td>
{% if backup.filesize %}
{{ (backup.filesize / 1024 / 1024)|round(2) }} MB
{% else %}
-
{% endif %}
</td>
<td>
{% if backup.backup_type == 'manual' %}
<span class="badge bg-primary">Manuell</span>
{% elif backup.backup_type == 'server' %}
<span class="badge bg-success">Server</span>
{% else %}
<span class="badge bg-secondary">Automatisch</span>
{% endif %}
</td>
<td>
{% if backup.status == 'success' %}
<span class="status-success">✅ Erfolgreich</span>
{% elif backup.status == 'failed' %}
<span class="status-failed" title="{{ backup.error_message }}">❌ Fehlgeschlagen</span>
{% else %}
<span class="status-in_progress">⏳ In Bearbeitung</span>
{% endif %}
</td>
<td>{{ backup.created_by }}</td>
<td>
{% if backup.github_uploaded %}
<span class="github-indicator uploaded" title="Auf GitHub gesichert">
<i class="fab fa-github"></i>
</span>
{% else %}
<span class="github-indicator not-uploaded" title="Nicht auf GitHub">
<i class="fab fa-github"></i>
</span>
{% endif %}
</td>
<td class="backup-actions">
{% if backup.status == 'success' %}
<div class="btn-group btn-group-sm" role="group">
{% if backup.file_exists or backup.github_uploaded %}
<a href="{{ url_for('admin.download_backup', backup_id=backup.id) }}"
class="btn btn-outline-primary"
title="Backup herunterladen">
📥
</a>
{% endif %}
{% if not backup.github_uploaded and backup.file_exists %}
<button class="btn btn-outline-success"
onclick="pushToGitHub({{ backup.id }})"
title="Zu GitHub hochladen">
<i class="fab fa-github"></i>
</button>
{% endif %}
{% if not backup.is_server_backup %}
<button class="btn btn-outline-warning"
onclick="restoreBackup({{ backup.id }}, '{{ backup.filename }}')"
title="Backup wiederherstellen">
🔄
</button>
{% endif %}
<button class="btn btn-outline-danger"
onclick="deleteBackup({{ backup.id }}, '{{ backup.filename }}')"
title="Backup löschen">
🗑️
</button>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not backups %}
<div class="text-center py-5">
<p class="text-muted">Noch keine Backups vorhanden.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- GitHub Backups Tab -->
<div class="tab-pane fade" id="github" role="tabpanel">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fab fa-github"></i> GitHub Backup-Archiv</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Dateiname</th>
<th>Typ</th>
<th>Pfad</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for backup in github_backups %}
<tr>
<td>{{ backup.filename }}</td>
<td>
{% if backup.type == 'server' %}
<span class="badge bg-success">Server</span>
{% else %}
<span class="badge bg-primary">Datenbank</span>
{% endif %}
</td>
<td><small>{{ backup.path }}</small></td>
<td>
<button class="btn btn-sm btn-outline-primary"
onclick="downloadFromGitHub('{{ backup.path }}', '{{ backup.filename }}')"
title="Von GitHub herunterladen">
📥 Download
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not github_backups %}
<div class="text-center py-5">
<p class="text-muted">Keine Backups auf GitHub gefunden.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Wiederherstellungs-Modal -->
<div class="modal fade" id="restoreModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">🔄 Backup wiederherstellen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>⚠️ Warnung:</strong> Bei der Wiederherstellung werden alle aktuellen Daten überschrieben!
</div>
<p>Backup: <strong id="restoreFilename"></strong></p>
<form id="restoreForm">
<input type="hidden" id="restoreBackupId">
<div class="mb-3">
<label for="encryptionKey" class="form-label">Verschlüsselungs-Passwort (optional)</label>
<input type="password" class="form-control" id="encryptionKey"
placeholder="Leer lassen für Standard-Passwort">
<small class="text-muted">
Falls das Backup mit einem anderen Passwort verschlüsselt wurde
</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-danger" onclick="confirmRestore()">
⚠️ Wiederherstellen
</button>
</div>
</div>
</div>
</div>
<!-- Backup Options Modal -->
<div class="modal fade" id="backupOptionsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="backupOptionsTitle">💾 Backup erstellen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="backupOptionsForm">
<input type="hidden" id="backupType">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="pushToGitHub" checked>
<label class="form-check-label" for="pushToGitHub">
<i class="fab fa-github"></i> Automatisch zu GitHub hochladen
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="deleteLocal" checked>
<label class="form-check-label" for="deleteLocal">
🗑️ Lokale Kopie nach Upload löschen (Speicherplatz sparen)
</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" onclick="confirmCreateBackup()">
💾 Backup erstellen
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function createBackup(type) {
document.getElementById('backupType').value = type;
document.getElementById('backupOptionsTitle').textContent =
type === 'server' ? '🖥️ Server-Backup erstellen' : '💾 Datenbank-Backup erstellen';
const modal = new bootstrap.Modal(document.getElementById('backupOptionsModal'));
modal.show();
}
function confirmCreateBackup() {
const type = document.getElementById('backupType').value;
const pushToGitHub = document.getElementById('pushToGitHub').checked;
const deleteLocal = document.getElementById('deleteLocal').checked;
// Modal schließen
bootstrap.Modal.getInstance(document.getElementById('backupOptionsModal')).hide();
// Loading anzeigen
const loadingDiv = document.createElement('div');
loadingDiv.className = 'position-fixed top-50 start-50 translate-middle text-center';
loadingDiv.innerHTML = `
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div class="mt-2">Backup wird erstellt...</div>
`;
document.body.appendChild(loadingDiv);
fetch('{{ url_for('admin.create_backup_route') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: type,
push_to_github: pushToGitHub,
delete_local: deleteLocal
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message);
location.reload();
} else {
alert('❌ ' + data.message);
}
})
.catch(error => {
alert('❌ Fehler beim Erstellen des Backups: ' + error);
})
.finally(() => {
document.body.removeChild(loadingDiv);
});
}
function pushToGitHub(backupId) {
if (!confirm('Backup zu GitHub hochladen?')) {
return;
}
// TODO: Implement push to GitHub for existing backup
alert('Diese Funktion wird noch implementiert.');
}
function downloadFromGitHub(path, filename) {
// TODO: Implement download from GitHub
alert('Download von GitHub: ' + filename + '\n\nDiese Funktion wird noch implementiert.');
}
function restoreBackup(backupId, filename) {
document.getElementById('restoreBackupId').value = backupId;
document.getElementById('restoreFilename').textContent = filename;
document.getElementById('encryptionKey').value = '';
const modal = new bootstrap.Modal(document.getElementById('restoreModal'));
modal.show();
}
function confirmRestore() {
if (!confirm('Wirklich wiederherstellen? Alle aktuellen Daten werden überschrieben!')) {
return;
}
const backupId = document.getElementById('restoreBackupId').value;
const encryptionKey = document.getElementById('encryptionKey').value;
const formData = new FormData();
if (encryptionKey) {
formData.append('encryption_key', encryptionKey);
}
// Modal schließen
bootstrap.Modal.getInstance(document.getElementById('restoreModal')).hide();
// Loading anzeigen
const loadingDiv = document.createElement('div');
loadingDiv.className = 'position-fixed top-50 start-50 translate-middle';
loadingDiv.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
document.body.appendChild(loadingDiv);
fetch(`/backup/restore/${backupId}`, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message + '\n\nDie Seite wird neu geladen...');
window.location.href = '{{ url_for('admin.dashboard') }}';
} else {
alert('❌ ' + data.message);
}
})
.catch(error => {
alert('❌ Fehler bei der Wiederherstellung: ' + error);
})
.finally(() => {
document.body.removeChild(loadingDiv);
});
}
function deleteBackup(backupId, filename) {
if (!confirm(`Soll das Backup "${filename}" wirklich gelöscht werden?\n\nDieser Vorgang kann nicht rückgängig gemacht werden!`)) {
return;
}
fetch(`/backup/delete/${backupId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message);
location.reload();
} else {
alert('❌ ' + data.message);
}
})
.catch(error => {
alert('❌ Fehler beim Löschen des Backups: ' + error);
});
}
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,297 @@
{% extends "base.html" %}
{% block title %}Backup-Verwaltung{% endblock %}
{% block extra_css %}
<style>
.status-success { color: #28a745; }
.status-failed { color: #dc3545; }
.status-in_progress { color: #ffc107; }
.backup-actions { white-space: nowrap; }
.github-indicator {
display: inline-block;
margin-left: 10px;
}
.github-indicator.uploaded { color: #28a745; }
.github-indicator.not-uploaded { color: #6c757d; }
.backup-type-badge {
margin-left: 5px;
}
.recent-backups {
background-color: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
}
.backup-card {
transition: transform 0.2s;
}
.backup-card:hover {
transform: translateY(-2px);
}
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>💾 Backup-Verwaltung</h2>
<div>
<span class="text-muted">Automatische Backups: Täglich um 03:00 Uhr</span>
</div>
</div>
<!-- Letzte 3 GitHub-Backups prominent anzeigen -->
<div class="recent-backups">
<h4 class="mb-3">🌟 Letzte GitHub-Backups</h4>
<div class="row">
{% set github_uploaded_backups = backups | selectattr('github_uploaded', 'eq', True) | selectattr('status', 'eq', 'success') | list %}
{% for backup in github_uploaded_backups[:3] %}
<div class="col-md-4 mb-3">
<div class="card backup-card h-100">
<div class="card-body">
<h6 class="card-title">
{% if backup.is_server_backup %}
🖥️ Server-Backup
{% else %}
💾 Datenbank-Backup
{% endif %}
</h6>
<p class="mb-1"><small>{{ backup.created_at.strftime('%d.%m.%Y %H:%M') }}</small></p>
<p class="mb-1"><small>{{ (backup.filesize / 1024 / 1024)|round(2) }} MB</small></p>
<div class="mt-2">
<button class="btn btn-sm btn-primary" onclick="downloadFromGitHub({{ backup.id }})">
<i class="fas fa-download"></i> Download
</button>
{% if backup.is_server_backup %}
<button class="btn btn-sm btn-warning" onclick="restoreServer({{ backup.id }})">
<i class="fas fa-undo"></i> Restore
</button>
{% else %}
<button class="btn btn-sm btn-warning" onclick="showRestoreModal({{ backup.id }})">
<i class="fas fa-undo"></i> Restore
</button>
{% endif %}
</div>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<p class="text-muted text-center">Noch keine GitHub-Backups vorhanden</p>
</div>
{% endfor %}
</div>
</div>
<!-- Backup-Info -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">📊 Backup-Statistiken</h5>
{% set total_backups = backups | selectattr('github_uploaded', 'eq', True) | list | length %}
{% set db_backups = backups | selectattr('github_uploaded', 'eq', True) | selectattr('is_server_backup', 'eq', False) | list | length %}
{% set server_backups = backups | selectattr('github_uploaded', 'eq', True) | selectattr('is_server_backup', 'eq', True) | list | length %}
<p class="mb-1"><strong>Gesamt:</strong> {{ total_backups }} Backups</p>
<p class="mb-1"><strong>Datenbank:</strong> {{ db_backups }}</p>
<p class="mb-0"><strong>Server:</strong> {{ server_backups }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">💿 Speicherplatz</h5>
{% set local_size = backups | selectattr('local_deleted', 'eq', False) | selectattr('file_exists', 'eq', True) | sum(attribute='filesize') %}
<p class="mb-1"><strong>Lokal:</strong> {{ (local_size / 1024 / 1024)|round(2) }} MB</p>
<p class="mb-0"><small class="text-muted">GitHub: Unbegrenzt</small></p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">🔧 Backup erstellen</h5>
<div class="d-grid gap-2">
<button class="btn btn-primary" onclick="createBackup('database')">
💾 Datenbank-Backup
</button>
<button class="btn btn-success" onclick="createBackup('server')">
🖥️ Server-Backup
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Alle GitHub Backups -->
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fab fa-github"></i> Alle GitHub-Backups</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Typ</th>
<th>Erstellt</th>
<th>Größe</th>
<th>Status</th>
<th>Erstellt von</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for backup in github_uploaded_backups %}
<tr>
<td>
{% if backup.is_server_backup %}
<span class="badge bg-success">🖥️ Server</span>
{% else %}
<span class="badge bg-primary">💾 DB</span>
{% endif %}
</td>
<td>{{ backup.created_at.strftime('%d.%m.%Y %H:%M:%S') }}</td>
<td>{{ (backup.filesize / 1024 / 1024)|round(2) }} MB</td>
<td>
<span class="status-{{ backup.status }}">
{% if backup.status == 'success' %}✅{% else %}❌{% endif %}
{{ backup.status }}
</span>
</td>
<td>{{ backup.created_by or 'system' }}</td>
<td class="backup-actions">
<button class="btn btn-sm btn-primary" onclick="downloadFromGitHub({{ backup.id }})" title="Von GitHub herunterladen">
<i class="fas fa-download"></i>
</button>
{% if backup.is_server_backup %}
<button class="btn btn-sm btn-warning" onclick="restoreServer({{ backup.id }})" title="Server wiederherstellen">
<i class="fas fa-undo"></i>
</button>
{% else %}
<button class="btn btn-sm btn-warning" onclick="showRestoreModal({{ backup.id }})" title="Datenbank wiederherstellen">
<i class="fas fa-database"></i>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Restore Modal für Datenbank -->
<div class="modal fade" id="restoreModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Backup wiederherstellen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>⚠️ <strong>Warnung:</strong> Alle aktuellen Daten werden überschrieben!</p>
<div class="mb-3">
<label class="form-label">Verschlüsselungsschlüssel (falls benötigt):</label>
<input type="password" class="form-control" id="encryptionKey" placeholder="Leer lassen für Standard-Schlüssel">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-danger" onclick="confirmRestore()">Wiederherstellen</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let selectedBackupId = null;
function createBackup(type) {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Erstelle...';
fetch('/backups/backup/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: type,
push_to_github: true,
delete_local: true // Automatisch lokal löschen
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Backup wurde erfolgreich erstellt und zu GitHub hochgeladen!');
location.reload();
} else {
alert('Fehler: ' + data.error);
btn.disabled = false;
btn.innerHTML = type === 'database' ? '💾 Datenbank-Backup' : '🖥️ Server-Backup';
}
})
.catch(error => {
alert('Fehler beim Erstellen des Backups: ' + error);
btn.disabled = false;
btn.innerHTML = type === 'database' ? '💾 Datenbank-Backup' : '🖥️ Server-Backup';
});
}
function downloadFromGitHub(backupId) {
window.location.href = `/backups/backup/download/${backupId}?from_github=true`;
}
function showRestoreModal(backupId) {
selectedBackupId = backupId;
const modal = new bootstrap.Modal(document.getElementById('restoreModal'));
modal.show();
}
function confirmRestore() {
const encryptionKey = document.getElementById('encryptionKey').value;
fetch(`/backups/backup/restore/${selectedBackupId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
encryption_key: encryptionKey
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Backup wurde erfolgreich wiederhergestellt!');
location.reload();
} else {
alert('Fehler beim Wiederherstellen: ' + data.error);
}
});
bootstrap.Modal.getInstance(document.getElementById('restoreModal')).hide();
}
function restoreServer(backupId) {
if (!confirm('⚠️ WARNUNG: Dies wird den kompletten Server-Zustand wiederherstellen!\n\nAlle aktuellen Konfigurationen, Datenbanken und Docker-Volumes werden überschrieben.\n\nSind Sie sicher?')) {
return;
}
alert('Server-Restore muss derzeit manuell über SSH durchgeführt werden:\n\n' +
'1. SSH zum Server verbinden\n' +
'2. Backup von GitHub herunterladen\n' +
'3. ./restore_full_backup.sh <backup_name> ausführen');
}
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,301 @@
{% extends "base.html" %}
{% block title %}Backup-Verwaltung{% endblock %}
{% block extra_css %}
<style>
.status-success { color: #28a745; }
.status-failed { color: #dc3545; }
.status-in_progress { color: #ffc107; }
.backup-actions { white-space: nowrap; }
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>💾 Backup-Verwaltung</h2>
<div>
</div>
</div>
<!-- Backup-Info -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">📅 Letztes erfolgreiches Backup</h5>
{% if last_backup %}
<p class="mb-1"><strong>Zeitpunkt:</strong> {{ last_backup.id.strftime('%d.%m.%Y %H:%M:%S') }}</p>
<p class="mb-1"><strong>Größe:</strong> {{ (last_backup.filename / 1024 / 1024)|round(2) }} MB</p>
<p class="mb-0"><strong>Dauer:</strong> {{ last_backup.filesize|round(1) }} Sekunden</p>
{% else %}
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">🔧 Backup-Aktionen</h5>
<button id="createBackupBtn" class="btn btn-primary" onclick="createBackup()">
💾 Backup jetzt erstellen
</button>
<p class="text-muted mt-2 mb-0">
<small>Automatische Backups: Täglich um 03:00 Uhr</small>
</p>
</div>
</div>
</div>
</div>
<!-- Backup-Historie -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">📋 Backup-Historie</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover sortable-table">
<thead>
<tr>
<th class="sortable" data-type="date">Zeitstempel</th>
<th class="sortable">Dateiname</th>
<th class="sortable" data-type="numeric">Größe</th>
<th class="sortable">Typ</th>
<th class="sortable">Status</th>
<th class="sortable">Erstellt von</th>
<th>Details</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for backup in backups %}
<tr>
<td>{{ backup.created_at.strftime('%d.%m.%Y %H:%M:%S') }}</td>
<td>
<small>{{ backup.filename }}</small>
{% if backup.is_encrypted %}
<span class="badge bg-info ms-1">🔒 Verschlüsselt</span>
{% endif %}
</td>
<td>
{% if backup.filesize %}
{{ (backup.filesize / 1024 / 1024)|round(2) }} MB
{% else %}
-
{% endif %}
</td>
<td>
{% if backup.backup_type == 'manual' %}
<span class="badge bg-primary">Manuell</span>
{% else %}
<span class="badge bg-secondary">Automatisch</span>
{% endif %}
</td>
<td>
{% if backup.status == 'success' %}
<span class="status-success">✅ Erfolgreich</span>
{% elif backup.status == 'failed' %}
<span class="status-failed" title="{{ backup.error_message }}">❌ Fehlgeschlagen</span>
{% else %}
<span class="status-in_progress">⏳ In Bearbeitung</span>
{% endif %}
</td>
<td>{{ backup.created_by }}</td>
<td>
{% if backup.tables_count and backup.records_count %}
<small>
{{ backup.tables_count }} Tabellen<br>
{{ backup.records_count }} Datensätze<br>
{% if backup.duration_seconds %}
{{ backup.duration_seconds|round(1) }}s
{% endif %}
</small>
{% else %}
-
{% endif %}
</td>
<td class="backup-actions">
{% if backup.status == 'success' %}
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('admin.download_backup', backup_id=backup.id) }}"
class="btn btn-outline-primary"
title="Backup herunterladen">
📥 Download
</a>
<button class="btn btn-outline-success"
onclick="restoreBackup({{ backup.id }}, '{{ backup.filename }}')"
title="Backup wiederherstellen">
🔄 Wiederherstellen
</button>
<button class="btn btn-outline-danger"
onclick="deleteBackup({{ backup.id }}, '{{ backup.filename }}')"
title="Backup löschen">
🗑️ Löschen
</button>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not backups %}
<div class="text-center py-5">
<p class="text-muted">Noch keine Backups vorhanden.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Wiederherstellungs-Modal -->
<div class="modal fade" id="restoreModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">🔄 Backup wiederherstellen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>⚠️ Warnung:</strong> Bei der Wiederherstellung werden alle aktuellen Daten überschrieben!
</div>
<p>Backup: <strong id="restoreFilename"></strong></p>
<form id="restoreForm">
<input type="hidden" id="restoreBackupId">
<div class="mb-3">
<label for="encryptionKey" class="form-label">Verschlüsselungs-Passwort (optional)</label>
<input type="password" class="form-control" id="encryptionKey"
placeholder="Leer lassen für Standard-Passwort">
<small class="text-muted">
Falls das Backup mit einem anderen Passwort verschlüsselt wurde
</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-danger" onclick="confirmRestore()">
⚠️ Wiederherstellen
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function createBackup() {
const btn = document.getElementById('createBackupBtn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '⏳ Backup wird erstellt...';
fetch('{{ url_for('admin.create_backup_route') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message);
location.reload();
} else {
alert('❌ ' + data.message);
}
})
.catch(error => {
alert('❌ Fehler beim Erstellen des Backups: ' + error);
})
.finally(() => {
btn.disabled = false;
btn.innerHTML = originalText;
});
}
function restoreBackup(backupId, filename) {
document.getElementById('restoreBackupId').value = backupId;
document.getElementById('restoreFilename').textContent = filename;
document.getElementById('encryptionKey').value = '';
const modal = new bootstrap.Modal(document.getElementById('restoreModal'));
modal.show();
}
function confirmRestore() {
if (!confirm('Wirklich wiederherstellen? Alle aktuellen Daten werden überschrieben!')) {
return;
}
const backupId = document.getElementById('restoreBackupId').value;
const encryptionKey = document.getElementById('encryptionKey').value;
const formData = new FormData();
if (encryptionKey) {
formData.append('encryption_key', encryptionKey);
}
// Modal schließen
bootstrap.Modal.getInstance(document.getElementById('restoreModal')).hide();
// Loading anzeigen
const loadingDiv = document.createElement('div');
loadingDiv.className = 'position-fixed top-50 start-50 translate-middle';
loadingDiv.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
document.body.appendChild(loadingDiv);
fetch(`/backup/restore/${backupId}`, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message + '\n\nDie Seite wird neu geladen...');
window.location.href = '{{ url_for('admin.dashboard') }}';
} else {
alert('❌ ' + data.message);
}
})
.catch(error => {
alert('❌ Fehler bei der Wiederherstellung: ' + error);
})
.finally(() => {
document.body.removeChild(loadingDiv);
});
}
function deleteBackup(backupId, filename) {
if (!confirm(`Soll das Backup "${filename}" wirklich gelöscht werden?\n\nDieser Vorgang kann nicht rückgängig gemacht werden!`)) {
return;
}
fetch(`/backup/delete/${backupId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message);
location.reload();
} else {
alert('❌ ' + data.message);
}
})
.catch(error => {
alert('❌ Fehler beim Löschen des Backups: ' + error);
});
}
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,705 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Panel{% endblock %} - Lizenzverwaltung</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
{% block extra_css %}{% endblock %}
<style>
/* Global Status Colors */
:root {
--status-active: #28a745;
--status-warning: #ffc107;
--status-danger: #dc3545;
--status-inactive: #6c757d;
--status-info: #17a2b8;
--sidebar-width: 250px;
--sidebar-collapsed: 60px;
}
/* Status Classes - Global */
.status-aktiv { color: var(--status-active) !important; }
.status-ablaufend { color: var(--status-warning) !important; }
.status-abgelaufen { color: var(--status-danger) !important; }
.status-deaktiviert { color: var(--status-inactive) !important; }
/* Badge Variants */
.badge-aktiv { background-color: var(--status-active) !important; }
.badge-ablaufend { background-color: var(--status-warning) !important; color: #000 !important; }
.badge-abgelaufen { background-color: var(--status-danger) !important; }
.badge-deaktiviert { background-color: var(--status-inactive) !important; }
/* Session Timer Styles */
#session-timer {
font-family: monospace;
font-weight: bold;
font-size: 1.1rem;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
transition: all 0.3s ease;
}
.timer-normal {
background-color: var(--status-active);
color: white;
}
.timer-warning {
background-color: var(--status-warning);
color: #000;
}
.timer-danger {
background-color: var(--status-danger);
color: white;
animation: pulse 1s infinite;
}
.timer-critical {
background-color: var(--status-danger);
color: white;
animation: blink 0.5s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
.session-warning-modal {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
max-width: 400px;
}
/* Table Improvements */
.table-container {
max-height: 600px;
overflow-y: auto;
position: relative;
}
.table-sticky thead {
position: sticky;
top: 0;
background-color: #fff;
z-index: 10;
box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1);
}
.table-sticky thead th {
background-color: #f8f9fa;
border-bottom: 2px solid #dee2e6;
}
/* Inline Actions */
.btn-copy {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-copy:hover {
background-color: #e9ecef;
}
.btn-copy.copied {
background-color: var(--status-active);
color: white;
}
/* Toggle Switch */
.form-switch-custom {
display: inline-block;
}
.form-switch-custom .form-check-input {
cursor: pointer;
width: 3em;
height: 1.5em;
}
.form-switch-custom .form-check-input:checked {
background-color: var(--status-active);
border-color: var(--status-active);
}
/* Bulk Actions Bar */
.bulk-actions {
position: sticky;
bottom: 0;
background-color: #212529;
color: white;
padding: 1rem;
display: none;
z-index: 100;
box-shadow: 0 -4px 6px rgba(0, 0, 0, 0.1);
}
.bulk-actions.show {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Checkbox Styling */
.checkbox-cell {
width: 40px;
}
.form-check-input-custom {
cursor: pointer;
width: 1.2em;
height: 1.2em;
}
/* Sortable Table Styles */
.sortable-table th.sortable {
cursor: pointer;
user-select: none;
position: relative;
padding-right: 25px;
}
.sortable-table th.sortable:hover {
background-color: #e9ecef;
}
.sortable-table th.sortable::after {
content: '↕';
position: absolute;
right: 8px;
opacity: 0.3;
}
.sortable-table th.sortable.asc::after {
content: '↑';
opacity: 1;
color: var(--status-active);
}
.sortable-table th.sortable.desc::after {
content: '↓';
opacity: 1;
color: var(--status-active);
}
/* Server-side sortable styles */
.server-sortable {
cursor: pointer;
text-decoration: none;
color: inherit;
display: flex;
align-items: center;
justify-content: space-between;
}
.server-sortable:hover {
color: var(--bs-primary);
text-decoration: none;
}
.sort-indicator {
margin-left: 5px;
font-size: 0.8em;
}
.sort-indicator.active {
color: var(--bs-primary);
}
/* Sidebar Navigation */
.sidebar {
position: fixed;
top: 56px;
left: 0;
height: calc(100vh - 56px);
width: var(--sidebar-width);
background-color: #f8f9fa;
border-right: 1px solid #dee2e6;
overflow-y: auto;
transition: all 0.3s;
z-index: 100;
}
.sidebar.collapsed {
width: var(--sidebar-collapsed);
}
.sidebar-header {
padding: 1rem;
background-color: #e9ecef;
border-bottom: 1px solid #dee2e6;
font-weight: 600;
}
.sidebar-nav {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-nav .nav-item {
border-bottom: 1px solid #e9ecef;
}
.sidebar-nav .nav-link {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
color: #495057;
text-decoration: none;
transition: all 0.2s;
}
.sidebar-nav .nav-link:hover {
background-color: #e9ecef;
color: #212529;
}
.sidebar-nav .nav-link.active {
background-color: var(--bs-primary);
color: white;
}
.sidebar-nav .nav-link i {
margin-right: 0.5rem;
font-size: 1.2rem;
width: 24px;
text-align: center;
}
.sidebar.collapsed .sidebar-nav .nav-link span {
display: none;
}
.sidebar-submenu {
list-style: none;
padding: 0;
margin: 0;
background-color: #f1f3f4;
max-height: none;
overflow: visible;
}
.sidebar-submenu .nav-link {
padding-left: 2.5rem;
font-size: 0.9rem;
}
/* Arrow indicator for items with submenus */
.nav-link.has-submenu::after {
content: '▾';
float: right;
opacity: 0.5;
transform: rotate(180deg);
}
/* Main Content with Sidebar */
.main-content {
margin-left: var(--sidebar-width);
transition: all 0.3s;
min-height: calc(100vh - 56px);
}
.main-content.expanded {
margin-left: var(--sidebar-collapsed);
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.show {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
}
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-dark bg-dark navbar-expand-lg sticky-top">
<div class="container-fluid">
<a href="{{ url_for('admin.dashboard') }}" class="navbar-brand text-decoration-none">🎛️ AccountForger - Admin Panel</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<!-- Navigation removed - access via sidebar -->
</ul>
<div class="d-flex align-items-center">
<div id="session-timer" class="timer-normal me-3">
⏱️ <span id="timer-display">5:00</span>
</div>
<span class="text-white me-3">Angemeldet als: {{ username }}</span>
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-light btn-sm me-2">👤 Profil</a>
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-light btn-sm">Abmelden</a>
</div>
</div>
</div>
</nav>
<!-- Sidebar Navigation -->
<aside class="sidebar" id="sidebar">
<ul class="sidebar-nav">
<li class="nav-item {% if request.endpoint in ['customers.customers', 'customers.customers_licenses', 'customers.edit_customer', 'customers.create_customer', 'licenses.edit_license', 'licenses.create_license', 'batch.batch_create'] %}has-active-child{% endif %}">
<a class="nav-link has-submenu {% if request.endpoint == 'customers.customers_licenses' %}active{% endif %}" href="{{ url_for('customers.customers_licenses') }}">
<i class="bi bi-people"></i>
<span>Kunden & Lizenzen</span>
</a>
<ul class="sidebar-submenu">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'customers.customers' %}active{% endif %}" href="{{ url_for('customers.customers') }}">
<i class="bi bi-list"></i>
<span>Alle Kunden</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'licenses.licenses' %}active{% endif %}" href="{{ url_for('licenses.licenses') }}">
<i class="bi bi-card-list"></i>
<span>Alle Lizenzen</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'customers.create_customer' %}active{% endif %}" href="{{ url_for('customers.create_customer') }}">
<i class="bi bi-person-plus"></i>
<span>Neuer Kunde</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'licenses.create_license' %}active{% endif %}" href="{{ url_for('licenses.create_license') }}">
<i class="bi bi-plus-circle"></i>
<span>Neue Lizenz</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'batch.batch_create' %}active{% endif %}" href="{{ url_for('batch.batch_create') }}">
<i class="bi bi-stack"></i>
<span>Batch-Erstellung</span>
</a>
</li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('leads.') %}active{% endif %}" href="{{ url_for('leads.lead_management') }}">
<i class="bi bi-people"></i>
<span>Lead Management</span>
</a>
</li>
<li class="nav-item {% if request.endpoint in ['resources.resources', 'resources.add_resources'] %}has-active-child{% endif %}">
<a class="nav-link has-submenu {% if request.endpoint == 'resources.resources' %}active{% endif %}" href="{{ url_for('resources.resources') }}">
<i class="bi bi-box-seam"></i>
<span>Ressourcen Pool</span>
</a>
<ul class="sidebar-submenu">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'resources.add_resources' %}active{% endif %}" href="{{ url_for('resources.add_resources') }}">
<i class="bi bi-plus-square"></i>
<span>Ressourcen hinzufügen</span>
</a>
</li>
</ul>
</li>
<li class="nav-item {% if request.endpoint in ['monitoring.unified_monitoring', 'monitoring.live_dashboard', 'monitoring.alerts'] %}has-active-child{% endif %}">
<a class="nav-link {% if request.endpoint == 'monitoring.unified_monitoring' %}active{% endif %}" href="{{ url_for('monitoring.unified_monitoring') }}">
<i class="bi bi-activity"></i>
<span>Monitoring</span>
</a>
</li>
<li class="nav-item {% if request.endpoint in ['admin.audit_log', 'admin.backups', 'admin.blocked_ips', 'admin.license_config'] %}has-active-child{% endif %}">
<a class="nav-link has-submenu" href="{{ url_for('admin.license_config') }}">
<i class="bi bi-tools"></i>
<span>Administration</span>
</a>
<ul class="sidebar-submenu">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.audit_log' %}active{% endif %}" href="{{ url_for('admin.audit_log') }}">
<i class="bi bi-journal-text"></i>
<span>Audit-Log</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.backups' %}active{% endif %}" href="{{ url_for('admin.backups') }}">
<i class="bi bi-cloud-download"></i>
<span>Backups</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.blocked_ips' %}active{% endif %}" href="{{ url_for('admin.blocked_ips') }}">
<i class="bi bi-slash-circle"></i>
<span>Gesperrte IPs</span>
</a>
</li>
</ul>
</li>
</ul>
</aside>
<!-- Main Content Area -->
<div class="main-content" id="main-content">
<!-- Page Content -->
<div class="container-fluid p-4">
{% block content %}{% endblock %}
</div>
</div>
<!-- Session Warning Modal -->
<div id="session-warning" class="session-warning-modal" style="display: none;">
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong>⚠️ Session läuft ab!</strong><br>
Ihre Session läuft in weniger als 1 Minute ab.<br>
<button type="button" class="btn btn-sm btn-success mt-2" onclick="extendSession()">
Session verlängern
</button>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
// Session-Timer Konfiguration
const SESSION_TIMEOUT = 5 * 60; // 5 Minuten in Sekunden
let timeRemaining = SESSION_TIMEOUT;
let timerInterval;
let warningShown = false;
let lastActivity = Date.now();
// Timer Display Update
function updateTimerDisplay() {
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining % 60;
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
document.getElementById('timer-display').textContent = display;
// Timer-Farbe ändern
const timerElement = document.getElementById('session-timer');
timerElement.className = timerElement.className.replace(/timer-\w+/, '');
if (timeRemaining <= 30) {
timerElement.classList.add('timer-critical');
} else if (timeRemaining <= 60) {
timerElement.classList.add('timer-danger');
if (!warningShown) {
showSessionWarning();
warningShown = true;
}
} else if (timeRemaining <= 120) {
timerElement.classList.add('timer-warning');
} else {
timerElement.classList.add('timer-normal');
warningShown = false;
hideSessionWarning();
}
}
// Session Warning anzeigen
function showSessionWarning() {
document.getElementById('session-warning').style.display = 'block';
}
// Session Warning verstecken
function hideSessionWarning() {
document.getElementById('session-warning').style.display = 'none';
}
// Timer zurücksetzen
function resetTimer() {
timeRemaining = SESSION_TIMEOUT;
lastActivity = Date.now();
updateTimerDisplay();
}
// Session verlängern
function extendSession() {
fetch('{{ url_for('auth.heartbeat') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'ok') {
resetTimer();
}
})
.catch(error => console.error('Heartbeat error:', error));
}
// Timer Countdown
function countdown() {
timeRemaining--;
updateTimerDisplay();
if (timeRemaining <= 0) {
clearInterval(timerInterval);
window.location.href = '{{ url_for('auth.logout') }}';
}
}
// Aktivitäts-Tracking
function trackActivity() {
const now = Date.now();
// Nur wenn mehr als 5 Sekunden seit letzter Aktivität
if (now - lastActivity > 5000) {
lastActivity = now;
extendSession(); // resetTimer() wird in extendSession nach erfolgreicher Response aufgerufen
}
}
// Event Listeners für Benutzeraktivität
document.addEventListener('click', trackActivity);
document.addEventListener('keypress', trackActivity);
document.addEventListener('mousemove', () => {
const now = Date.now();
// Mausbewegung nur alle 30 Sekunden tracken
if (now - lastActivity > 30000) {
trackActivity();
}
});
// AJAX Interceptor für automatische Session-Verlängerung
const originalFetch = window.fetch;
window.fetch = function(...args) {
// Nur für non-heartbeat requests den Timer verlängern
if (!args[0].includes('/heartbeat')) {
trackActivity();
}
return originalFetch.apply(this, args);
};
// Timer starten
timerInterval = setInterval(countdown, 1000);
updateTimerDisplay();
// Initial Heartbeat
extendSession();
// Preserve show_test parameter across navigation
function preserveShowTestParameter() {
const urlParams = new URLSearchParams(window.location.search);
const showTest = urlParams.get('show_test');
if (showTest === 'true') {
// Update all internal links to include show_test parameter
document.querySelectorAll('a[href^="/"]').forEach(link => {
const href = link.getAttribute('href');
// Skip if already has parameters or is just a fragment
if (!href.includes('?') && !href.startsWith('#')) {
link.setAttribute('href', href + '?show_test=true');
} else if (href.includes('?') && !href.includes('show_test=')) {
link.setAttribute('href', href + '&show_test=true');
}
});
}
}
// Client-side table sorting
document.addEventListener('DOMContentLoaded', function() {
// Preserve show_test parameter on page load
preserveShowTestParameter();
// Initialize all sortable tables
const sortableTables = document.querySelectorAll('.sortable-table');
sortableTables.forEach(table => {
const headers = table.querySelectorAll('th.sortable');
headers.forEach((header, index) => {
header.addEventListener('click', function() {
sortTable(table, index, header);
});
});
});
});
function sortTable(table, columnIndex, header) {
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
const isNumeric = header.dataset.type === 'numeric';
const isDate = header.dataset.type === 'date';
// Determine sort direction
let direction = 'asc';
if (header.classList.contains('asc')) {
direction = 'desc';
}
// Remove all sort classes from headers
table.querySelectorAll('th.sortable').forEach(th => {
th.classList.remove('asc', 'desc');
});
// Add appropriate class to clicked header
header.classList.add(direction);
// Sort rows
rows.sort((a, b) => {
let aValue = a.cells[columnIndex].textContent.trim();
let bValue = b.cells[columnIndex].textContent.trim();
// Handle different data types
if (isNumeric) {
aValue = parseFloat(aValue.replace(/[^0-9.-]/g, '')) || 0;
bValue = parseFloat(bValue.replace(/[^0-9.-]/g, '')) || 0;
} else if (isDate) {
// Parse German date format (DD.MM.YYYY HH:MM)
aValue = parseGermanDate(aValue);
bValue = parseGermanDate(bValue);
} else {
// Text comparison with locale support for umlauts
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (aValue < bValue) return direction === 'asc' ? -1 : 1;
if (aValue > bValue) return direction === 'asc' ? 1 : -1;
return 0;
});
// Reorder rows in DOM
rows.forEach(row => tbody.appendChild(row));
}
function parseGermanDate(dateStr) {
// Handle DD.MM.YYYY HH:MM format
const parts = dateStr.match(/(\d{2})\.(\d{2})\.(\d{4})\s*(\d{2}:\d{2})?/);
if (parts) {
const [_, day, month, year, time] = parts;
const timeStr = time || '00:00';
return new Date(`${year}-${month}-${day}T${timeStr}`);
}
return new Date(0);
}
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

Datei anzeigen

@@ -0,0 +1,514 @@
{% extends "base.html" %}
{% block title %}Batch-Lizenzen erstellen{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>🔑 Batch-Lizenzen erstellen</h2>
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">← Zurück zur Übersicht</a>
</div>
<div class="alert alert-info">
<strong> Batch-Generierung:</strong> Erstellen Sie mehrere Lizenzen auf einmal für einen Kunden.
Die Lizenzen werden automatisch generiert und können anschließend als CSV exportiert werden.
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Customer Type Indicator -->
<div id="customerTypeIndicator" class="alert d-none mb-3" role="alert">
<i class="fas fa-info-circle"></i> <span id="customerTypeMessage"></span>
</div>
<form method="post" action="{{ url_for('batch.batch_create') }}" accept-charset="UTF-8">
<div class="row g-3">
<div class="col-md-12">
<label for="customerSelect" class="form-label">Kunde auswählen</label>
<select class="form-select" id="customerSelect" name="customer_id" required>
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
<option value="new"> Neuer Kunde</option>
</select>
</div>
<div class="col-md-6" id="customerNameDiv" style="display: none;">
<label for="customerName" class="form-label">Kundenname</label>
<input type="text" class="form-control" id="customerName" name="customer_name"
placeholder="Firma GmbH" accept-charset="UTF-8">
</div>
<div class="col-md-6" id="emailDiv" style="display: none;">
<label for="email" class="form-label">E-Mail</label>
<input type="email" class="form-control" id="email" name="email"
placeholder="kontakt@firma.de" accept-charset="UTF-8">
</div>
<div class="col-md-3">
<label for="quantity" class="form-label">Anzahl Lizenzen</label>
<input type="number" class="form-control" id="quantity" name="quantity"
min="1" max="100" value="10" required>
<div class="form-text">Max. 100 Lizenzen pro Batch</div>
</div>
<div class="col-md-3">
<label for="licenseType" class="form-label">Lizenztyp</label>
<select class="form-select" id="licenseType" name="license_type" required>
<option value="full">Vollversion</option>
<option value="test">Testversion</option>
</select>
</div>
<div class="col-md-2">
<label for="validFrom" class="form-label">Gültig ab</label>
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
</div>
<div class="col-md-1">
<label for="duration" class="form-label">Laufzeit</label>
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
</div>
<div class="col-md-2">
<label for="durationType" class="form-label">Einheit</label>
<select class="form-select" id="durationType" name="duration_type" required>
<option value="days">Tage</option>
<option value="months">Monate</option>
<option value="years" selected>Jahre</option>
</select>
</div>
<div class="col-md-2">
<label for="validUntil" class="form-label">Gültig bis</label>
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
</div>
</div>
<!-- Resource Pool Allocation -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-server"></i> Ressourcen-Zuweisung pro Lizenz
<small class="text-muted float-end" id="resourceStatus"></small>
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label for="domainCount" class="form-label">
<i class="fas fa-globe"></i> Domains
</label>
<select class="form-select" id="domainCount" name="domain_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
| Benötigt: <span id="domainsNeeded" class="fw-bold">-</span>
</small>
</div>
<div class="col-md-4">
<label for="ipv4Count" class="form-label">
<i class="fas fa-network-wired"></i> IPv4-Adressen
</label>
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
| Benötigt: <span id="ipv4Needed" class="fw-bold">-</span>
</small>
</div>
<div class="col-md-4">
<label for="phoneCount" class="form-label">
<i class="fas fa-phone"></i> Telefonnummern
</label>
<select class="form-select" id="phoneCount" name="phone_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
| Benötigt: <span id="phoneNeeded" class="fw-bold">-</span>
</small>
</div>
</div>
<div class="alert alert-warning mt-3 mb-0" role="alert">
<i class="fas fa-exclamation-triangle"></i>
<strong>Batch-Ressourcen:</strong> Jede Lizenz erhält die angegebene Anzahl an Ressourcen.
Bei 10 Lizenzen mit je 2 Domains werden insgesamt 20 Domains benötigt.
</div>
</div>
</div>
<!-- Device Limit -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-laptop"></i> Gerätelimit pro Lizenz
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label for="deviceLimit" class="form-label">
Maximale Anzahl Geräte pro Lizenz
</label>
<select class="form-select" id="deviceLimit" name="device_limit" required>
{% for i in range(1, 11) %}
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Jede generierte Lizenz kann auf maximal dieser Anzahl von Geräten gleichzeitig aktiviert werden.
</small>
</div>
</div>
</div>
</div>
<div class="mt-4 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-lg">
🔑 Batch generieren
</button>
<button type="button" class="btn btn-outline-secondary" onclick="previewKeys()">
👁️ Vorschau
</button>
</div>
</form>
<!-- Vorschau Modal -->
<div class="modal fade" id="previewModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Vorschau der generierten Keys</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Es werden <strong id="previewQuantity">10</strong> Lizenzen im folgenden Format generiert:</p>
<div class="bg-light p-3 rounded font-monospace" id="previewFormat">
AF-F-YYYYMM-XXXX-YYYY-ZZZZ
</div>
<p class="mt-3 mb-0">Die Keys werden automatisch eindeutig generiert und in der Datenbank gespeichert.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
</div>
</div>
</div>
</div>
</div>
<script>
// Funktion zur Berechnung des Ablaufdatums
function calculateValidUntil() {
const validFrom = document.getElementById('validFrom').value;
const duration = parseInt(document.getElementById('duration').value) || 1;
const durationType = document.getElementById('durationType').value;
if (!validFrom) return;
const startDate = new Date(validFrom);
let endDate = new Date(startDate);
switch(durationType) {
case 'days':
endDate.setDate(endDate.getDate() + duration);
break;
case 'months':
endDate.setMonth(endDate.getMonth() + duration);
break;
case 'years':
endDate.setFullYear(endDate.getFullYear() + duration);
break;
}
// Kein Tag abziehen - die Lizenz ist bis einschließlich des Enddatums gültig
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
}
// Event Listener für Änderungen
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
document.getElementById('duration').addEventListener('input', calculateValidUntil);
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
// Setze heutiges Datum als Standard
document.addEventListener('DOMContentLoaded', function() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('validFrom').value = today;
// Initialize customer is_fake map
window.customerIsFakeMap = {};
// Berechne initiales Ablaufdatum
calculateValidUntil();
// Initialisiere Select2 für Kundenauswahl
$('#customerSelect').select2({
theme: 'bootstrap-5',
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
allowClear: true,
ajax: {
url: '/api/customers',
dataType: 'json',
delay: 250,
data: function (params) {
return {
q: params.term,
page: params.page || 1
};
},
processResults: function (data, params) {
params.page = params.page || 1;
// Store is_fake status for each customer
const results = data.results || [];
results.forEach(customer => {
if (customer.id !== 'new') {
window.customerIsFakeMap[customer.id] = customer.is_fake || false;
}
});
// "Neuer Kunde" Option immer oben anzeigen
if (params.page === 1) {
results.unshift({
id: 'new',
text: ' Neuer Kunde',
isNew: true
});
}
return {
results: results,
pagination: data.pagination
};
},
cache: true
},
minimumInputLength: 0,
language: {
inputTooShort: function() { return ''; },
noResults: function() { return 'Keine Kunden gefunden'; },
searching: function() { return 'Suche...'; },
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
}
});
// Event Handler für Kundenauswahl
$('#customerSelect').on('select2:select', function (e) {
const selectedValue = e.params.data.id;
const nameDiv = document.getElementById('customerNameDiv');
const emailDiv = document.getElementById('emailDiv');
const nameInput = document.getElementById('customerName');
const emailInput = document.getElementById('email');
if (selectedValue === 'new') {
// Zeige Eingabefelder für neuen Kunden
nameDiv.style.display = 'block';
emailDiv.style.display = 'block';
nameInput.required = true;
emailInput.required = true;
// Zeige Indikator für neuen Kunden
showCustomerTypeIndicator('new');
} else {
// Verstecke Eingabefelder bei bestehendem Kunden
nameDiv.style.display = 'none';
emailDiv.style.display = 'none';
nameInput.required = false;
emailInput.required = false;
nameInput.value = '';
emailInput.value = '';
// Zeige Indikator basierend auf Kundendaten
if (e.params.data.is_fake !== undefined) {
showCustomerTypeIndicator(e.params.data.is_fake ? 'fake' : 'real');
}
}
// Update resource availability check when customer changes
checkResourceAvailability();
});
// Clear handler
$('#customerSelect').on('select2:clear', function (e) {
document.getElementById('customerNameDiv').style.display = 'none';
document.getElementById('emailDiv').style.display = 'none';
document.getElementById('customerName').required = false;
document.getElementById('email').required = false;
hideCustomerTypeIndicator();
window.customerIsFakeMap = {};
checkResourceAvailability();
});
// Resource Availability Check
checkResourceAvailability();
// Event Listener für Resource Count und Quantity Änderungen
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
document.getElementById('quantity').addEventListener('input', checkResourceAvailability);
});
// Vorschau-Funktion
function previewKeys() {
const quantity = document.getElementById('quantity').value;
const type = document.getElementById('licenseType').value;
const typeChar = type === 'full' ? 'F' : 'T';
const date = new Date();
const dateStr = date.getFullYear() + ('0' + (date.getMonth() + 1)).slice(-2);
document.getElementById('previewQuantity').textContent = quantity;
document.getElementById('previewFormat').textContent = `AF-${typeChar}-${dateStr}-XXXX-YYYY-ZZZZ`;
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
modal.show();
}
// Validierung
document.getElementById('quantity').addEventListener('input', function(e) {
if (e.target.value > 100) {
e.target.value = 100;
}
if (e.target.value < 1) {
e.target.value = 1;
}
});
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit für Batch
function checkResourceAvailability() {
const quantity = parseInt(document.getElementById('quantity').value) || 1;
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
// Berechne Gesamtbedarf
const totalDomains = domainCount * quantity;
const totalIpv4 = ipv4Count * quantity;
const totalPhones = phoneCount * quantity;
// Update "Benötigt" Anzeigen
document.getElementById('domainsNeeded').textContent = totalDomains;
document.getElementById('ipv4Needed').textContent = totalIpv4;
document.getElementById('phoneNeeded').textContent = totalPhones;
// Get customer's is_fake status
const selectedCustomer = document.getElementById('customer_id');
let isFake = 'false';
if (selectedCustomer && selectedCustomer.value && window.customerIsFakeMap) {
isFake = window.customerIsFakeMap[selectedCustomer.value] ? 'true' : 'false';
}
// API-Call zur Verfügbarkeitsprüfung
fetch(`/api/resources/check-availability?domain=${totalDomains}&ipv4=${totalIpv4}&phone=${totalPhones}&is_fake=${isFake}`)
.then(response => response.json())
.then(data => {
// Update der Verfügbarkeitsanzeigen
updateAvailabilityDisplay('domainsAvailable', data.domain_available, totalDomains);
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, totalIpv4);
updateAvailabilityDisplay('phoneAvailable', data.phone_available, totalPhones);
// Gesamtstatus aktualisieren
updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity);
})
.catch(error => {
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
});
}
// Hilfsfunktion zur Anzeige der Verfügbarkeit
function updateAvailabilityDisplay(elementId, available, requested) {
const element = document.getElementById(elementId);
element.textContent = available;
const neededElement = element.parentElement.querySelector('.fw-bold:last-child');
if (requested > 0 && available < requested) {
element.classList.remove('text-success');
element.classList.add('text-danger');
neededElement.classList.add('text-danger');
neededElement.classList.remove('text-success');
} else if (available < 50) {
element.classList.remove('text-success', 'text-danger');
element.classList.add('text-warning');
} else {
element.classList.remove('text-danger', 'text-warning');
element.classList.add('text-success');
neededElement.classList.remove('text-danger');
neededElement.classList.add('text-success');
}
}
// Gesamtstatus der Ressourcen-Verfügbarkeit für Batch
function updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity) {
const statusElement = document.getElementById('resourceStatus');
let hasIssue = false;
let message = '';
if (totalDomains > 0 && data.domain_available < totalDomains) {
hasIssue = true;
message = `⚠️ Nicht genügend Domains (${data.domain_available}/${totalDomains})`;
} else if (totalIpv4 > 0 && data.ipv4_available < totalIpv4) {
hasIssue = true;
message = `⚠️ Nicht genügend IPv4-Adressen (${data.ipv4_available}/${totalIpv4})`;
} else if (totalPhones > 0 && data.phone_available < totalPhones) {
hasIssue = true;
message = `⚠️ Nicht genügend Telefonnummern (${data.phone_available}/${totalPhones})`;
} else {
message = `✅ Ressourcen für ${quantity} Lizenzen verfügbar`;
}
statusElement.textContent = message;
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
// Disable submit button if not enough resources
const submitButton = document.querySelector('button[type="submit"]');
submitButton.disabled = hasIssue;
if (hasIssue) {
submitButton.classList.add('btn-secondary');
submitButton.classList.remove('btn-primary');
} else {
submitButton.classList.add('btn-primary');
submitButton.classList.remove('btn-secondary');
}
}
// Funktion zur Anzeige des Kundentyp-Indikators
function showCustomerTypeIndicator(type) {
const indicator = document.getElementById('customerTypeIndicator');
const message = document.getElementById('customerTypeMessage');
indicator.classList.remove('d-none', 'alert-info', 'alert-warning', 'alert-success');
if (type === 'new') {
indicator.classList.add('alert-info');
message.textContent = 'Neue Kunden werden in der Testphase als TEST-Kunden erstellt. Alle Batch-Lizenzen werden automatisch als TEST-Lizenzen markiert.';
} else if (type === 'fake') {
indicator.classList.add('alert-warning');
message.textContent = 'Dies ist ein TEST-Kunde. Alle Batch-Lizenzen werden automatisch als TEST-Lizenzen markiert und von der Software ignoriert.';
} else if (type === 'real') {
indicator.classList.add('alert-success');
message.textContent = 'Dies ist ein PRODUKTIV-Kunde. Alle Batch-Lizenzen werden als produktive Lizenzen erstellt.';
}
}
function hideCustomerTypeIndicator() {
document.getElementById('customerTypeIndicator').classList.add('d-none');
}
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,156 @@
{% extends "base.html" %}
{% block title %}Batch-Lizenzen generiert{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>✅ Batch-Lizenzen erfolgreich generiert</h2>
<div>
<a href="{{ url_for('batch.batch_create') }}" class="btn btn-primary">🔑 Weitere Batch erstellen</a>
</div>
</div>
<div class="alert alert-success">
<h5 class="alert-heading">🎉 {{ licenses|length }} Lizenzen wurden erfolgreich generiert!</h5>
<p class="mb-0">Die Lizenzen wurden in der Datenbank gespeichert und dem Kunden zugeordnet.</p>
</div>
<!-- Kunden-Info -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">📋 Kundeninformationen</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Kunde:</strong> {{ customer }}</p>
<p><strong>E-Mail:</strong> {{ email or 'Nicht angegeben' }}</p>
</div>
<div class="col-md-6">
<p><strong>Gültig von:</strong> {{ valid_from }}</p>
<p><strong>Gültig bis:</strong> {{ valid_until }}</p>
</div>
</div>
</div>
</div>
<!-- Export-Optionen -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">📥 Export-Optionen</h5>
</div>
<div class="card-body">
<p>Exportieren Sie die generierten Lizenzen für den Kunden:</p>
<div class="d-flex gap-2">
<a href="{{ url_for('batch.export_batch') }}" class="btn btn-success">
📄 Als CSV exportieren
</a>
<button class="btn btn-outline-primary" onclick="copyAllKeys()">
📋 Alle Keys kopieren
</button>
<button class="btn btn-outline-secondary" onclick="window.print()">
🖨️ Drucken
</button>
</div>
</div>
</div>
<!-- Generierte Lizenzen -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">🔑 Generierte Lizenzen</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th width="50">#</th>
<th>Lizenzschlüssel</th>
<th width="120">Typ</th>
<th width="100">Aktionen</th>
</tr>
</thead>
<tbody>
{% for license in licenses %}
<tr>
<td>{{ loop.index }}</td>
<td class="font-monospace">{{ license.key }}</td>
<td>
{% if license.type == 'full' %}
<span class="badge bg-success">Vollversion</span>
{% else %}
<span class="badge bg-info">Testversion</span>
{% endif %}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary"
onclick="copyKey('{{ license.key }}')"
title="Kopieren">
📋
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Hinweis -->
<div class="alert alert-info mt-4">
<strong>💡 Tipp:</strong> Die generierten Lizenzen sind sofort aktiv und können verwendet werden.
Sie finden alle Lizenzen auch in der <a href="{{ url_for('licenses.licenses', search=customer) }}">Lizenzübersicht</a>.
</div>
</div>
<!-- Textarea für Kopieren (unsichtbar) -->
<textarea id="copyArea" style="position: absolute; left: -9999px;"></textarea>
<style>
@media print {
.btn, .alert-info, .card-header h5 { display: none !important; }
.card { border: 1px solid #000 !important; }
}
</style>
<script>
// Einzelnen Key kopieren
function copyKey(key) {
navigator.clipboard.writeText(key).then(function() {
// Visuelles Feedback
event.target.textContent = '✓';
setTimeout(() => {
event.target.textContent = '📋';
}, 2000);
});
}
// Alle Keys kopieren
function copyAllKeys() {
const keys = [];
{% for license in licenses %}
keys.push('{{ license.key }}');
{% endfor %}
const text = keys.join('\n');
const textarea = document.getElementById('copyArea');
textarea.value = text;
textarea.select();
try {
document.execCommand('copy');
// Visuelles Feedback
event.target.textContent = '✓ Kopiert!';
setTimeout(() => {
event.target.textContent = '📋 Alle Keys kopieren';
}, 2000);
} catch (err) {
// Fallback für moderne Browser
navigator.clipboard.writeText(text);
}
}
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}Gesperrte IPs{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>🔒 Gesperrte IPs</h1>
<div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">IP-Sperrverwaltung</h5>
</div>
<div class="card-body">
{% if blocked_ips %}
<div class="table-responsive">
<table class="table table-hover sortable-table">
<thead>
<tr>
<th class="sortable">IP-Adresse</th>
<th class="sortable" data-type="numeric">Versuche</th>
<th class="sortable" data-type="date">Erster Versuch</th>
<th class="sortable" data-type="date">Letzter Versuch</th>
<th class="sortable" data-type="date">Gesperrt bis</th>
<th class="sortable">Letzter User</th>
<th class="sortable">Letzte Meldung</th>
<th class="sortable">Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for ip in blocked_ips %}
<tr class="{% if ip.is_active %}table-danger{% else %}table-secondary{% endif %}">
<td><code>{{ ip.ip_address }}</code></td>
<td><span class="badge bg-danger">{{ ip.attempt_count }}</span></td>
<td>{{ ip.first_attempt }}</td>
<td>{{ ip.last_attempt }}</td>
<td>{{ ip.blocked_until }}</td>
<td>{{ ip.last_username or '-' }}</td>
<td><strong>{{ ip.last_error or '-' }}</strong></td>
<td>
{% if ip.is_active %}
<span class="badge bg-danger">GESPERRT</span>
{% else %}
<span class="badge bg-secondary">ABGELAUFEN</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
{% if ip.is_active %}
<form method="post" action="{{ url_for('admin.unblock_ip') }}" class="d-inline">
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
<button type="submit" class="btn btn-success"
onclick="return confirm('IP {{ ip.ip_address }} wirklich entsperren?')">
🔓 Entsperren
</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('admin.clear_attempts') }}" class="d-inline ms-1">
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
<button type="submit" class="btn btn-warning"
onclick="return confirm('Alle Versuche für IP {{ ip.ip_address }} zurücksetzen?')">
🗑️ Reset
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">
<strong>Keine gesperrten IPs vorhanden.</strong>
Das System läuft ohne Sicherheitsvorfälle.
</div>
{% endif %}
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<h5 class="card-title"> Informationen</h5>
<ul class="mb-0">
<li>IPs werden nach <strong>{{ 5 }} fehlgeschlagenen Login-Versuchen</strong> für <strong>24 Stunden</strong> gesperrt.</li>
<li>Nach <strong>2 Versuchen</strong> wird ein CAPTCHA angezeigt.</li>
<li>Bei <strong>5 Versuchen</strong> wird eine E-Mail-Benachrichtigung gesendet (wenn aktiviert).</li>
<li>Gesperrte IPs können manuell entsperrt werden.</li>
<li>Die Fehlermeldungen werden zufällig ausgewählt für zusätzliche Verwirrung.</li>
</ul>
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Neuer Kunde{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>👤 Neuer Kunde anlegen</h2>
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">← Zurück zur Übersicht</a>
</div>
<div class="card">
<div class="card-body">
<form method="post" action="{{ url_for('customers.create_customer') }}" accept-charset="UTF-8">
<div class="row g-3">
<div class="col-md-6">
<label for="name" class="form-label">Kundenname <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name"
placeholder="Firmenname oder Vor- und Nachname"
accept-charset="UTF-8" required autofocus>
<div class="form-text">Der Name des Kunden oder der Firma</div>
</div>
<div class="col-md-6">
<label for="email" class="form-label">E-Mail <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="email" name="email"
placeholder="kunde@beispiel.de"
accept-charset="UTF-8" required>
<div class="form-text">Kontakt-E-Mail-Adresse des Kunden</div>
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="isFake" name="is_fake">
<label class="form-check-label" for="isFake">
<i class="fas fa-flask"></i> Als Fake-Daten markieren
<small class="text-muted">(Kunde wird von der Software ignoriert)</small>
</label>
</div>
<div class="alert alert-info mt-4" role="alert">
<i class="fas fa-info-circle"></i>
<strong>Hinweis:</strong> Nach dem Anlegen des Kunden können Sie direkt Lizenzen für diesen Kunden erstellen.
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">💾 Kunde anlegen</button>
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
{% for category, message in messages %}
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% endblock %}

Datei anzeigen

@@ -0,0 +1,185 @@
{% extends "base.html" %}
{% block title %}Kundenverwaltung{% endblock %}
{% macro sortable_header(label, field, current_sort, current_order) %}
<th>
{% if current_sort == field %}
<a href="{{ url_for('customers.customers', sort=field, order='desc' if current_order == 'asc' else 'asc', search=search, show_fake=show_fake, page=1) }}"
class="server-sortable">
{% else %}
<a href="{{ url_for('customers.customers', sort=field, order='asc', search=search, show_fake=show_fake, page=1) }}"
class="server-sortable">
{% endif %}
{{ label }}
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
{% if current_sort == field %}
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
{% else %}
{% endif %}
</span>
</a>
</th>
{% endmacro %}
{% block content %}
<div class="container py-5">
<div class="mb-4">
<h2>Kundenverwaltung</h2>
</div>
<!-- Suchformular -->
<div class="card mb-3">
<div class="card-body">
<form method="get" action="{{ url_for('customers.customers') }}" id="customerSearchForm" class="row g-3 align-items-end">
<div class="col-md-8">
<label for="search" class="form-label">🔍 Suchen</label>
<input type="text" class="form-control" id="search" name="search"
placeholder="Kundenname oder E-Mail..."
value="{{ search }}" autofocus>
</div>
<div class="col-md-2">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" id="show_fake" name="show_fake" value="true"
{% if show_fake %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label" for="show_fake">
🧪 Fake-Daten anzeigen
</label>
</div>
</div>
<div class="col-md-2">
<a href="{{ url_for('customers.customers') }}" class="btn btn-outline-secondary w-100">Zurücksetzen</a>
</div>
</form>
{% if search %}
<div class="mt-2">
<small class="text-muted">Suchergebnisse für: <strong>{{ search }}</strong></small>
<a href="{{ url_for('customers.customers') }}" class="btn btn-sm btn-outline-secondary ms-2">✖ Suche zurücksetzen</a>
</div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
{{ sortable_header('ID', 'id', sort, order) }}
{{ sortable_header('Name', 'name', sort, order) }}
{{ sortable_header('E-Mail', 'email', sort, order) }}
{{ sortable_header('Erstellt am', 'created_at', sort, order) }}
{{ sortable_header('Lizenzen (Aktiv/Gesamt)', 'licenses', sort, order) }}
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for customer in customers %}
<tr>
<td>{{ customer.id }}</td>
<td>
{{ customer.name }}
{% if customer.is_test %}
<span class="badge bg-secondary ms-1" title="Testdaten">🧪</span>
{% endif %}
</td>
<td>{{ customer.email or '-' }}</td>
<td>{{ customer.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td>
<span class="badge bg-info">{{ customer.active_licenses }}/{{ customer.license_count }}</span>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('customers.edit_customer', customer_id=customer.id) }}" class="btn btn-outline-primary">✏️ Bearbeiten</a>
{% if customer.license_count == 0 %}
<form method="post" action="{{ url_for('customers.delete_customer', customer_id=customer.id) }}" style="display: inline;" onsubmit="return confirm('Kunde wirklich löschen?');">
<button type="submit" class="btn btn-outline-danger">🗑️ Löschen</button>
</form>
{% else %}
<button class="btn btn-outline-danger" disabled title="Kunde hat Lizenzen">🗑️ Löschen</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not customers %}
<div class="text-center py-5">
{% if search %}
<p class="text-muted">Keine Kunden gefunden für: <strong>{{ search }}</strong></p>
<a href="{{ url_for('customers.customers') }}" class="btn btn-secondary">Alle Kunden anzeigen</a>
{% else %}
<p class="text-muted">Noch keine Kunden vorhanden.</p>
<a href="{{ url_for('licenses.create_license') }}" class="btn btn-primary">Erste Lizenz erstellen</a>
{% endif %}
</div>
{% endif %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav aria-label="Seitennavigation" class="mt-3">
<ul class="pagination justify-content-center">
<!-- Erste Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('customers.customers', page=1, search=search, sort=sort, order=order, show_fake=show_fake) }}">Erste</a>
</li>
<!-- Vorherige Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('customers.customers', page=page-1, search=search, sort=sort, order=order, show_fake=show_fake) }}"></a>
</li>
<!-- Seitenzahlen -->
{% for p in range(1, total_pages + 1) %}
{% if p >= page - 2 and p <= page + 2 %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('customers.customers', page=p, search=search, sort=sort, order=order, show_fake=show_fake) }}">{{ p }}</a>
</li>
{% endif %}
{% endfor %}
<!-- Nächste Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('customers.customers', page=page+1, search=search, sort=sort, order=order, show_fake=show_fake) }}"></a>
</li>
<!-- Letzte Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('customers.customers', page=total_pages, search=search, sort=sort, order=order, show_fake=show_fake) }}">Letzte</a>
</li>
</ul>
<p class="text-center text-muted">
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Kunden
</p>
</nav>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Live Search für Kunden
document.addEventListener('DOMContentLoaded', function() {
const searchForm = document.getElementById('customerSearchForm');
const searchInput = document.getElementById('search');
// Debounce timer für Suchfeld
let searchTimeout;
// Live-Suche mit 300ms Verzögerung
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchForm.submit();
}, 300);
});
});
</script>
{% endblock %}

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@@ -0,0 +1,477 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block extra_css %}
<style>
.stat-card {
transition: all 0.3s ease;
cursor: pointer;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.stat-card .card-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
opacity: 0.8;
}
.stat-card .card-value {
font-size: 2.5rem;
font-weight: bold;
margin: 0.5rem 0;
}
.stat-card .card-label {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
}
a:hover .stat-card {
transform: translateY(-5px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.text-decoration-none:hover {
text-decoration: none !important;
}
/* Session pulse effect */
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
.pulse-effect {
animation: pulse 2s infinite;
}
/* Progress bar styles */
.progress-custom {
height: 8px;
background-color: #e9ecef;
border-radius: 4px;
overflow: hidden;
}
.progress-bar-custom {
background-color: #28a745;
transition: width 0.3s ease;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<h1 class="mb-4">Dashboard</h1>
<!-- Statistik-Karten -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<a href="{{ url_for('customers.customers_licenses') }}" class="text-decoration-none">
<div class="card stat-card h-100">
<div class="card-body text-center">
<div class="card-icon text-primary">👥</div>
<div class="card-value text-primary">{{ stats.total_customers }}</div>
<div class="card-label text-muted">Kunden Gesamt</div>
</div>
</div>
</a>
</div>
<div class="col-md-4">
<a href="{{ url_for('customers.customers_licenses') }}" class="text-decoration-none">
<div class="card stat-card h-100">
<div class="card-body text-center">
<div class="card-icon text-info">📋</div>
<div class="card-value text-info">{{ stats.total_licenses }}</div>
<div class="card-label text-muted">Lizenzen Gesamt</div>
</div>
</div>
</a>
</div>
<div class="col-md-4">
<div class="card stat-card h-100">
<div class="card-body text-center">
<div class="card-icon text-success{% if stats.active_usage > 0 %} pulse-effect{% endif %}">🟢</div>
<div class="card-value text-success">{{ stats.active_usage }}</div>
<div class="card-label text-muted">Aktive Nutzung</div>
</div>
</div>
</div>
</div>
<!-- Lizenztypen -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Lizenztypen</h5>
<div class="row">
<div class="col-6 text-center">
<h3 class="text-success">{{ stats.full_licenses }}</h3>
<p class="text-muted">Vollversionen</p>
</div>
<div class="col-6 text-center">
<h3 class="text-warning">{{ stats.fake_licenses }}</h3>
<p class="text-muted">Testversionen</p>
</div>
</div>
{% if stats.fake_data_count > 0 or stats.fake_customers_count > 0 or stats.fake_resources_count > 0 %}
<div class="alert alert-info mt-3 mb-0">
<small>
<i class="fas fa-flask"></i> Fake-Daten:
{{ stats.fake_data_count }} Lizenzen,
{{ stats.fake_customers_count }} Kunden,
{{ stats.fake_resources_count }} Ressourcen
</small>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Lizenzstatus</h5>
<div class="row">
<div class="col-4 text-center">
<h3 class="text-success">{{ stats.active_licenses }}</h3>
<p class="text-muted">Aktiv</p>
</div>
<div class="col-4 text-center">
<h3 class="text-danger">{{ stats.expired_licenses }}</h3>
<p class="text-muted">Abgelaufen</p>
</div>
<div class="col-4 text-center">
<h3 class="text-secondary">{{ stats.inactive_licenses }}</h3>
<p class="text-muted">Deaktiviert</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Service Health Status -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-dark text-white">
<h5 class="mb-0">
<i class="bi bi-heartbeat"></i> Service Status
{% if service_health.overall_status == 'healthy' %}
<span class="badge bg-success float-end">Alle Systeme betriebsbereit</span>
{% elif service_health.overall_status == 'partial' %}
<span class="badge bg-warning float-end">Teilweise Störungen</span>
{% else %}
<span class="badge bg-danger float-end">Kritische Störungen</span>
{% endif %}
</h5>
</div>
<div class="card-body">
<div class="row">
{% for service in service_health.services %}
<div class="col-md-6 mb-3">
<div class="d-flex align-items-center p-3 border rounded"
style="border-left: 4px solid {% if service.status == 'healthy' %}#28a745{% elif service.status == 'unhealthy' %}#ffc107{% else %}#dc3545{% endif %} !important;">
<div class="me-3 fs-2">{{ service.icon }}</div>
<div class="flex-grow-1">
<h6 class="mb-1">{{ service.name }}</h6>
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-{% if service.status == 'healthy' %}success{% elif service.status == 'unhealthy' %}warning{% else %}danger{% endif %}">
{% if service.status == 'healthy' %}Betriebsbereit{% elif service.status == 'unhealthy' %}Eingeschränkt{% else %}Ausgefallen{% endif %}
</span>
{% if service.response_time %}
<small class="text-muted">{{ service.response_time }}ms</small>
{% endif %}
</div>
{% if service.details %}
<small class="text-muted">{{ service.details }}</small>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<!-- Backup-Status und Sicherheit nebeneinander -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<a href="{{ url_for('admin.backups') }}" class="text-decoration-none">
<div class="card stat-card h-100">
<div class="card-body">
<h5 class="card-title">💾 Backup-Status</h5>
{% if stats.last_backup %}
{% if stats.last_backup[4] == 'success' %}
<div class="d-flex align-items-center mb-2">
<span class="text-success me-2"></span>
<small>{{ stats.last_backup[0].strftime('%d.%m.%Y %H:%M') }}</small>
</div>
<div class="progress-custom">
<div class="progress-bar-custom" style="width: 100%;"></div>
</div>
<small class="text-muted mt-1 d-block">
{{ (stats.last_backup[1] / 1024 / 1024)|round(1) }} MB • {{ stats.last_backup[2]|round(0)|int }}s
</small>
{% else %}
<div class="d-flex align-items-center">
<span class="text-danger me-2"></span>
<small>Backup fehlgeschlagen</small>
</div>
{% endif %}
{% else %}
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
{% endif %}
</div>
</div>
</a>
</div>
<!-- Sicherheitsstatus -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">🔒 Sicherheitsstatus</h5>
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Sicherheitslevel:</span>
<span class="badge bg-{{ stats.security_level }} fs-6">{{ stats.security_level_text }}</span>
</div>
<div class="row text-center">
<div class="col-6">
<h4 class="text-danger mb-0">{{ stats.blocked_ips_count }}</h4>
<small class="text-muted">Gesperrte IPs</small>
</div>
<div class="col-6">
<h4 class="text-warning mb-0">{{ stats.failed_attempts_today }}</h4>
<small class="text-muted">Fehlversuche heute</small>
</div>
</div>
<a href="{{ url_for('admin.blocked_ips') }}" class="btn btn-sm btn-outline-danger mt-3">IP-Verwaltung →</a>
</div>
</div>
</div>
</div>
<!-- Sicherheitsereignisse -->
{% if stats.recent_security_events %}
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-dark text-white">
<h6 class="mb-0">🚨 Letzte Sicherheitsereignisse</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 sortable-table">
<thead>
<tr>
<th class="sortable" data-type="date">Zeit</th>
<th class="sortable">IP-Adresse</th>
<th class="sortable" data-type="numeric">Versuche</th>
<th class="sortable">Fehlermeldung</th>
<th class="sortable">Status</th>
</tr>
</thead>
<tbody>
{% for event in stats.recent_security_events %}
<tr>
<td>{{ event.last_attempt }}</td>
<td><code>{{ event.ip_address }}</code></td>
<td><span class="badge bg-secondary">{{ event.attempt_count }}</span></td>
<td><strong class="text-danger">{{ event.error_message }}</strong></td>
<td>
{% if event.blocked_until %}
<span class="badge bg-danger">Gesperrt bis {{ event.blocked_until }}</span>
{% else %}
<span class="badge bg-warning">Aktiv</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Resource Pool Status -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="fas fa-server"></i> Resource Pool Status
</h5>
</div>
<div class="card-body">
<div class="row">
{% if resource_stats %}
{% for type, data in resource_stats.items() %}
<div class="col-md-4 mb-3">
<div class="d-flex align-items-center">
<div class="me-3">
<a href="{{ url_for('resources.resources', type=type, show_test=request.args.get('show_test')) }}"
class="text-decoration-none">
<i class="fas fa-{{ 'globe' if type == 'domain' else ('network-wired' if type == 'ipv4' else 'phone') }} fa-2x text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"></i>
</a>
</div>
<div class="flex-grow-1">
<h6 class="mb-1">
<a href="{{ url_for('resources.resources', type=type, show_test=request.args.get('show_test')) }}"
class="text-decoration-none text-dark">{{ type|upper }}</a>
</h6>
<div class="d-flex justify-content-between align-items-center">
<span>
<strong class="text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">{{ data.available }}</strong> / {{ data.total }} verfügbar
</span>
<span class="badge bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">
{{ data.available_percent }}%
</span>
</div>
<div class="progress mt-1" style="height: 8px;">
<div class="progress-bar bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"
style="width: {{ data.available_percent }}%"
data-bs-toggle="tooltip"
title="{{ data.available }} von {{ data.total }} verfügbar"></div>
</div>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">
<a href="{{ url_for('resources.resources', type=type, status='allocated', show_test=request.args.get('show_test')) }}"
class="text-decoration-none text-muted">
{{ data.allocated }} zugeteilt
</a>
</small>
{% if data.quarantine > 0 %}
<small>
<a href="{{ url_for('resources.resources', type=type, status='quarantine', show_test=request.args.get('show_test')) }}"
class="text-decoration-none text-warning">
<i class="bi bi-exclamation-triangle"></i> {{ data.quarantine }} in Quarantäne
</a>
</small>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12 text-center text-muted">
<i class="fas fa-inbox fa-3x mb-3"></i>
<p>Keine Ressourcen im Pool vorhanden.</p>
<a href="{{ url_for('resources.add_resources', show_test=request.args.get('show_test')) }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Ressourcen hinzufügen
</a>
</div>
{% endif %}
</div>
{% if resource_warning %}
<div class="alert alert-danger mt-3 mb-0 d-flex justify-content-between align-items-center" role="alert">
<div>
<i class="fas fa-exclamation-triangle"></i>
<strong>Kritisch:</strong> {{ resource_warning }}
</div>
<a href="{{ url_for('resources.add_resources', show_test=request.args.get('show_test')) }}"
class="btn btn-sm btn-danger">
<i class="bi bi-plus"></i> Ressourcen auffüllen
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Bald ablaufende Lizenzen -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0">⏰ Bald ablaufende Lizenzen</h5>
</div>
<div class="card-body">
{% if stats.expiring_licenses %}
<div class="table-responsive">
<table class="table table-sm sortable-table">
<thead>
<tr>
<th class="sortable">Kunde</th>
<th class="sortable">Lizenz</th>
<th class="sortable" data-type="numeric">Tage</th>
</tr>
</thead>
<tbody>
{% for license in stats.expiring_licenses %}
<tr>
<td>{{ license[2] }}</td>
<td><small><code>{{ license[1][:8] }}...</code></small></td>
<td><span class="badge bg-warning">{{ license[4] }} Tage</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Keine Lizenzen laufen in den nächsten 30 Tagen ab.</p>
{% endif %}
</div>
</div>
</div>
<!-- Letzte Lizenzen -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="mb-0">🆕 Zuletzt erstellte Lizenzen</h5>
</div>
<div class="card-body">
{% if stats.recent_licenses %}
<div class="table-responsive">
<table class="table table-sm sortable-table">
<thead>
<tr>
<th class="sortable">Kunde</th>
<th class="sortable">Lizenz</th>
<th class="sortable">Status</th>
</tr>
</thead>
<tbody>
{% for license in stats.recent_licenses %}
<tr>
<td>{{ license[2] }}</td>
<td><small><code>{{ license[1][:8] }}...</code></small></td>
<td>
{% if license[4] == 'deaktiviert' %}
<span class="status-deaktiviert">🚫 Deaktiviert</span>
{% elif license[4] == 'abgelaufen' %}
<span class="status-abgelaufen">⚠️ Abgelaufen</span>
{% elif license[4] == 'läuft bald ab' %}
<span class="status-ablaufend">⏰ Läuft bald ab</span>
{% else %}
<span class="status-aktiv">✅ Aktiv</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Noch keine Lizenzen erstellt.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}Kunde bearbeiten{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Kunde bearbeiten</h2>
<div>
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">👥 Zurück zur Übersicht</a>
</div>
</div>
<div class="card mb-4">
<div class="card-body">
<form method="post" action="{{ url_for('customers.edit_customer', customer_id=customer.id) }}" accept-charset="UTF-8">
{% if request.args.get('show_fake') == 'true' %}
<input type="hidden" name="show_fake" value="true">
{% endif %}
<div class="row g-3">
<div class="col-md-6">
<label for="name" class="form-label">Kundenname</label>
<input type="text" class="form-control" id="name" name="name" value="{{ customer.name }}" accept-charset="UTF-8" required>
</div>
<div class="col-md-6">
<label for="email" class="form-label">E-Mail</label>
<input type="email" class="form-control" id="email" name="email" value="{{ customer.email or '' }}" accept-charset="UTF-8">
</div>
<div class="col-12">
<label class="form-label text-muted">Erstellt am</label>
<p class="form-control-plaintext">{{ customer.created_at.strftime('%d.%m.%Y %H:%M') }}</p>
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="isTest" name="is_fake" {% if customer.is_fake %}checked{% endif %}>
<label class="form-check-label" for="isTest">
<i class="fas fa-flask"></i> Als Fake-Daten markieren
<small class="text-muted">(Kunde und seine Lizenzen werden von der Software ignoriert)</small>
</label>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Lizenzen des Kunden</h5>
</div>
<div class="card-body">
{% if licenses %}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Lizenzschlüssel</th>
<th>Typ</th>
<th>Gültig von</th>
<th>Gültig bis</th>
<th>Aktiv</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for license in licenses %}
<tr>
<td><code>{{ license[1] }}</code></td>
<td>
{% if license[2] == 'full' %}
<span class="badge bg-success">Vollversion</span>
{% else %}
<span class="badge bg-warning">Testversion</span>
{% endif %}
</td>
<td>{{ license[3].strftime('%d.%m.%Y') }}</td>
<td>{{ license[4].strftime('%d.%m.%Y') }}</td>
<td>
{% if license[5] %}
<span class="text-success"></span>
{% else %}
<span class="text-danger"></span>
{% endif %}
</td>
<td>
<a href="{{ url_for('licenses.edit_license', license_id=license[0]) }}" class="btn btn-outline-primary btn-sm">Bearbeiten</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Dieser Kunde hat noch keine Lizenzen.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}Lizenz bearbeiten{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Lizenz bearbeiten</h2>
<div>
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">📋 Zurück zur Übersicht</a>
</div>
</div>
<div class="card">
<div class="card-body">
<form method="post" action="{{ url_for('licenses.edit_license', license_id=license.id) }}" accept-charset="UTF-8">
{% if request.args.get('show_fake') == 'true' %}
<input type="hidden" name="show_fake" value="true">
{% endif %}
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Kunde</label>
<input type="text" class="form-control" value="{{ license.customer_name }}" disabled>
<small class="text-muted">Kunde kann nicht geändert werden</small>
</div>
<div class="col-md-6">
<label class="form-label">E-Mail</label>
<input type="email" class="form-control" value="-" disabled>
</div>
<div class="col-md-6">
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
<input type="text" class="form-control" id="licenseKey" name="license_key" value="{{ license.license_key }}" required>
</div>
<div class="col-md-6">
<label for="licenseType" class="form-label">Lizenztyp</label>
<select class="form-select" id="licenseType" name="license_type" required>
<option value="full" {% if license.license_type == 'full' %}selected{% endif %}>Vollversion</option>
<option value="test" {% if license.license_type == 'test' %}selected{% endif %}>Testversion</option>
</select>
</div>
<div class="col-md-4">
<label for="validFrom" class="form-label">Gültig von</label>
<input type="date" class="form-control" id="validFrom" name="valid_from" value="{{ license.valid_from.strftime('%Y-%m-%d') }}" required>
</div>
<div class="col-md-4">
<label for="validUntil" class="form-label">Gültig bis</label>
<input type="date" class="form-control" id="validUntil" name="valid_until" value="{{ license.valid_until.strftime('%Y-%m-%d') }}" required>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isActive" name="is_active" {% if license.is_active %}checked{% endif %}>
<label class="form-check-label" for="isActive">
Lizenz ist aktiv
</label>
</div>
</div>
<div class="col-md-6">
<label for="deviceLimit" class="form-label">Gerätelimit</label>
<select class="form-select" id="deviceLimit" name="device_limit" required>
{% for i in range(1, 11) %}
<option value="{{ i }}" {% if license.get('device_limit', 3) == i %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
{% endfor %}
</select>
<small class="form-text text-muted">Maximale Anzahl gleichzeitig aktiver Geräte</small>
</div>
</div>
<div class="alert {% if license.is_fake %}alert-warning{% else %}alert-success{% endif %} mt-3" role="alert">
<i class="fas fa-info-circle"></i>
<strong>Status:</strong>
{% if license.is_fake %}
TEST-Lizenz (wird von der Software ignoriert)
{% else %}
PRODUKTIV-Lizenz
{% endif %}
<br>
<small class="text-muted">Der Status wird vom Kunden geerbt und kann nicht direkt geändert werden.</small>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Fehler{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center mt-5">
<div class="col-md-8">
<div class="card shadow">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="bi bi-exclamation-triangle-fill"></i>
Fehler
</h4>
</div>
<div class="card-body">
<div class="alert alert-danger mb-4" role="alert">
<h5 class="alert-heading">{{ error|default('Ein Fehler ist aufgetreten') }}</h5>
{% if error_code %}
<hr>
<p class="mb-0">
<strong>Fehlercode:</strong> {{ error_code }}<br>
{% if request_id %}
<strong>Anfrage-ID:</strong> <code>{{ request_id }}</code>
{% endif %}
</p>
{% endif %}
</div>
<div class="bg-light p-3 rounded mb-4">
<h6 class="text-muted">Was können Sie tun?</h6>
<ul class="mb-0">
<li>Versuchen Sie die Aktion erneut</li>
<li>Überprüfen Sie Ihre Eingaben</li>
<li>Kontaktieren Sie den Support mit der Anfrage-ID</li>
</ul>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-primary">
<i class="bi bi-house"></i> Zum Dashboard
</a>
<button onclick="window.history.back();" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Zurück
</button>
<button onclick="location.reload();" class="btn btn-outline-secondary">
<i class="bi bi-arrow-clockwise"></i> Erneut versuchen
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,578 @@
{% extends "base.html" %}
{% block title %}Admin Panel{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Neue Lizenz erstellen</h2>
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">← Zurück zur Übersicht</a>
</div>
<!-- Customer Type Indicator -->
<div id="customerTypeIndicator" class="alert d-none mb-3" role="alert">
<i class="fas fa-info-circle"></i> <span id="customerTypeMessage"></span>
</div>
<form method="post" action="{{ url_for('licenses.create_license') }}" accept-charset="UTF-8">
<div class="row g-3">
<div class="col-md-12">
<label for="customerSelect" class="form-label">Kunde auswählen</label>
<select class="form-select" id="customerSelect" name="customer_id" required>
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
<option value="new"> Neuer Kunde</option>
</select>
</div>
<div class="col-md-6" id="customerNameDiv" style="display: none;">
<label for="customerName" class="form-label">Kundenname</label>
<input type="text" class="form-control" id="customerName" name="customer_name" accept-charset="UTF-8">
</div>
<div class="col-md-6" id="emailDiv" style="display: none;">
<label for="email" class="form-label">E-Mail</label>
<input type="email" class="form-control" id="email" name="email" accept-charset="UTF-8">
</div>
<div class="col-md-4">
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
<div class="input-group">
<input type="text" class="form-control" id="licenseKey" name="license_key"
placeholder="AF-F-YYYYMM-XXXX-YYYY-ZZZZ" required
pattern="AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}"
title="Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ">
<button type="button" class="btn btn-outline-primary" onclick="generateLicenseKey()">
🔑 Generieren
</button>
</div>
<div class="form-text">Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)</div>
</div>
<div class="col-md-4">
<label for="licenseType" class="form-label">Lizenztyp</label>
<select class="form-select" id="licenseType" name="license_type" required>
<option value="full">Vollversion</option>
<option value="test">Testversion</option>
</select>
</div>
<div class="col-md-2">
<label for="validFrom" class="form-label">Kaufdatum</label>
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
</div>
<div class="col-md-1">
<label for="duration" class="form-label">Laufzeit</label>
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
</div>
<div class="col-md-1">
<label for="durationType" class="form-label">Einheit</label>
<select class="form-select" id="durationType" name="duration_type" required>
<option value="days">Tage</option>
<option value="months">Monate</option>
<option value="years" selected>Jahre</option>
</select>
</div>
<div class="col-md-2">
<label for="validUntil" class="form-label">Ablaufdatum</label>
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
</div>
</div>
<!-- Resource Pool Allocation -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-server"></i> Ressourcen-Zuweisung
<small class="text-muted float-end" id="resourceStatus"></small>
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label for="domainCount" class="form-label">
<i class="fas fa-globe"></i> Domains
</label>
<select class="form-select" id="domainCount" name="domain_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
</small>
</div>
<div class="col-md-4">
<label for="ipv4Count" class="form-label">
<i class="fas fa-network-wired"></i> IPv4-Adressen
</label>
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
</small>
</div>
<div class="col-md-4">
<label for="phoneCount" class="form-label">
<i class="fas fa-phone"></i> Telefonnummern
</label>
<select class="form-select" id="phoneCount" name="phone_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
</small>
</div>
</div>
<div class="alert alert-info mt-3 mb-0" role="alert">
<i class="fas fa-info-circle"></i>
Die Ressourcen werden bei der Lizenzerstellung automatisch aus dem Pool zugewiesen.
Wählen Sie 0, wenn für diesen Typ keine Ressourcen benötigt werden.
</div>
</div>
</div>
<!-- Device Limit -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-laptop"></i> Gerätelimit
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label for="deviceLimit" class="form-label">
Maximale Anzahl Geräte
</label>
<select class="form-select" id="deviceLimit" name="device_limit" required>
{% for i in range(1, 11) %}
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann.
</small>
</div>
</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"> Lizenz erstellen</button>
</div>
</form>
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
{% for category, message in messages %}
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<script>
// License Key Generator
function generateLicenseKey() {
const licenseType = document.getElementById('licenseType').value;
// Zeige Ladeindikator
const button = event.target;
const originalText = button.innerHTML;
button.disabled = true;
button.innerHTML = '⏳ Generiere...';
// API-Call
fetch('{{ url_for('api.api_generate_key') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({type: licenseType})
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('licenseKey').value = data.key;
// Visuelles Feedback
document.getElementById('licenseKey').classList.add('border-success');
setTimeout(() => {
document.getElementById('licenseKey').classList.remove('border-success');
}, 2000);
} else {
alert('Fehler bei der Key-Generierung: ' + (data.error || 'Unbekannter Fehler'));
}
})
.catch(error => {
console.error('Fehler:', error);
alert('Netzwerkfehler bei der Key-Generierung');
})
.finally(() => {
// Button zurücksetzen
button.disabled = false;
button.innerHTML = originalText;
});
}
// Event Listener für Lizenztyp-Änderung
document.getElementById('licenseType').addEventListener('change', function() {
const keyField = document.getElementById('licenseKey');
if (keyField.value && keyField.value.startsWith('AF-')) {
// Prüfe ob der Key zum neuen Typ passt
const currentType = this.value;
const keyType = keyField.value.charAt(3); // Position des F/T im Key (AF-F-...)
if ((currentType === 'full' && keyType === 'T') ||
(currentType === 'test' && keyType === 'F')) {
if (confirm('Der aktuelle Key passt nicht zum gewählten Lizenztyp. Neuen Key generieren?')) {
generateLicenseKey();
}
}
}
});
// Auto-Uppercase für License Key Input
document.getElementById('licenseKey').addEventListener('input', function(e) {
e.target.value = e.target.value.toUpperCase();
});
// Funktion zur Berechnung des Ablaufdatums
function calculateValidUntil() {
const validFrom = document.getElementById('validFrom').value;
const duration = parseInt(document.getElementById('duration').value) || 1;
const durationType = document.getElementById('durationType').value;
if (!validFrom) return;
const startDate = new Date(validFrom);
let endDate = new Date(startDate);
switch(durationType) {
case 'days':
endDate.setDate(endDate.getDate() + duration);
break;
case 'months':
endDate.setMonth(endDate.getMonth() + duration);
break;
case 'years':
endDate.setFullYear(endDate.getFullYear() + duration);
break;
}
// Kein Tag abziehen - die Lizenz ist bis einschließlich des Enddatums gültig
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
}
// Event Listener für Änderungen
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
document.getElementById('duration').addEventListener('input', calculateValidUntil);
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
// Setze heutiges Datum als Standard für valid_from
document.addEventListener('DOMContentLoaded', function() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('validFrom').value = today;
// Berechne initiales Ablaufdatum
calculateValidUntil();
// Initialisiere Select2 für Kundenauswahl
$('#customerSelect').select2({
theme: 'bootstrap-5',
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
allowClear: true,
ajax: {
url: '{{ url_for('api.api_customers') }}',
dataType: 'json',
delay: 250,
data: function (params) {
return {
q: params.term,
page: params.page || 1
};
},
processResults: function (data, params) {
params.page = params.page || 1;
// "Neuer Kunde" Option immer oben anzeigen
const results = data.results || [];
if (params.page === 1) {
results.unshift({
id: 'new',
text: ' Neuer Kunde',
isNew: true
});
}
return {
results: results,
pagination: data.pagination
};
},
cache: true
},
minimumInputLength: 0,
language: {
inputTooShort: function() { return ''; },
noResults: function() { return 'Keine Kunden gefunden'; },
searching: function() { return 'Suche...'; },
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
}
});
// Vorausgewählten Kunden setzen (falls von kombinierter Ansicht kommend)
{% if preselected_customer_id %}
// Lade Kundendetails und setze Auswahl
fetch('{{ url_for('api.api_customers') }}?id={{ preselected_customer_id }}')
.then(response => response.json())
.then(data => {
if (data.results && data.results.length > 0) {
const customer = data.results[0];
// Erstelle Option und setze sie als ausgewählt
const option = new Option(customer.text, customer.id, true, true);
$('#customerSelect').append(option).trigger('change');
// Verstecke die Eingabefelder
document.getElementById('customerNameDiv').style.display = 'none';
document.getElementById('emailDiv').style.display = 'none';
}
});
{% endif %}
// Event Handler für Kundenauswahl
$('#customerSelect').on('select2:select', function (e) {
const selectedValue = e.params.data.id;
const nameDiv = document.getElementById('customerNameDiv');
const emailDiv = document.getElementById('emailDiv');
const nameInput = document.getElementById('customerName');
const emailInput = document.getElementById('email');
if (selectedValue === 'new') {
// Zeige Eingabefelder für neuen Kunden
nameDiv.style.display = 'block';
emailDiv.style.display = 'block';
nameInput.required = true;
emailInput.required = true;
// New customers are currently hardcoded as fake (TODO in backend)
window.selectedCustomerIsFake = true;
// Zeige Indikator für neuen Kunden
showCustomerTypeIndicator('new');
} else {
// Verstecke Eingabefelder bei bestehendem Kunden
nameDiv.style.display = 'none';
emailDiv.style.display = 'none';
nameInput.required = false;
emailInput.required = false;
nameInput.value = '';
emailInput.value = '';
// Store customer's is_fake status
window.selectedCustomerIsFake = e.params.data.is_fake || false;
// Zeige Indikator basierend auf Kundendaten
if (e.params.data.is_fake !== undefined) {
showCustomerTypeIndicator(e.params.data.is_fake ? 'fake' : 'real');
}
}
// Update resource availability check with new customer status
checkResourceAvailability();
});
// Clear handler
$('#customerSelect').on('select2:clear', function (e) {
document.getElementById('customerNameDiv').style.display = 'none';
document.getElementById('emailDiv').style.display = 'none';
document.getElementById('customerName').required = false;
document.getElementById('email').required = false;
window.selectedCustomerIsFake = false;
checkResourceAvailability();
hideCustomerTypeIndicator();
});
// Store selected customer's is_fake status
window.selectedCustomerIsFake = false;
// Resource Availability Check
checkResourceAvailability();
// Event Listener für Resource Count Änderungen
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
});
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit
function checkResourceAvailability() {
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
// Include is_fake parameter based on selected customer
const isFake = window.selectedCustomerIsFake ? 'true' : 'false';
// API-Call zur Verfügbarkeitsprüfung
fetch(`{{ url_for('api.check_resource_availability') }}?domain=${domainCount}&ipv4=${ipv4Count}&phone=${phoneCount}&is_fake=${isFake}`)
.then(response => response.json())
.then(data => {
// Update der Verfügbarkeitsanzeigen
updateAvailabilityDisplay('domainsAvailable', data.domain_available, domainCount);
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, ipv4Count);
updateAvailabilityDisplay('phoneAvailable', data.phone_available, phoneCount);
// Gesamtstatus aktualisieren
updateResourceStatus(data, domainCount, ipv4Count, phoneCount);
})
.catch(error => {
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
});
}
// Hilfsfunktion zur Anzeige der Verfügbarkeit
function updateAvailabilityDisplay(elementId, available, requested) {
const element = document.getElementById(elementId);
const container = element.parentElement;
// Verfügbarkeit mit Prozentanzeige
const percent = Math.round((available / (available + requested + 50)) * 100);
let statusHtml = `<strong>${available}</strong>`;
if (requested > 0 && available < requested) {
element.classList.remove('text-success', 'text-warning');
element.classList.add('text-danger');
statusHtml += ` <i class="bi bi-exclamation-triangle"></i>`;
// Füge Warnung hinzu
if (!container.querySelector('.availability-warning')) {
const warning = document.createElement('div');
warning.className = 'availability-warning text-danger small mt-1';
warning.innerHTML = `<i class="bi bi-x-circle"></i> Nicht genügend Ressourcen verfügbar!`;
container.appendChild(warning);
}
} else {
// Entferne Warnung wenn vorhanden
const warning = container.querySelector('.availability-warning');
if (warning) warning.remove();
if (available < 20) {
element.classList.remove('text-success');
element.classList.add('text-danger');
statusHtml += ` <span class="badge bg-danger">Kritisch</span>`;
} else if (available < 50) {
element.classList.remove('text-success', 'text-danger');
element.classList.add('text-warning');
statusHtml += ` <span class="badge bg-warning text-dark">Niedrig</span>`;
} else {
element.classList.remove('text-danger', 'text-warning');
element.classList.add('text-success');
statusHtml += ` <span class="badge bg-success">OK</span>`;
}
}
element.innerHTML = statusHtml;
// Zeige Fortschrittsbalken
updateResourceProgressBar(elementId.replace('Available', ''), available, requested);
}
// Fortschrittsbalken für Ressourcen
function updateResourceProgressBar(resourceType, available, requested) {
const progressId = `${resourceType}Progress`;
let progressBar = document.getElementById(progressId);
// Erstelle Fortschrittsbalken wenn nicht vorhanden
if (!progressBar) {
const container = document.querySelector(`#${resourceType}Available`).parentElement.parentElement;
const progressDiv = document.createElement('div');
progressDiv.className = 'mt-2';
progressDiv.innerHTML = `
<div class="progress" style="height: 20px;" id="${progressId}">
<div class="progress-bar bg-success" role="progressbar" style="width: 0%">
<span class="progress-text"></span>
</div>
</div>
`;
container.appendChild(progressDiv);
progressBar = document.getElementById(progressId);
}
const total = available + requested;
const availablePercent = total > 0 ? (available / total) * 100 : 100;
const bar = progressBar.querySelector('.progress-bar');
const text = progressBar.querySelector('.progress-text');
// Setze Farbe basierend auf Verfügbarkeit
bar.classList.remove('bg-success', 'bg-warning', 'bg-danger');
if (requested > 0 && available < requested) {
bar.classList.add('bg-danger');
} else if (availablePercent < 30) {
bar.classList.add('bg-warning');
} else {
bar.classList.add('bg-success');
}
// Animiere Fortschrittsbalken
bar.style.width = `${availablePercent}%`;
text.textContent = requested > 0 ? `${available} von ${total}` : `${available} verfügbar`;
}
// Gesamtstatus der Ressourcen-Verfügbarkeit
function updateResourceStatus(data, domainCount, ipv4Count, phoneCount) {
const statusElement = document.getElementById('resourceStatus');
let hasIssue = false;
let message = '';
if (domainCount > 0 && data.domain_available < domainCount) {
hasIssue = true;
message = '⚠️ Nicht genügend Domains';
} else if (ipv4Count > 0 && data.ipv4_available < ipv4Count) {
hasIssue = true;
message = '⚠️ Nicht genügend IPv4-Adressen';
} else if (phoneCount > 0 && data.phone_available < phoneCount) {
hasIssue = true;
message = '⚠️ Nicht genügend Telefonnummern';
} else {
message = '✅ Alle Ressourcen verfügbar';
}
statusElement.textContent = message;
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
}
// Funktion zur Anzeige des Kundentyp-Indikators
function showCustomerTypeIndicator(type) {
const indicator = document.getElementById('customerTypeIndicator');
const message = document.getElementById('customerTypeMessage');
indicator.classList.remove('d-none', 'alert-info', 'alert-warning', 'alert-success');
if (type === 'new') {
indicator.classList.add('alert-info');
message.textContent = 'Neue Kunden werden in der Testphase als TEST-Kunden erstellt. Die Lizenz wird automatisch als TEST-Lizenz markiert.';
} else if (type === 'fake') {
indicator.classList.add('alert-warning');
message.textContent = 'Dies ist ein TEST-Kunde. Die Lizenz wird automatisch als TEST-Lizenz markiert und von der Software ignoriert.';
} else if (type === 'real') {
indicator.classList.add('alert-success');
message.textContent = 'Dies ist ein PRODUKTIV-Kunde. Die Lizenz wird als produktive Lizenz erstellt.';
}
}
function hideCustomerTypeIndicator() {
document.getElementById('customerTypeIndicator').classList.add('d-none');
}
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,445 @@
{% extends "base.html" %}
{% block title %}License Analytics{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<h1 class="h3">License Analytics</h1>
<!-- Time Range Selector -->
<div class="btn-group mb-3" role="group">
<a href="{{ url_for('admin.license_analytics', days=7) }}"
class="btn btn-sm {% if days == 7 %}btn-primary{% else %}btn-outline-primary{% endif %}">7 Tage</a>
<a href="{{ url_for('admin.license_analytics', days=30) }}"
class="btn btn-sm {% if days == 30 %}btn-primary{% else %}btn-outline-primary{% endif %}">30 Tage</a>
<a href="{{ url_for('admin.license_analytics', days=90) }}"
class="btn btn-sm {% if days == 90 %}btn-primary{% else %}btn-outline-primary{% endif %}">90 Tage</a>
</div>
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Aktive Lizenzen</h5>
<h2 class="text-primary" id="active-licenses">-</h2>
<small class="text-muted">In den letzten {{ days }} Tagen</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Aktive Geräte</h5>
<h2 class="text-info" id="active-devices">-</h2>
<small class="text-muted">Unique Hardware IDs</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Validierungen</h5>
<h2 class="text-success" id="total-validations">-</h2>
<small class="text-muted">Gesamt in {{ days }} Tagen</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Churn Risk</h5>
<h2 class="text-warning" id="churn-risk">-</h2>
<small class="text-muted">Kunden mit hohem Risiko</small>
</div>
</div>
</div>
</div>
<!-- Usage Trends Chart -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Nutzungstrends</h5>
</div>
<div class="card-body">
<canvas id="usageTrendsChart" height="100"></canvas>
</div>
</div>
</div>
</div>
<!-- Performance Metrics -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Performance Metriken</h5>
</div>
<div class="card-body">
<canvas id="performanceChart" height="150"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Lizenzverteilung</h5>
</div>
<div class="card-body">
<canvas id="distributionChart" height="150"></canvas>
</div>
</div>
</div>
</div>
<!-- Revenue Analysis -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Revenue Analysis</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="revenue-table">
<thead>
<tr>
<th>Lizenztyp</th>
<th>Anzahl Lizenzen</th>
<th>Aktive Lizenzen</th>
<th>Gesamtumsatz</th>
<th>Aktiver Umsatz</th>
<th>Inaktiver Umsatz</th>
</tr>
</thead>
<tbody>
<!-- Wird von JavaScript gefüllt -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Top Performers -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Top 10 Aktive Lizenzen</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm" id="top-licenses">
<thead>
<tr>
<th>Lizenz</th>
<th>Kunde</th>
<th>Geräte</th>
<th>Validierungen</th>
</tr>
</thead>
<tbody>
<!-- Wird von JavaScript gefüllt -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Churn Risk Kunden</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm" id="churn-risk-table">
<thead>
<tr>
<th>Kunde</th>
<th>Lizenzen</th>
<th>Letzte Aktivität</th>
<th>Risk Level</th>
</tr>
</thead>
<tbody>
<!-- Wird von JavaScript gefüllt -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Usage Patterns Heatmap -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Nutzungsmuster (Heatmap)</h5>
</div>
<div class="card-body">
<canvas id="usagePatternsChart" height="60"></canvas>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
// Configuration
const API_BASE_URL = '/api/v1/analytics';
const DAYS = {{ days }};
// Fetch data from Analytics Service
async function fetchAnalyticsData() {
try {
// Get JWT token first (from license server auth)
const tokenResponse = await fetch('/api/admin/license/auth-token');
const tokenData = await tokenResponse.json();
const token = tokenData.token;
const headers = {
'Authorization': `Bearer ${token}`
};
// Fetch all analytics data
const [usage, performance, distribution, revenue, patterns, churnRisk] = await Promise.all([
fetch(`${API_BASE_URL}/usage?days=${DAYS}`, { headers }).then(r => r.json()),
fetch(`${API_BASE_URL}/performance?days=${DAYS}`, { headers }).then(r => r.json()),
fetch(`${API_BASE_URL}/distribution`, { headers }).then(r => r.json()),
fetch(`${API_BASE_URL}/revenue?days=${DAYS}`, { headers }).then(r => r.json()),
fetch(`${API_BASE_URL}/patterns`, { headers }).then(r => r.json()),
fetch(`${API_BASE_URL}/churn-risk`, { headers }).then(r => r.json())
]);
// Update UI with fetched data
updateSummaryCards(usage.data, distribution.data, churnRisk.data);
createUsageTrendsChart(usage.data);
createPerformanceChart(performance.data);
createDistributionChart(distribution.data);
updateRevenueTable(revenue.data);
updateChurnRiskTable(churnRisk.data);
createUsagePatternsHeatmap(patterns.data);
} catch (error) {
console.error('Error fetching analytics data:', error);
// Fallback to database data if API is not available
useFallbackData();
}
}
// Update summary cards
function updateSummaryCards(usageData, distributionData, churnData) {
if (usageData && usageData.length > 0) {
const totalValidations = usageData.reduce((sum, day) => sum + day.total_heartbeats, 0);
const uniqueLicenses = new Set(usageData.flatMap(day => day.active_licenses)).size;
const uniqueDevices = new Set(usageData.flatMap(day => day.active_devices)).size;
document.getElementById('active-licenses').textContent = uniqueLicenses.toLocaleString();
document.getElementById('active-devices').textContent = uniqueDevices.toLocaleString();
document.getElementById('total-validations').textContent = totalValidations.toLocaleString();
}
if (churnData && churnData.length > 0) {
const highRiskCount = churnData.filter(c => c.churn_risk === 'high').length;
document.getElementById('churn-risk').textContent = highRiskCount;
}
}
// Create usage trends chart
function createUsageTrendsChart(data) {
const ctx = document.getElementById('usageTrendsChart').getContext('2d');
const chartData = {
labels: data.map(d => new Date(d.date).toLocaleDateString('de-DE')),
datasets: [
{
label: 'Aktive Lizenzen',
data: data.map(d => d.active_licenses),
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
tension: 0.1
},
{
label: 'Aktive Geräte',
data: data.map(d => d.active_devices),
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.1)',
tension: 0.1
}
]
};
new Chart(ctx, {
type: 'line',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// Create performance chart
function createPerformanceChart(data) {
const ctx = document.getElementById('performanceChart').getContext('2d');
const chartData = {
labels: data.map(d => new Date(d.hour).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit'
})),
datasets: [{
label: 'Validierungen pro Stunde',
data: data.map(d => d.validation_count),
backgroundColor: 'rgba(75, 192, 192, 0.6)',
borderColor: 'rgba(75, 192, 192, 1)',
borderWidth: 1
}]
};
new Chart(ctx, {
type: 'bar',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// Create distribution chart
function createDistributionChart(data) {
const ctx = document.getElementById('distributionChart').getContext('2d');
const chartData = {
labels: data.map(d => `${d.license_type} (${d.is_test ? 'Test' : 'Prod'})`),
datasets: [{
label: 'Lizenzverteilung',
data: data.map(d => d.active_count),
backgroundColor: [
'rgba(255, 99, 132, 0.6)',
'rgba(54, 162, 235, 0.6)',
'rgba(255, 206, 86, 0.6)',
'rgba(75, 192, 192, 0.6)',
'rgba(153, 102, 255, 0.6)'
],
borderWidth: 1
}]
};
new Chart(ctx, {
type: 'doughnut',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false
}
});
}
// Update revenue table
function updateRevenueTable(data) {
const tbody = document.querySelector('#revenue-table tbody');
tbody.innerHTML = '';
data.forEach(row => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${row.license_type}</td>
<td>${row.total_licenses}</td>
<td>${row.total_licenses > 0 ? Math.round((row.active_revenue / row.total_revenue) * 100) : 0}%</td>
<td>€${(row.total_revenue || 0).toFixed(2)}</td>
<td>€${(row.active_revenue || 0).toFixed(2)}</td>
<td>€${(row.inactive_revenue || 0).toFixed(2)}</td>
`;
tbody.appendChild(tr);
});
}
// Update churn risk table
function updateChurnRiskTable(data) {
const tbody = document.querySelector('#churn-risk-table tbody');
tbody.innerHTML = '';
data.filter(d => d.churn_risk !== 'low').slice(0, 10).forEach(row => {
const tr = document.createElement('tr');
const riskClass = row.churn_risk === 'high' ? 'danger' : 'warning';
tr.innerHTML = `
<td>${row.customer_id}</td>
<td>${row.total_licenses}</td>
<td>${row.avg_days_since_activity ? Math.round(row.avg_days_since_activity) + ' Tage' : '-'}</td>
<td><span class="badge bg-${riskClass}">${row.churn_risk}</span></td>
`;
tbody.appendChild(tr);
});
}
// Create usage patterns heatmap
function createUsagePatternsHeatmap(data) {
// Implementation for heatmap would go here
// For now, just log the data
console.log('Usage patterns data:', data);
}
// Fallback to use existing database data
function useFallbackData() {
// Use the data passed from the server template
const usageTrends = {{ usage_trends | tojson | safe }};
const licenseMetrics = {{ license_metrics | tojson | safe }};
const deviceDistribution = {{ device_distribution | tojson | safe }};
const revenueAnalysis = {{ revenue_analysis | tojson | safe }};
// Create charts with fallback data
if (usageTrends) {
const ctx = document.getElementById('usageTrendsChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: usageTrends.map(d => d[0]),
datasets: [{
label: 'Aktive Lizenzen',
data: usageTrends.map(d => d[1]),
borderColor: 'rgb(54, 162, 235)',
tension: 0.1
}]
}
});
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
fetchAnalyticsData();
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,241 @@
{% extends "base.html" %}
{% block title %}License Anomalien{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<h1 class="h3">Anomalie-Erkennung</h1>
<!-- Filter -->
<div class="card mb-3">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-3">
<label class="form-label">Schweregrad</label>
<select name="severity" class="form-select">
<option value="">Alle</option>
<option value="low" {% if request.args.get('severity') == 'low' %}selected{% endif %}>Niedrig</option>
<option value="medium" {% if request.args.get('severity') == 'medium' %}selected{% endif %}>Mittel</option>
<option value="high" {% if request.args.get('severity') == 'high' %}selected{% endif %}>Hoch</option>
<option value="critical" {% if request.args.get('severity') == 'critical' %}selected{% endif %}>Kritisch</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Status</label>
<select name="resolved" class="form-select">
<option value="false" {% if request.args.get('resolved', 'false') == 'false' %}selected{% endif %}>Ungelöst</option>
<option value="true" {% if request.args.get('resolved') == 'true' %}selected{% endif %}>Gelöst</option>
<option value="">Alle</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Anomalie-Typ</label>
<select name="anomaly_type" class="form-select">
<option value="">Alle</option>
<option value="multiple_ips" {% if request.args.get('anomaly_type') == 'multiple_ips' %}selected{% endif %}>Multiple IPs</option>
<option value="rapid_hardware_change" {% if request.args.get('anomaly_type') == 'rapid_hardware_change' %}selected{% endif %}>Schneller Hardware-Wechsel</option>
<option value="suspicious_pattern" {% if request.args.get('anomaly_type') == 'suspicious_pattern' %}selected{% endif %}>Verdächtiges Muster</option>
<option value="concurrent_use" {% if request.args.get('anomaly_type') == 'concurrent_use' %}selected{% endif %}>Gleichzeitige Nutzung</option>
</select>
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary">Filter anwenden</button>
</div>
</form>
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-3">
<div class="col-md-3">
<div class="card border-danger">
<div class="card-body text-center">
<h5 class="card-title text-danger">Kritisch</h5>
<h2 class="mb-0">
{% set critical_count = namespace(value=0) %}
{% for stat in anomaly_stats if stat[1] == 'critical' %}
{% set critical_count.value = critical_count.value + stat[2] %}
{% endfor %}
{{ critical_count.value }}
</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body text-center">
<h5 class="card-title text-warning">Hoch</h5>
<h2 class="mb-0">
{% set high_count = namespace(value=0) %}
{% for stat in anomaly_stats if stat[1] == 'high' %}
{% set high_count.value = high_count.value + stat[2] %}
{% endfor %}
{{ high_count.value }}
</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-info">
<div class="card-body text-center">
<h5 class="card-title text-info">Mittel</h5>
<h2 class="mb-0">
{% set medium_count = namespace(value=0) %}
{% for stat in anomaly_stats if stat[1] == 'medium' %}
{% set medium_count.value = medium_count.value + stat[2] %}
{% endfor %}
{{ medium_count.value }}
</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title text-muted">Niedrig</h5>
<h2 class="mb-0">
{% set low_count = namespace(value=0) %}
{% for stat in anomaly_stats if stat[1] == 'low' %}
{% set low_count.value = low_count.value + stat[2] %}
{% endfor %}
{{ low_count.value }}
</h2>
</div>
</div>
</div>
</div>
<!-- Anomaly List -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Anomalien</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Lizenz</th>
<th>Typ</th>
<th>Schweregrad</th>
<th>Details</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for anomaly in anomalies %}
<tr class="{% if anomaly[6] == 'critical' %}table-danger{% elif anomaly[6] == 'high' %}table-warning{% endif %}">
<td>{{ anomaly[3].strftime('%d.%m.%Y %H:%M') if anomaly[3] else '-' }}</td>
<td>
{% if anomaly[8] %}
<small>{{ anomaly[8][:8] }}...</small><br>
<span class="text-muted">{{ anomaly[9] }}</span>
{% else %}
<span class="text-muted">Unbekannt</span>
{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ anomaly[5] }}</span>
</td>
<td>
<span class="badge bg-{% if anomaly[6] == 'critical' %}danger{% elif anomaly[6] == 'high' %}warning{% elif anomaly[6] == 'medium' %}info{% else %}secondary{% endif %}">
{{ anomaly[6] }}
</span>
</td>
<td>
<button class="btn btn-sm btn-link" onclick="showDetails('{{ anomaly[7] }}')">
Details anzeigen
</button>
</td>
<td>
{% if anomaly[2] %}
<span class="badge bg-success">Gelöst</span>
{% else %}
<span class="badge bg-danger">Ungelöst</span>
{% endif %}
</td>
<td>
{% if not anomaly[2] %}
<button class="btn btn-sm btn-success" onclick="resolveAnomaly('{{ anomaly[0] }}')">
Lösen
</button>
{% else %}
<small class="text-muted">{{ anomaly[4].strftime('%d.%m %H:%M') if anomaly[4] else '' }}</small>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center text-muted">Keine Anomalien gefunden</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Details Modal -->
<div class="modal fade" id="detailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Anomalie Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<pre id="anomalyDetails"></pre>
</div>
</div>
</div>
</div>
<!-- Resolve Modal -->
<div class="modal fade" id="resolveModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form id="resolveForm" method="post">
<div class="modal-header">
<h5 class="modal-title">Anomalie lösen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Ergriffene Maßnahme</label>
<textarea name="action_taken" class="form-control" rows="3" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-success">Als gelöst markieren</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function showDetails(detailsJson) {
try {
const details = JSON.parse(detailsJson);
document.getElementById('anomalyDetails').textContent = JSON.stringify(details, null, 2);
new bootstrap.Modal(document.getElementById('detailsModal')).show();
} catch (e) {
alert('Fehler beim Anzeigen der Details');
}
}
function resolveAnomaly(anomalyId) {
const form = document.getElementById('resolveForm');
form.action = `/lizenzserver/anomaly/${anomalyId}/resolve`;
new bootstrap.Modal(document.getElementById('resolveModal')).show();
}
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,373 @@
{% extends "base.html" %}
{% block title %}Administration{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<h1 class="h3">Administration</h1>
</div>
</div>
<!-- Account Forger Configuration -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">Account Forger Konfiguration</h5>
</div>
<div class="card-body">
<form method="post" action="{{ url_for('admin.update_client_config') }}" class="row g-3">
<div class="col-md-6">
<label class="form-label">Aktuelle Version</label>
<input type="text" class="form-control" name="current_version"
value="{{ client_config[4] if client_config else '1.0.0' }}"
pattern="^\d+\.\d+\.\d+$" required>
</div>
<div class="col-md-6">
<label class="form-label">Minimum Version</label>
<input type="text" class="form-control" name="minimum_version"
value="{{ client_config[5] if client_config else '1.0.0' }}"
pattern="^\d+\.\d+\.\d+$" required>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Aktive Sitzungen</h5>
<div>
<span class="badge bg-white text-dark" id="sessionCount">{{ active_sessions|length if active_sessions else 0 }}</span>
<a href="{{ url_for('admin.license_sessions') }}" class="btn btn-sm btn-light ms-2">Alle anzeigen</a>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Kunde</th>
<th>Version</th>
<th>Letztes Heartbeat</th>
<th>Status</th>
</tr>
</thead>
<tbody id="sessionTableBody">
{% if active_sessions %}
{% for session in active_sessions[:5] %}
<tr>
<td>{{ session[3] or 'Unbekannt' }}</td>
<td>{{ session[6] }}</td>
<td>{{ session[8].strftime('%H:%M:%S') }}</td>
<td>
{% if session[9] < 90 %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-warning">Timeout</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="text-center text-muted">Keine aktiven Sitzungen</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- System API Key Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0"><i class="bi bi-key"></i> API Key für Account Forger</h5>
</div>
<div class="card-body">
{% if system_api_key %}
<div class="alert alert-info mb-3">
<i class="bi bi-info-circle"></i> Dies ist der einzige API Key, den Account Forger benötigt.
Verwenden Sie diesen Key im Header <code>X-API-Key</code> für alle API-Anfragen.
</div>
<div class="row mb-3">
<div class="col-md-12">
<label class="form-label fw-bold">Aktueller API Key:</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="systemApiKey"
value="{{ system_api_key.api_key }}" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copySystemApiKey()">
<i class="bi bi-clipboard"></i> Kopieren
</button>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h6>Key Informationen:</h6>
<ul class="list-unstyled small">
<li><strong>Erstellt:</strong>
{% if system_api_key.created_at %}
{{ system_api_key.created_at.strftime('%d.%m.%Y %H:%M') }}
{% else %}
N/A
{% endif %}
</li>
<li><strong>Erstellt von:</strong> {{ system_api_key.created_by or 'System' }}</li>
{% if system_api_key.regenerated_at %}
<li><strong>Zuletzt regeneriert:</strong>
{{ system_api_key.regenerated_at.strftime('%d.%m.%Y %H:%M') }}
</li>
<li><strong>Regeneriert von:</strong> {{ system_api_key.regenerated_by }}</li>
{% else %}
<li><strong>Zuletzt regeneriert:</strong> Nie</li>
{% endif %}
</ul>
</div>
<div class="col-md-6">
<h6>Nutzungsstatistiken:</h6>
<ul class="list-unstyled small">
<li><strong>Letzte Nutzung:</strong>
{% if system_api_key.last_used_at %}
{{ system_api_key.last_used_at.strftime('%d.%m.%Y %H:%M') }}
{% else %}
Noch nie genutzt
{% endif %}
</li>
<li><strong>Gesamte Anfragen:</strong> {{ system_api_key.usage_count or 0 }}</li>
</ul>
</div>
</div>
<hr>
<div class="row">
<div class="col-md-12">
<form action="{{ url_for('admin.regenerate_api_key') }}" method="POST"
onsubmit="return confirmRegenerate()">
<button type="submit" class="btn btn-warning">
<i class="bi bi-arrow-clockwise"></i> API Key regenerieren
</button>
<span class="text-muted ms-2">
<i class="bi bi-exclamation-triangle"></i>
Dies wird den aktuellen Key ungültig machen!
</span>
</form>
</div>
</div>
<div class="mt-3">
<details>
<summary class="text-primary" style="cursor: pointer;">Verwendungsbeispiel anzeigen</summary>
<div class="mt-2">
<pre class="bg-light p-3"><code>import requests
headers = {
"X-API-Key": "{{ system_api_key.api_key }}",
"Content-Type": "application/json"
}
response = requests.post(
"{{ request.url_root }}api/license/verify",
headers=headers,
json={"license_key": "YOUR_LICENSE_KEY"}
)</code></pre>
</div>
</details>
</div>
{% else %}
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle"></i> Kein System API Key gefunden!
Bitte kontaktieren Sie den Administrator.
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Technical Settings (collapsible) -->
<div class="accordion mb-4" id="technicalSettings">
<!-- Feature Flags -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#featureFlags">
Feature Flags
</button>
</h2>
<div id="featureFlags" class="accordion-collapse collapse" data-bs-parent="#technicalSettings">
<div class="accordion-body">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Feature</th>
<th>Beschreibung</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for flag in feature_flags %}
<tr>
<td><strong>{{ flag[1] }}</strong></td>
<td><small>{{ flag[2] }}</small></td>
<td>
{% if flag[3] %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Inaktiv</span>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('admin.toggle_feature_flag', flag_id=flag[0]) }}" style="display: inline;">
<button type="submit" class="btn btn-sm {% if flag[3] %}btn-danger{% else %}btn-success{% endif %}">
{% if flag[3] %}Deaktivieren{% else %}Aktivieren{% endif %}
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted">Keine Feature Flags konfiguriert</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Rate Limits -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#rateLimits">
Rate Limits
</button>
</h2>
<div id="rateLimits" class="accordion-collapse collapse" data-bs-parent="#technicalSettings">
<div class="accordion-body">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>API Key</th>
<th>Requests/Minute</th>
<th>Requests/Stunde</th>
<th>Requests/Tag</th>
<th>Burst Size</th>
</tr>
</thead>
<tbody>
{% for limit in rate_limits %}
<tr>
<td><code>{{ limit[1][:12] }}...</code></td>
<td>{{ limit[2] }}</td>
<td>{{ limit[3] }}</td>
<td>{{ limit[4] }}</td>
<td>{{ limit[5] }}</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center text-muted">Keine Rate Limits konfiguriert</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
// Show success message instead of alert
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<i class="bi bi-check"></i> Kopiert!';
button.classList.remove('btn-outline-secondary');
button.classList.add('btn-success');
setTimeout(() => {
button.innerHTML = originalText;
button.classList.remove('btn-success');
button.classList.add('btn-outline-secondary');
}, 2000);
});
}
function copySystemApiKey() {
const apiKeyInput = document.getElementById('systemApiKey');
apiKeyInput.select();
apiKeyInput.setSelectionRange(0, 99999);
navigator.clipboard.writeText(apiKeyInput.value).then(function() {
const button = event.currentTarget;
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="bi bi-check"></i> Kopiert!';
button.classList.remove('btn-outline-secondary');
button.classList.add('btn-success');
setTimeout(() => {
button.innerHTML = originalHTML;
button.classList.remove('btn-success');
button.classList.add('btn-outline-secondary');
}, 2000);
});
}
function confirmRegenerate() {
return confirm('Sind Sie sicher, dass Sie den API Key regenerieren möchten?\n\n' +
'Dies wird den aktuellen Key ungültig machen und alle bestehenden ' +
'Integrationen müssen mit dem neuen Key aktualisiert werden.');
}
// Auto-refresh sessions every 30 seconds
function refreshSessions() {
fetch('{{ url_for("admin.license_live_stats") }}')
.then(response => response.json())
.then(data => {
document.getElementById('sessionCount').textContent = data.active_licenses || 0;
// Update session table
const tbody = document.getElementById('sessionTableBody');
if (data.latest_sessions && data.latest_sessions.length > 0) {
tbody.innerHTML = data.latest_sessions.map(session => `
<tr>
<td>${session.customer_name || 'Unbekannt'}</td>
<td>${session.version}</td>
<td>${session.last_heartbeat}</td>
<td>
${session.seconds_since < 90
? '<span class="badge bg-success">Aktiv</span>'
: '<span class="badge bg-warning">Timeout</span>'}
</td>
</tr>
`).join('');
} else {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">Keine aktiven Sitzungen</td></tr>';
}
})
.catch(error => console.error('Error refreshing sessions:', error));
}
// Refresh sessions every 30 seconds
setInterval(refreshSessions, 30000);
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}Aktive Lizenzsitzungen{% endblock %}
{% block content %}
<div class="container-fluid">
<h1>Lizenzsitzungen</h1>
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>Aktive Sitzungen</h5>
</div>
<div class="card-body">
{% if active_sessions %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Lizenzschlüssel</th>
<th>Kunde</th>
<th>Hardware ID</th>
<th>IP-Adresse</th>
<th>Version</th>
<th>Gestartet</th>
<th>Letztes Heartbeat</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for session in active_sessions %}
<tr>
<td><code>{{ session[2][:8] }}...</code></td>
<td>{{ session[3] or 'Unbekannt' }}</td>
<td><code>{{ session[4][:12] }}...</code></td>
<td>{{ session[5] or 'Unbekannt' }}</td>
<td>{{ session[6] }}</td>
<td>{{ session[7].strftime('%H:%M:%S') }}</td>
<td>{{ session[8].strftime('%H:%M:%S') }}</td>
<td>
{% if session[9] < 90 %}
<span class="badge bg-success">Aktiv</span>
{% elif session[9] < 120 %}
<span class="badge bg-warning">Timeout bald</span>
{% else %}
<span class="badge bg-danger">Timeout</span>
{% endif %}
</td>
<td>
{% if session.get('username') in ['rac00n', 'w@rh@mm3r'] %}
<form method="POST" action="{{ url_for('admin.terminate_session', session_id=session[0]) }}"
style="display: inline;" onsubmit="return confirm('Sitzung wirklich beenden?');">
<button type="submit" class="btn btn-sm btn-danger">Beenden</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">Keine aktiven Sitzungen vorhanden.</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>Sitzungsverlauf (letzte 24 Stunden)</h5>
</div>
<div class="card-body">
{% if session_history %}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Lizenzschlüssel</th>
<th>Kunde</th>
<th>Hardware ID</th>
<th>IP-Adresse</th>
<th>Version</th>
<th>Gestartet</th>
<th>Beendet</th>
<th>Dauer</th>
<th>Grund</th>
</tr>
</thead>
<tbody>
{% for hist in session_history %}
<tr>
<td><code>{{ hist[1][:8] }}...</code></td>
<td>{{ hist[2] or 'Unbekannt' }}</td>
<td><code>{{ hist[3][:12] }}...</code></td>
<td>{{ hist[4] or 'Unbekannt' }}</td>
<td>{{ hist[5] }}</td>
<td>{{ hist[6].strftime('%d.%m %H:%M') }}</td>
<td>{{ hist[7].strftime('%d.%m %H:%M') }}</td>
<td>
{% set duration = hist[9] %}
{% if duration < 60 %}
{{ duration|int }}s
{% elif duration < 3600 %}
{{ (duration / 60)|int }}m
{% else %}
{{ (duration / 3600)|round(1) }}h
{% endif %}
</td>
<td>
{% if hist[8] == 'normal' %}
<span class="badge bg-success">Normal</span>
{% elif hist[8] == 'timeout' %}
<span class="badge bg-warning">Timeout</span>
{% elif hist[8] == 'forced' %}
<span class="badge bg-danger">Erzwungen</span>
{% elif hist[8] == 'replaced' %}
<span class="badge bg-info">Ersetzt</span>
{% else %}
{{ hist[8] }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">Keine Sitzungen in den letzten 24 Stunden.</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('admin.license_config') }}" class="btn btn-secondary">Zurück zur Konfiguration</a>
</div>
</div>
<script>
// Auto-refresh every 30 seconds
setTimeout(function() {
location.reload();
}, 30000);
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,455 @@
{% extends "base.html" %}
{% block title %}Lizenzübersicht{% endblock %}
{% macro sortable_header(label, field, current_sort, current_order) %}
<th>
{% set base_url = url_for('licenses.licenses') %}
{% set params = [] %}
{% if search %}{% set _ = params.append('search=' + search|urlencode) %}{% endif %}
{% if request.args.get('data_source') %}{% set _ = params.append('data_source=' + request.args.get('data_source')|urlencode) %}{% endif %}
{% if request.args.get('license_type') %}{% set _ = params.append('license_type=' + request.args.get('license_type')|urlencode) %}{% endif %}
{% if request.args.get('license_status') %}{% set _ = params.append('license_status=' + request.args.get('license_status')|urlencode) %}{% endif %}
{% set _ = params.append('sort=' + field) %}
{% if current_sort == field %}
{% set _ = params.append('order=' + ('desc' if current_order == 'asc' else 'asc')) %}
{% else %}
{% set _ = params.append('order=asc') %}
{% endif %}
{% set _ = params.append('page=1') %}
<a href="{{ base_url }}?{{ params|join('&') }}" class="server-sortable">
{{ label }}
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
{% if current_sort == field %}
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
{% else %}
{% endif %}
</span>
</a>
</th>
{% endmacro %}
{% block extra_css %}
<style>
.filter-group {
background-color: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
}
.filter-group h6 {
color: #495057;
font-weight: 600;
}
.badge.bg-info {
background-color: #0dcaf0 !important;
font-size: 0.75rem;
}
.active-filters .badge {
font-size: 0.875rem;
padding: 0.35em 0.65em;
}
.active-filters a {
text-decoration: none;
opacity: 0.8;
}
.active-filters a:hover {
opacity: 1;
}
#advancedFilters {
transition: all 0.3s ease;
}
.btn-sm {
padding: 0.375rem 0.75rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="mb-4">
<h2>Lizenzübersicht</h2>
</div>
<!-- Such- und Filterformular -->
<div class="card mb-3">
<div class="card-body">
<form method="get" action="{{ url_for('licenses.licenses') }}" id="filterForm">
<!-- Filter Controls -->
<div class="row g-3 mb-3">
<div class="col-md-3">
<label for="dataSource" class="form-label">Datenquelle</label>
<select class="form-select" name="data_source" id="dataSource" onchange="this.form.submit()">
<option value="real" {% if request.args.get('data_source', 'real') == 'real' %}selected{% endif %}>Echte Lizenzen</option>
<option value="fake" {% if request.args.get('data_source') == 'fake' %}selected{% endif %}>🧪 Fake-Daten</option>
<option value="all" {% if request.args.get('data_source') == 'all' %}selected{% endif %}>Alle Daten</option>
</select>
</div>
<div class="col-md-3">
<label for="licenseType" class="form-label">Lizenztyp</label>
<select class="form-select" name="license_type" id="licenseType" onchange="this.form.submit()">
<option value="" {% if not request.args.get('license_type') %}selected{% endif %}>Alle Typen</option>
<option value="full" {% if request.args.get('license_type') == 'full' %}selected{% endif %}>Vollversion</option>
<option value="test" {% if request.args.get('license_type') == 'test' %}selected{% endif %}>Testversion</option>
</select>
</div>
<div class="col-md-3">
<label for="licenseStatus" class="form-label">Status</label>
<select class="form-select" name="license_status" id="licenseStatus" onchange="this.form.submit()">
<option value="" {% if not request.args.get('license_status') %}selected{% endif %}>Alle Status</option>
<option value="active" {% if request.args.get('license_status') == 'active' %}selected{% endif %}>✅ Aktiv</option>
<option value="expired" {% if request.args.get('license_status') == 'expired' %}selected{% endif %}>⚠️ Abgelaufen</option>
<option value="inactive" {% if request.args.get('license_status') == 'inactive' %}selected{% endif %}>❌ Deaktiviert</option>
</select>
</div>
<div class="col-md-3 d-flex align-items-end">
<a href="{{ url_for('licenses.licenses') }}" class="btn btn-secondary w-100">
<i class="bi bi-arrow-clockwise"></i> Filter zurücksetzen
</a>
</div>
</div>
<!-- Search Field -->
<div class="row g-3 mb-3">
<div class="col-md-9">
<label for="search" class="form-label">🔍 Suchen</label>
<input type="text" class="form-control" id="search" name="search"
placeholder="Lizenzschlüssel, Kunde, E-Mail..."
value="{{ search }}">
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-search"></i> Suchen
</button>
</div>
</div>
<!-- Hidden fields for sorting and pagination -->
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="order" value="{{ order }}">
<!-- Note: Other filters are preserved by the form elements themselves -->
</form>
{% if search or request.args.get('data_source') != 'real' or request.args.get('license_type') or request.args.get('license_status') %}
<div class="mt-2">
<div class="d-flex align-items-center justify-content-between">
<small class="text-muted">
Gefiltert: {{ total }} Ergebnisse
</small>
<div class="active-filters">
{% if search %}
<span class="badge bg-secondary me-1">
<i class="bi bi-search"></i> {{ search }}
{% set clear_search_params = [] %}
{% for type in filter_types %}{% set _ = clear_search_params.append('types[]=' + type|urlencode) %}{% endfor %}
{% for status in filter_statuses %}{% set _ = clear_search_params.append('statuses[]=' + status|urlencode) %}{% endfor %}
{% if show_fake %}{% set _ = clear_search_params.append('show_fake=1') %}{% endif %}
{% set _ = clear_search_params.append('sort=' + sort) %}
{% set _ = clear_search_params.append('order=' + order) %}
<a href="{{ url_for('licenses.licenses') }}?{{ clear_search_params|join('&') }}" class="text-white ms-1">&times;</a>
</span>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-container">
<table class="table table-hover table-sticky mb-0">
<thead>
<tr>
<th class="checkbox-cell">
<input type="checkbox" class="form-check-input form-check-input-custom" id="selectAll">
</th>
{{ sortable_header('ID', 'id', sort, order) }}
{{ sortable_header('Lizenzschlüssel', 'license_key', sort, order) }}
{{ sortable_header('Kunde', 'customer', sort, order) }}
{{ sortable_header('E-Mail', 'email', sort, order) }}
{{ sortable_header('Typ', 'type', sort, order) }}
{{ sortable_header('Gültig von', 'valid_from', sort, order) }}
{{ sortable_header('Gültig bis', 'valid_until', sort, order) }}
{{ sortable_header('Status', 'status', sort, order) }}
{{ sortable_header('Aktiv', 'active', sort, order) }}
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for license in licenses %}
<tr>
<td class="checkbox-cell">
<input type="checkbox" class="form-check-input form-check-input-custom license-checkbox" value="{{ license.id }}">
</td>
<td>{{ license.id }}</td>
<td>
<div class="d-flex align-items-center">
<code class="me-2">{{ license.license_key }}</code>
<button class="btn btn-sm btn-outline-secondary btn-copy" onclick="copyToClipboard('{{ license.license_key }}', this)" title="Kopieren">
📋
</button>
</div>
</td>
<td>
{{ license.customer_name }}
{% if license.is_fake %}
<span class="badge bg-secondary ms-1" title="Fake-Daten">🧪</span>
{% endif %}
</td>
<td>-</td>
<td>
{% if license.license_type == 'full' %}
<span class="badge bg-success">Vollversion</span>
{% else %}
<span class="badge bg-warning">Testversion</span>
{% endif %}
</td>
<td>{{ license.valid_from.strftime('%d.%m.%Y') }}</td>
<td>{{ license.valid_until.strftime('%d.%m.%Y') }}</td>
<td>
{% if not license.is_active %}
<span class="status-deaktiviert">❌ Deaktiviert</span>
{% elif license.valid_until < now().date() %}
<span class="status-abgelaufen">⚠️ Abgelaufen</span>
{% else %}
<span class="status-aktiv">✅ Aktiv</span>
{% endif %}
</td>
<td>
<div class="form-check form-switch form-switch-custom">
<input class="form-check-input" type="checkbox"
id="active_{{ license.id }}"
{{ 'checked' if license.is_active else '' }}
onchange="toggleLicenseStatus({{ license.id }}, this.checked)">
</div>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('licenses.edit_license', license_id=license.id) }}" class="btn btn-outline-primary">✏️ Bearbeiten</a>
<form method="post" action="{{ url_for('licenses.delete_license', license_id=license.id) }}" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');">
<button type="submit" class="btn btn-outline-danger">🗑️ Löschen</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not licenses %}
<div class="text-center py-5">
{% if search %}
<p class="text-muted">Keine Lizenzen gefunden für: <strong>{{ search }}</strong></p>
<a href="{{ url_for('licenses.licenses') }}" class="btn btn-secondary">Alle Lizenzen anzeigen</a>
{% else %}
<p class="text-muted">Noch keine Lizenzen vorhanden.</p>
<a href="{{ url_for('licenses.create_license') }}" class="btn btn-primary">Erste Lizenz erstellen</a>
{% endif %}
</div>
{% endif %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav aria-label="Seitennavigation" class="mt-3">
<ul class="pagination justify-content-center">
{% set base_url = url_for('licenses.licenses') %}
{% set base_params = [] %}
{% if search %}{% set _ = base_params.append('search=' + search|urlencode) %}{% endif %}
{% for type in filter_types %}{% set _ = base_params.append('types[]=' + type|urlencode) %}{% endfor %}
{% for status in filter_statuses %}{% set _ = base_params.append('statuses[]=' + status|urlencode) %}{% endfor %}
{% set _ = base_params.append('sort=' + sort) %}
{% set _ = base_params.append('order=' + order) %}
<!-- Erste Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ base_url }}?{{ base_params|join('&') }}&page=1">Erste</a>
</li>
<!-- Vorherige Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ base_url }}?{{ base_params|join('&') }}&page={{ page-1 }}"></a>
</li>
<!-- Seitenzahlen -->
{% for p in range(1, total_pages + 1) %}
{% if p >= page - 2 and p <= page + 2 %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ base_url }}?{{ base_params|join('&') }}&page={{ p }}">{{ p }}</a>
</li>
{% endif %}
{% endfor %}
<!-- Nächste Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ base_url }}?{{ base_params|join('&') }}&page={{ page+1 }}"></a>
</li>
<!-- Letzte Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ base_url }}?{{ base_params|join('&') }}&page={{ total_pages }}">Letzte</a>
</li>
</ul>
<p class="text-center text-muted">
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Lizenzen
</p>
</nav>
{% endif %}
</div>
</div>
</div>
<!-- Bulk Actions Bar -->
<div class="bulk-actions" id="bulkActionsBar">
<div>
<span id="selectedCount">0</span> Lizenzen ausgewählt
</div>
<div>
<button class="btn btn-success btn-sm me-2" onclick="bulkActivate()">✅ Aktivieren</button>
<button class="btn btn-warning btn-sm me-2" onclick="bulkDeactivate()">⏸️ Deaktivieren</button>
<button class="btn btn-danger btn-sm" onclick="bulkDelete()">🗑️ Löschen</button>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Live Filtering
document.addEventListener('DOMContentLoaded', function() {
const filterForm = document.getElementById('filterForm');
const searchInput = document.getElementById('search');
});
// Copy to Clipboard
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(function() {
button.classList.add('copied');
button.innerHTML = '✅';
setTimeout(function() {
button.classList.remove('copied');
button.innerHTML = '📋';
}, 2000);
});
}
// Toggle License Status
function toggleLicenseStatus(licenseId, isActive) {
// Build URL manually to avoid template rendering issues
const baseUrl = '{{ url_for("api.toggle_license", license_id=999999) }}'.replace('999999', licenseId);
fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_active: isActive })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Optional: Show success message
} else {
// Revert toggle on error
document.getElementById(`active_${licenseId}`).checked = !isActive;
alert('Fehler beim Ändern des Status');
}
});
}
// Bulk Selection
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.license-checkbox');
const bulkActionsBar = document.getElementById('bulkActionsBar');
const selectedCount = document.getElementById('selectedCount');
selectAll.addEventListener('change', function() {
checkboxes.forEach(cb => cb.checked = this.checked);
updateBulkActions();
});
checkboxes.forEach(cb => {
cb.addEventListener('change', updateBulkActions);
});
function updateBulkActions() {
const checkedBoxes = document.querySelectorAll('.license-checkbox:checked');
const count = checkedBoxes.length;
if (count > 0) {
bulkActionsBar.classList.add('show');
selectedCount.textContent = count;
} else {
bulkActionsBar.classList.remove('show');
}
// Update select all checkbox
selectAll.checked = count === checkboxes.length && count > 0;
selectAll.indeterminate = count > 0 && count < checkboxes.length;
}
// Bulk Actions
function getSelectedIds() {
return Array.from(document.querySelectorAll('.license-checkbox:checked'))
.map(cb => cb.value);
}
function bulkActivate() {
const ids = getSelectedIds();
if (confirm(`${ids.length} Lizenzen aktivieren?`)) {
performBulkAction('{{ url_for('api.bulk_activate_licenses') }}', ids);
}
}
function bulkDeactivate() {
const ids = getSelectedIds();
if (confirm(`${ids.length} Lizenzen deaktivieren?`)) {
performBulkAction('{{ url_for('api.bulk_deactivate_licenses') }}', ids);
}
}
function bulkDelete() {
const ids = getSelectedIds();
if (confirm(`${ids.length} Lizenzen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden!`)) {
performBulkAction('{{ url_for('api.bulk_delete_licenses') }}', ids);
}
}
function performBulkAction(url, ids) {
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids: ids })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show warnings if any licenses were skipped
if (data.warnings && data.warnings.length > 0) {
let warningMessage = data.message + '\n\n';
warningMessage += 'Warnungen:\n';
data.warnings.forEach(warning => {
warningMessage += '⚠️ ' + warning + '\n';
});
alert(warningMessage);
}
location.reload();
} else {
alert('Fehler bei der Bulk-Aktion: ' + (data.error || data.message));
}
});
}
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Lizenzverwaltung</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.error-failed {
background-color: #dc3545 !important;
color: white !important;
font-weight: bold !important;
font-size: 1.5rem !important;
text-align: center !important;
padding: 1rem !important;
border-radius: 0.5rem !important;
text-transform: uppercase !important;
animation: shake 0.5s;
box-shadow: 0 0 20px rgba(220, 53, 69, 0.5);
}
.error-blocked {
background-color: #6f42c1 !important;
color: white !important;
font-weight: bold !important;
font-size: 1.2rem !important;
text-align: center !important;
padding: 1rem !important;
border-radius: 0.5rem !important;
animation: pulse 2s infinite;
}
.error-captcha {
background-color: #fd7e14 !important;
color: white !important;
font-weight: bold !important;
font-size: 1.2rem !important;
text-align: center !important;
padding: 1rem !important;
border-radius: 0.5rem !important;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
20%, 40%, 60%, 80% { transform: translateX(10px); }
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
.attempts-warning {
background-color: #ffc107;
color: #000;
padding: 0.5rem;
border-radius: 0.25rem;
text-align: center;
margin-bottom: 1rem;
font-weight: bold;
}
.security-info {
font-size: 0.875rem;
color: #6c757d;
text-align: center;
margin-top: 1rem;
}
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="row min-vh-100 align-items-center justify-content-center">
<div class="col-md-4">
<div class="card shadow">
<div class="card-body p-5">
<h2 class="text-center mb-4">🔐 Admin Login</h2>
{% if error %}
<div class="error-{{ error_type|default('failed') }}">
{{ error }}
</div>
{% endif %}
{% if attempts_left is defined and attempts_left > 0 and attempts_left < 5 %}
<div class="attempts-warning">
⚠️ Noch {{ attempts_left }} Versuch(e) bis zur IP-Sperre!
</div>
{% endif %}
<form method="post">
<div class="mb-3">
<label for="username" class="form-label">Benutzername</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Passwort</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
{% if show_captcha and recaptcha_site_key %}
<div class="mb-3">
<div class="g-recaptcha" data-sitekey="{{ recaptcha_site_key }}"></div>
</div>
{% endif %}
<button type="submit" class="btn btn-primary w-100">Anmelden</button>
</form>
<div class="security-info">
🛡️ Geschützt durch Rate-Limiting und IP-Sperre
</div>
</div>
</div>
</div>
</div>
</div>
{% if show_captcha and recaptcha_site_key %}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{% endif %}
</body>
</html>

Datei anzeigen

@@ -0,0 +1,322 @@
{% extends "base.html" %}
{% block title %}Alerts{% endblock %}
{% block extra_css %}
<style>
.alert-card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 15px;
border-left: 5px solid #dee2e6;
transition: all 0.3s;
}
.alert-critical {
border-left-color: #dc3545;
background-color: #f8d7da;
}
.alert-high {
border-left-color: #fd7e14;
background-color: #ffe5d1;
}
.alert-warning {
border-left-color: #ffc107;
background-color: #fff3cd;
}
.alert-info {
border-left-color: #17a2b8;
background-color: #d1ecf1;
}
.severity-badge {
font-size: 0.75rem;
padding: 4px 12px;
border-radius: 15px;
font-weight: 600;
text-transform: uppercase;
}
.severity-critical {
background-color: #dc3545;
color: white;
}
.severity-high {
background-color: #fd7e14;
color: white;
}
.severity-medium {
background-color: #ffc107;
color: #212529;
}
.severity-low {
background-color: #28a745;
color: white;
}
.alert-timestamp {
font-size: 0.875rem;
color: #6c757d;
}
.alert-actions {
margin-top: 15px;
}
.alert-details {
background: rgba(0,0,0,0.05);
padding: 10px;
border-radius: 5px;
margin-top: 10px;
font-size: 0.875rem;
}
.alert-stats {
background: white;
padding: 20px;
border-radius: 10px;
text-align: center;
margin-bottom: 20px;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
}
.filter-pills {
margin-bottom: 20px;
}
.filter-pill {
cursor: pointer;
transition: all 0.2s;
}
.filter-pill.active {
transform: scale(1.05);
}
</style>
{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2><i class="bi bi-exclamation-triangle"></i> Alerts</h2>
<p class="text-muted">Aktive Warnungen und Anomalien</p>
</div>
<div class="col-auto">
<button class="btn btn-outline-primary" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i> Aktualisieren
</button>
</div>
</div>
<!-- Alert Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="alert-stats">
<div class="stat-number text-danger">{{ alerts|selectattr('severity', 'equalto', 'critical')|list|length }}</div>
<div class="text-muted">Kritisch</div>
</div>
</div>
<div class="col-md-3">
<div class="alert-stats">
<div class="stat-number text-warning">{{ alerts|selectattr('severity', 'equalto', 'high')|list|length }}</div>
<div class="text-muted">Hoch</div>
</div>
</div>
<div class="col-md-3">
<div class="alert-stats">
<div class="stat-number text-info">{{ alerts|selectattr('severity', 'equalto', 'medium')|list|length }}</div>
<div class="text-muted">Mittel</div>
</div>
</div>
<div class="col-md-3">
<div class="alert-stats">
<div class="stat-number text-success">{{ alerts|selectattr('severity', 'equalto', 'low')|list|length }}</div>
<div class="text-muted">Niedrig</div>
</div>
</div>
</div>
<!-- Filter Pills -->
<div class="filter-pills">
<span class="badge bg-secondary filter-pill active me-2" onclick="filterAlerts('all')">
Alle ({{ alerts|length }})
</span>
<span class="badge bg-danger filter-pill me-2" onclick="filterAlerts('critical')">
Kritisch
</span>
<span class="badge bg-warning filter-pill me-2" onclick="filterAlerts('high')">
Hoch
</span>
<span class="badge bg-info filter-pill me-2" onclick="filterAlerts('medium')">
Mittel
</span>
<span class="badge bg-success filter-pill me-2" onclick="filterAlerts('low')">
Niedrig
</span>
</div>
<!-- Alerts List -->
<div id="alerts-container">
{% for alert in alerts %}
<div class="alert-card alert-{{ alert.severity }}" data-severity="{{ alert.severity }}">
<div class="row">
<div class="col-md-8">
<div class="d-flex align-items-center mb-2">
<h5 class="mb-0 me-3">
{% if alert.anomaly_type == 'multiple_ips' %}
<i class="bi bi-geo-alt-fill"></i> Mehrere IP-Adressen erkannt
{% elif alert.anomaly_type == 'rapid_hardware_change' %}
<i class="bi bi-laptop"></i> Schneller Hardware-Wechsel
{% elif alert.anomaly_type == 'suspicious_pattern' %}
<i class="bi bi-shield-exclamation"></i> Verdächtiges Muster
{% else %}
<i class="bi bi-exclamation-circle"></i> {{ alert.anomaly_type }}
{% endif %}
</h5>
<span class="severity-badge severity-{{ alert.severity }}">
{{ alert.severity }}
</span>
</div>
{% if alert.company_name %}
<div class="mb-2">
<strong>Kunde:</strong> {{ alert.company_name }}
{% if alert.license_key %}
<span class="text-muted">({{ alert.license_key[:8] }}...)</span>
{% endif %}
</div>
{% endif %}
<div class="alert-timestamp">
<i class="bi bi-clock"></i> {{ alert.detected_at|default(alert.startsAt) }}
</div>
{% if alert.details %}
<div class="alert-details">
<strong>Details:</strong><br>
{{ alert.details }}
</div>
{% endif %}
</div>
<div class="col-md-4">
<div class="alert-actions">
{% if not alert.resolved %}
<button class="btn btn-sm btn-success w-100 mb-2" onclick="resolveAlert('{{ alert.id }}')">
<i class="bi bi-check-circle"></i> Als gelöst markieren
</button>
<button class="btn btn-sm btn-warning w-100 mb-2" onclick="investigateAlert('{{ alert.id }}')">
<i class="bi bi-search"></i> Untersuchen
</button>
{% if alert.severity in ['critical', 'high'] %}
<button class="btn btn-sm btn-danger w-100" onclick="blockLicense('{{ alert.license_id }}')">
<i class="bi bi-shield-lock"></i> Lizenz blockieren
</button>
{% endif %}
{% else %}
<div class="text-success text-center">
<i class="bi bi-check-circle-fill"></i> Gelöst
{% if alert.resolved_at %}
<div class="small">{{ alert.resolved_at }}</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-shield-check" style="font-size: 4rem; color: #28a745;"></i>
<h4 class="mt-3">Keine aktiven Alerts</h4>
<p class="text-muted">Alle Systeme laufen normal</p>
</div>
{% endfor %}
</div>
{% endblock %}
{% block extra_js %}
<script>
// Filter alerts by severity
function filterAlerts(severity) {
// Update active pill
document.querySelectorAll('.filter-pill').forEach(pill => {
pill.classList.remove('active');
});
event.target.classList.add('active');
// Filter alert cards
document.querySelectorAll('.alert-card').forEach(card => {
if (severity === 'all' || card.dataset.severity === severity) {
card.style.display = 'block';
} else {
card.style.display = 'none';
}
});
}
// Resolve alert
async function resolveAlert(alertId) {
if (!confirm('Möchten Sie diesen Alert als gelöst markieren?')) return;
try {
const response = await fetch(`/api/alerts/${alertId}/resolve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
alert('Alert wurde als gelöst markiert');
location.reload();
}
} catch (error) {
alert('Fehler beim Markieren des Alerts');
}
}
// Investigate alert
function investigateAlert(alertId) {
// In production, this would open a detailed investigation view
alert('Detaillierte Untersuchung wird geöffnet...');
}
// Block license
async function blockLicense(licenseId) {
if (!confirm('WARNUNG: Möchten Sie diese Lizenz wirklich blockieren? Der Kunde kann die Software nicht mehr nutzen!')) return;
try {
const response = await fetch(`/api/licenses/${licenseId}/block`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
alert('Lizenz wurde blockiert');
location.reload();
}
} catch (error) {
alert('Fehler beim Blockieren der Lizenz');
}
}
// Auto-refresh alerts every 60 seconds
setInterval(() => {
location.reload();
}, 60000);
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,453 @@
{% extends "base.html" %}
{% block title %}Analytics & Lizenzserver Status{% endblock %}
{% block extra_css %}
<style>
/* Analytics Styles */
.analytics-card {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
height: 100%;
}
.chart-container {
position: relative;
height: 300px;
margin-top: 20px;
}
.stat-box {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 15px;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #495057;
}
.stat-label {
font-size: 0.875rem;
color: #6c757d;
text-transform: uppercase;
margin-top: 5px;
}
.trend-up {
color: #28a745;
}
.trend-down {
color: #dc3545;
}
.date-range-selector {
background: white;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.export-buttons {
margin-top: 20px;
}
.loading-spinner {
text-align: center;
padding: 50px;
}
.no-data {
text-align: center;
padding: 30px;
color: #6c757d;
}
/* License Monitor Styles */
.stat-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
color: var(--status-active);
}
.live-indicator {
display: inline-block;
width: 8px;
height: 8px;
background: var(--status-active);
border-radius: 50%;
animation: pulse 2s infinite;
margin-right: 5px;
}
.validation-timeline {
height: 300px;
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
}
.anomaly-alert {
padding: 1rem;
border-left: 4px solid var(--status-danger);
background: #fff5f5;
margin-bottom: 0.5rem;
}
.device-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: #e9ecef;
border-radius: 4px;
font-size: 0.85rem;
margin-right: 0.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-bar-chart-line"></i> Analytics & Lizenzserver Status</h1>
<div>
<span class="live-indicator"></span>
<span class="text-muted">Live-Daten</span>
<button class="btn btn-sm btn-outline-secondary ms-3" onclick="toggleAutoRefresh()">
<i class="bi bi-arrow-clockwise"></i> Auto-Refresh: <span id="refresh-status">AN</span>
</button>
</div>
</div>
<!-- Date Range Selector -->
<div class="date-range-selector">
<div class="row align-items-center">
<div class="col-md-6">
<label>Zeitraum auswählen:</label>
<select class="form-select" id="date-range" onchange="updateAnalytics()">
<option value="today">Heute</option>
<option value="week" selected>Letzte 7 Tage</option>
<option value="month">Letzte 30 Tage</option>
<option value="quarter">Letztes Quartal</option>
<option value="year">Letztes Jahr</option>
</select>
</div>
<div class="col-md-6 text-end">
<button class="btn btn-outline-primary" onclick="refreshAnalytics()">
<i class="bi bi-arrow-clockwise"></i> Aktualisieren
</button>
</div>
</div>
</div>
<!-- Live Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="stat-card">
<div class="stat-number" id="active-licenses">
{{ live_stats[0] if live_stats else 0 }}
</div>
<div class="stat-label">Aktive Lizenzen</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="stat-card">
<div class="stat-number" id="total-validations">
{{ live_stats[1] if live_stats else 0 }}
</div>
<div class="stat-label">Validierungen (5 Min)</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="stat-card">
<div class="stat-number" id="unique-devices">
{{ live_stats[2] if live_stats else 0 }}
</div>
<div class="stat-label">Aktive Geräte</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="stat-card">
<div class="stat-number" id="unique-ips">
{{ live_stats[3] if live_stats else 0 }}
</div>
<div class="stat-label">Unique IPs</div>
</div>
</div>
</div>
<div class="row">
<!-- Validation Timeline -->
<div class="col-md-8 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Validierungen pro Minute</h5>
</div>
<div class="card-body">
<canvas id="validationChart" height="100"></canvas>
</div>
</div>
</div>
<!-- Recent Anomalies -->
<div class="col-md-4 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Aktuelle Anomalien</h5>
<a href="{{ url_for('admin.license_anomalies') }}" class="btn btn-sm btn-outline-primary">
Alle anzeigen
</a>
</div>
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
{% if recent_anomalies %}
{% for anomaly in recent_anomalies %}
<div class="anomaly-alert">
<div class="d-flex justify-content-between">
<span class="badge badge-{{ 'danger' if anomaly['severity'] == 'critical' else anomaly['severity'] }}">
{{ anomaly['severity'].upper() }}
</span>
<small class="text-muted">{{ anomaly['detected_at'].strftime('%H:%M') }}</small>
</div>
<div class="mt-2">
<strong>{{ anomaly['anomaly_type'].replace('_', ' ').title() }}</strong><br>
<small>Lizenz: {{ anomaly['license_key'][:8] }}...</small>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">Keine aktiven Anomalien</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Top Active Licenses -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Top Aktive Lizenzen (letzte 15 Min)</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Lizenzschlüssel</th>
<th>Kunde</th>
<th>Geräte</th>
<th>Validierungen</th>
<th>Zuletzt gesehen</th>
<th>Status</th>
</tr>
</thead>
<tbody id="top-licenses-tbody">
{% for license in top_licenses %}
<tr>
<td>
<code>{{ license['license_key'][:12] }}...</code>
</td>
<td>{{ license['customer_name'] }}</td>
<td>
<span class="device-badge">
<i class="bi bi-laptop"></i> {{ license['device_count'] }}
</span>
</td>
<td>{{ license['validation_count'] }}</td>
<td>{{ license['last_seen'].strftime('%H:%M:%S') }}</td>
<td>
<span class="badge bg-success">Aktiv</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Latest Validations Stream -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Letzte Validierungen (Live-Stream)</h5>
</div>
<div class="card-body">
<div id="validation-stream" style="max-height: 300px; overflow-y: auto;">
<!-- Will be populated by JavaScript -->
</div>
</div>
</div>
<!-- Export Options -->
<div class="analytics-card">
<h5>Berichte exportieren</h5>
<div class="export-buttons">
<button class="btn btn-outline-success me-2" onclick="exportReport('excel')">
<i class="bi bi-file-excel"></i> Excel Export
</button>
<button class="btn btn-outline-info" onclick="exportReport('csv')">
<i class="bi bi-file-text"></i> CSV Export
</button>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
let autoRefresh = true;
let refreshInterval;
let validationChart;
// Initialize validation chart
const ctx = document.getElementById('validationChart').getContext('2d');
validationChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Validierungen',
data: [],
borderColor: 'rgb(40, 167, 69)',
backgroundColor: 'rgba(40, 167, 69, 0.1)',
tension: 0.1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
}
}
});
// Update chart with validation rates
{% if validation_rates %}
const rates = {{ validation_rates|tojson }};
validationChart.data.labels = rates.map(r => new Date(r[0]).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})).reverse();
validationChart.data.datasets[0].data = rates.map(r => r[1]).reverse();
validationChart.update();
{% endif %}
function loadAnalyticsData() {
// Load basic statistics from database
fetch('/monitoring/api/live-stats')
.then(response => response.json())
.then(data => {
document.getElementById('active-licenses').textContent = data.active_licenses || '0';
document.getElementById('total-validations').textContent = data.validations_last_minute || '0';
document.getElementById('unique-devices').textContent = data.active_devices || '0';
})
.catch(error => {
console.error('Error loading analytics:', error);
document.getElementById('active-licenses').textContent = '0';
document.getElementById('total-validations').textContent = '0';
document.getElementById('unique-devices').textContent = '0';
});
}
// Fetch live statistics
function fetchLiveStats() {
fetch('{{ url_for("admin.license_live_stats") }}')
.then(response => response.json())
.then(data => {
// Update statistics
document.getElementById('active-licenses').textContent = data.active_licenses;
document.getElementById('total-validations').textContent = data.validations_per_minute;
document.getElementById('unique-devices').textContent = data.active_devices;
// Update validation stream
const stream = document.getElementById('validation-stream');
const newEntries = data.latest_validations.map(v =>
`<div class="d-flex justify-content-between border-bottom py-2">
<span>
<code>${v.license_key}</code> |
<span class="text-muted">${v.hardware_id}</span>
</span>
<span>
<span class="badge bg-secondary">${v.ip_address}</span>
<span class="text-muted ms-2">${v.timestamp}</span>
</span>
</div>`
).join('');
if (newEntries) {
stream.innerHTML = newEntries + stream.innerHTML;
// Keep only last 20 entries
const entries = stream.querySelectorAll('div');
if (entries.length > 20) {
for (let i = 20; i < entries.length; i++) {
entries[i].remove();
}
}
}
})
.catch(error => console.error('Error fetching live stats:', error));
}
// Toggle auto-refresh
function toggleAutoRefresh() {
autoRefresh = !autoRefresh;
document.getElementById('refresh-status').textContent = autoRefresh ? 'AN' : 'AUS';
if (autoRefresh) {
refreshInterval = setInterval(fetchLiveStats, 5000);
} else {
clearInterval(refreshInterval);
}
}
function updateAnalytics() {
const range = document.getElementById('date-range').value;
console.log('Updating analytics for range:', range);
loadAnalyticsData();
fetchLiveStats();
}
function refreshAnalytics() {
loadAnalyticsData();
fetchLiveStats();
}
function exportReport(format) {
// Redirect to export endpoint with format parameter
const hours = 24; // Default to 24 hours
window.location.href = `/export/monitoring?format=${format}&hours=${hours}`;
}
// Start auto-refresh
if (autoRefresh) {
refreshInterval = setInterval(fetchLiveStats, 5000);
}
// Load data on page load
document.addEventListener('DOMContentLoaded', function() {
loadAnalyticsData();
fetchLiveStats();
// Refresh every 30 seconds
setInterval(loadAnalyticsData, 30000);
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,698 @@
{% extends "base.html" %}
{% block title %}Live Dashboard & Analytics{% endblock %}
{% block extra_css %}
<style>
/* Combined styles from both dashboards */
.stats-card {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
transition: transform 0.2s;
height: 100%;
}
.stats-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.stats-number {
font-size: 2.5rem;
font-weight: bold;
margin: 10px 0;
}
.stats-label {
color: #6c757d;
font-size: 0.9rem;
text-transform: uppercase;
}
.session-card {
background: white;
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
border-left: 4px solid #28a745;
transition: all 0.2s;
}
.session-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.activity-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 5px;
animation: pulse 2s infinite;
}
.activity-active {
background-color: #28a745;
}
.activity-recent {
background-color: #ffc107;
}
.activity-inactive {
background-color: #6c757d;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.geo-info {
font-size: 0.85rem;
color: #6c757d;
}
.device-info {
background: #f8f9fa;
padding: 5px 10px;
border-radius: 15px;
font-size: 0.85rem;
display: inline-block;
margin: 2px;
}
/* Analytics specific styles */
.chart-container {
position: relative;
height: 300px;
margin-top: 20px;
}
.live-indicator {
display: inline-block;
width: 8px;
height: 8px;
background: var(--status-active);
border-radius: 50%;
animation: pulse 2s infinite;
margin-right: 5px;
}
.anomaly-alert {
padding: 1rem;
border-left: 4px solid var(--status-danger);
background: #fff5f5;
margin-bottom: 0.5rem;
}
.device-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: #e9ecef;
border-radius: 4px;
font-size: 0.85rem;
margin-right: 0.5rem;
}
.nav-tabs .nav-link {
color: #495057;
border: none;
border-bottom: 3px solid transparent;
padding: 0.5rem 1rem;
}
.nav-tabs .nav-link.active {
color: var(--bs-primary);
background: none;
border-bottom-color: var(--bs-primary);
}
.tab-content {
padding-top: 1.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2><i class="bi bi-activity"></i> Live Dashboard & Analytics</h2>
<p class="text-muted">Echtzeit-Übersicht und Analyse der Lizenznutzung</p>
</div>
<div class="col-auto">
<span class="live-indicator"></span>
<span class="text-muted">Live-Daten</span>
<span class="text-muted ms-3">Auto-Refresh: <span id="refresh-countdown">30</span>s</span>
<button class="btn btn-sm btn-outline-primary ms-2" onclick="refreshData()">
<i class="bi bi-arrow-clockwise"></i> Jetzt aktualisieren
</button>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="stats-card">
<i class="bi bi-people-fill text-primary" style="font-size: 2rem;"></i>
<div class="stats-number text-primary" id="active-licenses">{{ live_stats[0] if live_stats else 0 }}</div>
<div class="stats-label">Aktive Lizenzen</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<i class="bi bi-shield-check text-success" style="font-size: 2rem;"></i>
<div class="stats-number text-success" id="total-validations">{{ live_stats[1] if live_stats else 0 }}</div>
<div class="stats-label">Validierungen (5 Min)</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<i class="bi bi-laptop text-info" style="font-size: 2rem;"></i>
<div class="stats-number text-info" id="unique-devices">{{ live_stats[2] if live_stats else 0 }}</div>
<div class="stats-label">Aktive Geräte</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<i class="bi bi-globe text-warning" style="font-size: 2rem;"></i>
<div class="stats-number text-warning" id="unique-ips">{{ live_stats[3] if live_stats else 0 }}</div>
<div class="stats-label">Unique IPs</div>
</div>
</div>
</div>
<!-- Tabbed Interface -->
<ul class="nav nav-tabs" id="dashboardTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab">
<i class="bi bi-speedometer2"></i> Übersicht
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="sessions-tab" data-bs-toggle="tab" data-bs-target="#sessions" type="button" role="tab">
<i class="bi bi-people"></i> Aktive Sessions
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="analytics-tab" data-bs-toggle="tab" data-bs-target="#analytics" type="button" role="tab">
<i class="bi bi-bar-chart-line"></i> Analytics
</button>
</li>
</ul>
<div class="tab-content" id="dashboardTabContent">
<!-- Overview Tab -->
<div class="tab-pane fade show active" id="overview" role="tabpanel">
<div class="row">
<!-- Activity Timeline Chart -->
<div class="col-md-8 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-graph-up"></i> Aktivität (letzte 60 Minuten)</h5>
</div>
<div class="card-body">
<canvas id="activityChart" height="80"></canvas>
</div>
</div>
</div>
<!-- Recent Anomalies -->
<div class="col-md-4 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Aktuelle Anomalien</h5>
<a href="{{ url_for('admin.license_anomalies') }}" class="btn btn-sm btn-outline-primary">
Alle anzeigen
</a>
</div>
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
{% if recent_anomalies %}
{% for anomaly in recent_anomalies %}
<div class="anomaly-alert">
<div class="d-flex justify-content-between">
<span class="badge bg-{{ 'danger' if anomaly['severity'] == 'critical' else anomaly['severity'] }}">
{{ anomaly['severity'].upper() }}
</span>
<small class="text-muted">{{ anomaly['detected_at'].strftime('%H:%M') }}</small>
</div>
<div class="mt-2">
<strong>{{ anomaly['anomaly_type'].replace('_', ' ').title() }}</strong><br>
<small>Lizenz: {{ anomaly['license_key'][:8] }}...</small>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">Keine aktiven Anomalien</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Top Active Licenses -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Top Aktive Lizenzen (letzte 15 Min)</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Lizenzschlüssel</th>
<th>Kunde</th>
<th>Geräte</th>
<th>Validierungen</th>
<th>Zuletzt gesehen</th>
<th>Status</th>
</tr>
</thead>
<tbody id="top-licenses-tbody">
{% for license in top_licenses %}
<tr>
<td>
<code>{{ license['license_key'][:12] }}...</code>
</td>
<td>{{ license['customer_name'] }}</td>
<td>
<span class="device-badge">
<i class="bi bi-laptop"></i> {{ license['device_count'] }}
</span>
</td>
<td>{{ license['validation_count'] }}</td>
<td>{{ license['last_seen'].strftime('%H:%M:%S') }}</td>
<td>
<span class="badge bg-success">Aktiv</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Active Sessions Tab -->
<div class="tab-pane fade" id="sessions" role="tabpanel">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-people"></i> Aktive Kunden-Sessions (letzte 5 Minuten)</h5>
</div>
<div class="card-body">
<div id="active-sessions-container">
{% for session in active_sessions %}
<div class="session-card">
<div class="row align-items-center">
<div class="col-md-4">
<h6 class="mb-1">
<span class="activity-indicator activity-active"></span>
{{ session.company_name }}
</h6>
<small class="text-muted">{{ session.contact_person }}</small>
</div>
<div class="col-md-3">
<div class="mb-1">
<i class="bi bi-key"></i> {{ session.license_key[:8] }}...
</div>
<div class="device-info">
<i class="bi bi-laptop"></i> {{ session.active_devices }} Gerät(e)
</div>
</div>
<div class="col-md-3">
<div class="geo-info">
<i class="bi bi-geo-alt"></i> {{ session.ip_address }}
<div><small>Hardware: {{ session.hardware_id[:12] }}...</small></div>
</div>
</div>
<div class="col-md-2 text-end">
<div class="text-muted">
<i class="bi bi-clock"></i>
<span class="last-activity" data-timestamp="{{ session.last_activity }}">
vor wenigen Sekunden
</span>
</div>
</div>
</div>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
<p>Keine aktiven Sessions in den letzten 5 Minuten</p>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Latest Validations Stream -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">Letzte Validierungen (Live-Stream)</h5>
</div>
<div class="card-body">
<div id="validation-stream" style="max-height: 300px; overflow-y: auto;">
<!-- Will be populated by JavaScript -->
</div>
</div>
</div>
</div>
<!-- Analytics Tab -->
<div class="tab-pane fade" id="analytics" role="tabpanel">
<div class="row">
<div class="col-md-12 mb-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Validierungen pro Minute (30 Min)</h5>
</div>
<div class="card-body">
<canvas id="validationChart" height="100"></canvas>
</div>
</div>
</div>
</div>
<!-- Export Options -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Berichte exportieren</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label>Zeitraum auswählen:</label>
<select class="form-select" id="date-range" onchange="updateAnalytics()">
<option value="today">Heute</option>
<option value="week" selected>Letzte 7 Tage</option>
<option value="month">Letzte 30 Tage</option>
<option value="quarter">Letztes Quartal</option>
<option value="year">Letztes Jahr</option>
</select>
</div>
<div class="col-md-6">
<label>&nbsp;</label>
<div>
<button class="btn btn-outline-primary me-2" onclick="exportReport('pdf')">
<i class="bi bi-file-pdf"></i> PDF Export
</button>
<button class="btn btn-outline-success me-2" onclick="exportReport('excel')">
<i class="bi bi-file-excel"></i> Excel Export
</button>
<button class="btn btn-outline-info" onclick="exportReport('csv')">
<i class="bi bi-file-text"></i> CSV Export
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Hidden validation timeline data for chart -->
<script id="validation-timeline-data" type="application/json">
{{ validation_timeline|tojson }}
</script>
<script id="validation-rates-data" type="application/json">
{{ validation_rates|tojson }}
</script>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/date-fns@2.29.3/index.min.js"></script>
<script>
let activityChart;
let validationChart;
let refreshInterval;
let refreshCountdown = 30;
// Initialize activity chart
function initActivityChart() {
const ctx = document.getElementById('activityChart').getContext('2d');
const timelineData = JSON.parse(document.getElementById('validation-timeline-data').textContent);
// Prepare data for chart
const labels = timelineData.map(item => {
const date = new Date(item.minute);
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}).reverse();
const data = timelineData.map(item => item.validations).reverse();
activityChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Validierungen',
data: data,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.1,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// Initialize validation chart
function initValidationChart() {
const ctx = document.getElementById('validationChart').getContext('2d');
const ratesData = JSON.parse(document.getElementById('validation-rates-data').textContent);
validationChart = new Chart(ctx, {
type: 'line',
data: {
labels: ratesData.map(r => new Date(r[0]).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})).reverse(),
datasets: [{
label: 'Validierungen',
data: ratesData.map(r => r[1]).reverse(),
borderColor: 'rgb(40, 167, 69)',
backgroundColor: 'rgba(40, 167, 69, 0.1)',
tension: 0.1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// Update last activity times
function updateActivityTimes() {
document.querySelectorAll('.last-activity').forEach(el => {
const timestamp = new Date(el.dataset.timestamp);
const now = new Date();
const seconds = Math.floor((now - timestamp) / 1000);
if (seconds < 60) {
el.textContent = 'vor wenigen Sekunden';
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
el.textContent = `vor ${minutes} Minute${minutes > 1 ? 'n' : ''}`;
} else {
const hours = Math.floor(seconds / 3600);
el.textContent = `vor ${hours} Stunde${hours > 1 ? 'n' : ''}`;
}
});
}
// Refresh data via AJAX
async function refreshData() {
try {
// Get live stats
const statsResponse = await fetch('/monitoring/api/live-stats');
const stats = await statsResponse.json();
// Update stats cards
document.getElementById('active-licenses').textContent = stats.active_licenses || 0;
document.getElementById('unique-devices').textContent = stats.active_devices || 0;
document.getElementById('total-validations').textContent = stats.validations_last_minute || 0;
// Get active sessions
const sessionsResponse = await fetch('/monitoring/api/active-sessions');
const sessions = await sessionsResponse.json();
// Update sessions display
updateSessionsDisplay(sessions);
// Fetch live stats for validation stream
fetchLiveStats();
// Reset countdown
refreshCountdown = 30;
} catch (error) {
console.error('Error refreshing data:', error);
}
}
// Update sessions display
function updateSessionsDisplay(sessions) {
const container = document.getElementById('active-sessions-container');
if (sessions.length === 0) {
container.innerHTML = `
<div class="text-center text-muted py-5">
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
<p>Keine aktiven Sessions in den letzten 5 Minuten</p>
</div>
`;
return;
}
const sessionsHtml = sessions.map(session => {
const secondsAgo = Math.floor(session.seconds_ago);
let activityClass = 'activity-active';
if (secondsAgo > 120) activityClass = 'activity-recent';
if (secondsAgo > 240) activityClass = 'activity-inactive';
return `
<div class="session-card">
<div class="row align-items-center">
<div class="col-md-4">
<h6 class="mb-1">
<span class="activity-indicator ${activityClass}"></span>
${session.company_name}
</h6>
</div>
<div class="col-md-3">
<div class="mb-1">
<i class="bi bi-key"></i> ${session.license_key.substring(0, 8)}...
</div>
</div>
<div class="col-md-3">
<div class="geo-info">
<i class="bi bi-geo-alt"></i> ${session.ip_address}
<div><small>Hardware: ${session.hardware_id.substring(0, 12)}...</small></div>
</div>
</div>
<div class="col-md-2 text-end">
<div class="text-muted">
<i class="bi bi-clock"></i>
vor ${formatSecondsAgo(secondsAgo)}
</div>
</div>
</div>
</div>
`;
}).join('');
container.innerHTML = sessionsHtml;
}
// Fetch live statistics
function fetchLiveStats() {
fetch('{{ url_for("admin.license_live_stats") }}')
.then(response => response.json())
.then(data => {
// Update validation stream
const stream = document.getElementById('validation-stream');
const newEntries = data.latest_validations.map(v =>
`<div class="d-flex justify-content-between border-bottom py-2">
<span>
<code>${v.license_key}</code> |
<span class="text-muted">${v.hardware_id}</span>
</span>
<span>
<span class="badge bg-secondary">${v.ip_address}</span>
<span class="text-muted ms-2">${v.timestamp}</span>
</span>
</div>`
).join('');
if (newEntries) {
stream.innerHTML = newEntries + stream.innerHTML;
// Keep only last 20 entries
const entries = stream.querySelectorAll('div');
if (entries.length > 20) {
for (let i = 20; i < entries.length; i++) {
entries[i].remove();
}
}
}
})
.catch(error => console.error('Error fetching live stats:', error));
}
// Format seconds ago
function formatSecondsAgo(seconds) {
if (seconds < 60) return 'wenigen Sekunden';
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return `${minutes} Minute${minutes > 1 ? 'n' : ''}`;
}
const hours = Math.floor(seconds / 3600);
return `${hours} Stunde${hours > 1 ? 'n' : ''}`;
}
function updateAnalytics() {
const range = document.getElementById('date-range').value;
console.log('Updating analytics for range:', range);
refreshData();
}
function exportReport(format) {
// Redirect to export endpoint with format parameter
const hours = 24; // Default to 24 hours
window.location.href = `/export/monitoring?format=${format}&hours=${hours}`;
}
// Countdown timer
function updateCountdown() {
refreshCountdown--;
document.getElementById('refresh-countdown').textContent = refreshCountdown;
if (refreshCountdown <= 0) {
refreshData();
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initActivityChart();
initValidationChart();
updateActivityTimes();
// Set up auto-refresh
refreshInterval = setInterval(() => {
updateCountdown();
updateActivityTimes();
}, 1000);
});
// Clean up on page leave
window.addEventListener('beforeunload', function() {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,609 @@
{% extends "base.html" %}
{% block title %}Monitoring{% endblock %}
{% block extra_css %}
<style>
/* Header Status Bar */
.status-header {
background: #f8f9fa;
border-bottom: 2px solid #dee2e6;
padding: 0.75rem;
position: sticky;
top: 0;
z-index: 100;
margin: -1rem -1rem 1rem -1rem;
}
.status-indicator {
display: inline-flex;
align-items: center;
font-weight: 500;
}
.status-indicator .badge {
margin-left: 0.5rem;
}
/* Metric Cards */
.metric-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1rem;
position: relative;
transition: all 0.2s;
}
.metric-card:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.metric-value {
font-size: 2rem;
font-weight: bold;
margin: 0.5rem 0;
}
.metric-label {
color: #6c757d;
font-size: 0.875rem;
text-transform: uppercase;
}
.metric-trend {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 0.875rem;
}
.metric-alert {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
background: #dc3545;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.75rem;
font-weight: bold;
}
/* Activity Stream */
.activity-stream {
max-height: 500px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
background: white;
}
.activity-item {
padding: 0.75rem;
border-bottom: 1px solid #f8f9fa;
transition: background 0.2s;
}
.activity-item:hover {
background: #f8f9fa;
}
.activity-item.validation {
border-left: 3px solid #28a745;
}
.activity-item.anomaly-warning {
border-left: 3px solid #ffc107;
background-color: #fff3cd;
}
.activity-item.anomaly-critical {
border-left: 3px solid #dc3545;
background-color: #f8d7da;
}
/* Statistics Panel */
.stats-panel {
background: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1rem;
}
.license-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid #f8f9fa;
}
.license-item:last-child {
border-bottom: none;
}
/* Tabs */
.analysis-tabs .nav-link {
color: #495057;
border: none;
border-bottom: 2px solid transparent;
padding: 0.5rem 1rem;
}
.analysis-tabs .nav-link.active {
color: #0d6efd;
border-bottom-color: #0d6efd;
background: none;
}
/* Charts */
.chart-container {
height: 300px;
position: relative;
}
/* Expandable sections */
.expandable-section {
border: 1px solid #dee2e6;
border-radius: 0.5rem;
margin-bottom: 1rem;
overflow: hidden;
}
.expandable-header {
background: #f8f9fa;
padding: 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.expandable-header:hover {
background: #e9ecef;
}
.expandable-content {
padding: 1rem;
display: none;
}
.expandable-content.show {
display: block;
}
/* Auto-refresh indicator */
.refresh-indicator {
font-size: 0.875rem;
color: #6c757d;
}
.refresh-indicator.active {
color: #28a745;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.metric-card {
margin-bottom: 1rem;
}
.activity-stream {
max-height: 300px;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid p-3">
<!-- Status Header -->
<div class="status-header">
<div class="d-flex justify-content-between align-items-center">
<div class="status-indicator">
{% if system_status == 'normal' %}
<span class="text-success">🟢 System Normal</span>
{% elif system_status == 'warning' %}
<span class="text-warning">🟡 System Warning</span>
{% else %}
<span class="text-danger">🔴 System Critical</span>
{% endif %}
<span class="badge bg-{{ status_color }}">{{ active_alerts }} Aktive Alerts</span>
</div>
<div class="d-flex align-items-center gap-3">
<span class="text-muted">Letzte Aktualisierung: <span id="last-update">jetzt</span></span>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto-refresh" checked>
<label class="form-check-label" for="auto-refresh">Auto-Refresh</label>
</div>
</div>
</div>
</div>
<!-- Executive Summary (Collapsible) -->
<div class="expandable-section" id="executive-summary">
<div class="expandable-header" onclick="toggleSection('executive-summary')">
<h5 class="mb-0">📊 Executive Summary</h5>
<i class="bi bi-chevron-down"></i>
</div>
<div class="expandable-content show">
<div class="row g-3">
<div class="col-md-3">
<div class="metric-card">
<div class="metric-label">Aktive Lizenzen</div>
<div class="metric-value text-primary">{{ live_metrics.active_licenses or 0 }}</div>
<div class="metric-trend text-success">
<i class="bi bi-arrow-up"></i> <span class="trend-value">0%</span>
</div>
{% if live_metrics.active_licenses > 100 %}
<div class="metric-alert">!</div>
{% endif %}
</div>
</div>
<div class="col-md-3">
<div class="metric-card">
<div class="metric-label">Validierungen (5 Min)</div>
<div class="metric-value text-info">{{ live_metrics.total_validations or 0 }}</div>
<div class="metric-trend text-muted">
<i class="bi bi-dash"></i> <span class="trend-value">0%</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="metric-card">
<div class="metric-label">Aktive Geräte</div>
<div class="metric-value text-success">{{ live_metrics.unique_devices or 0 }}</div>
<div class="metric-trend text-success">
<i class="bi bi-arrow-up"></i> <span class="trend-value">0%</span>
</div>
</div>
</div>
<div class="col-md-3">
<div class="metric-card">
<div class="metric-label">Response Zeit</div>
<div class="metric-value text-warning">{{ (live_metrics.avg_response_time or 0)|round(1) }}ms</div>
<div class="metric-trend text-warning">
<i class="bi bi-arrow-up"></i> <span class="trend-value">0%</span>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<canvas id="trend-chart" height="80"></canvas>
</div>
</div>
</div>
</div>
<!-- Main Content Area -->
<div class="row g-3">
<!-- Activity Stream (Left Panel) -->
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">🔄 Activity Stream</h5>
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary active" data-filter="all">Alle</button>
<button type="button" class="btn btn-outline-success" data-filter="normal">Normal</button>
<button type="button" class="btn btn-outline-warning" data-filter="warning">Warnungen</button>
<button type="button" class="btn btn-outline-danger" data-filter="critical">Kritisch</button>
</div>
</div>
<div class="activity-stream">
{% for event in activity_stream %}
<div class="activity-item {{ event.event_type }} {% if event.event_type == 'anomaly' %}anomaly-{{ event.severity }}{% endif %}"
data-severity="{{ event.severity or 'normal' }}">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center mb-1">
{% if event.event_type == 'validation' %}
<i class="bi bi-check-circle text-success me-2"></i>
{% else %}
<i class="bi bi-exclamation-triangle text-{{ 'warning' if event.severity == 'warning' else 'danger' }} me-2"></i>
{% endif %}
<strong>{{ event.customer_name or 'Unbekannt' }}</strong>
<span class="text-muted ms-2">{{ event.license_key[:8] }}...</span>
</div>
<div class="small text-muted">
{% if event.event_type == 'anomaly' %}
<span class="badge bg-{{ 'warning' if event.severity == 'warning' else 'danger' }} me-2">
{{ event.anomaly_type }}
</span>
{{ event.description }}
{% else %}
Validierung von {{ event.ip_address }} • Gerät: {{ event.hardware_id[:8] }}...
{% endif %}
</div>
</div>
<div class="text-end">
<small class="text-muted">{{ event.timestamp.strftime('%H:%M:%S') if event.timestamp else '-' }}</small>
{% if event.event_type == 'anomaly' and event.severity == 'critical' %}
<div class="mt-1">
<button class="btn btn-sm btn-danger" onclick="blockIP('{{ event.ip_address }}')">
<i class="bi bi-slash-circle"></i> Block
</button>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% if not activity_stream %}
<div class="text-center text-muted p-5">
<i class="bi bi-inbox fs-1"></i>
<p class="mt-2">Keine Aktivitäten in den letzten 60 Minuten</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Statistics Panel (Right Panel) -->
<div class="col-lg-4">
<!-- Top Active Licenses -->
<div class="stats-panel mb-3">
<h6 class="mb-3">🏆 Top Aktive Lizenzen</h6>
{% for license in top_licenses %}
<div class="license-item">
<div>
<div class="fw-bold">{{ license.customer_name }}</div>
<small class="text-muted">{{ license.device_count }} Geräte • {{ license.validation_count }} Validierungen</small>
</div>
{% if license.anomaly_count > 0 %}
<span class="badge bg-warning">{{ license.anomaly_count }} ⚠️</span>
{% else %}
<span class="badge bg-success">OK</span>
{% endif %}
</div>
{% endfor %}
{% if not top_licenses %}
<p class="text-muted text-center">Keine aktiven Lizenzen</p>
{% endif %}
</div>
<!-- Anomaly Distribution -->
<div class="stats-panel mb-3">
<h6 class="mb-3">🎯 Anomalie-Verteilung</h6>
<canvas id="anomaly-chart" height="200"></canvas>
{% if not anomaly_distribution %}
<p class="text-muted text-center">Keine Anomalien erkannt</p>
{% endif %}
</div>
<!-- Geographic Distribution -->
<div class="stats-panel">
<h6 class="mb-3">🌍 Geografische Verteilung</h6>
<div style="max-height: 200px; overflow-y: auto;">
{% for geo in geo_data[:5] %}
<div class="d-flex justify-content-between align-items-center py-1">
<span class="text-truncate">{{ geo.ip_address }}</span>
<span class="badge bg-secondary">{{ geo.request_count }}</span>
</div>
{% endfor %}
</div>
{% if geo_data|length > 5 %}
<small class="text-muted">+{{ geo_data|length - 5 }} weitere IPs</small>
{% endif %}
</div>
</div>
</div>
<!-- Analysis Tabs -->
<div class="card mt-4">
<div class="card-header">
<ul class="nav nav-tabs analysis-tabs card-header-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" data-bs-toggle="tab" href="#patterns">🔍 Patterns</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#performance">⚡ Performance</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#forensics">🔬 Forensics</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#predictions">📈 Predictions</a>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<div class="tab-pane active" id="patterns">
<p class="text-muted">Ungewöhnliche Verhaltensmuster werden hier angezeigt...</p>
</div>
<div class="tab-pane" id="performance">
<div class="chart-container">
<canvas id="performance-chart"></canvas>
</div>
</div>
<div class="tab-pane" id="forensics">
<p class="text-muted">Detaillierte Analyse spezifischer Lizenzen...</p>
</div>
<div class="tab-pane" id="predictions">
<p class="text-muted">Vorhersagen und Kapazitätsplanung...</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// Auto-refresh functionality
let refreshInterval;
const AUTO_REFRESH_INTERVAL = 30000; // 30 seconds
function startAutoRefresh() {
if ($('#auto-refresh').is(':checked')) {
refreshInterval = setInterval(() => {
location.reload();
}, AUTO_REFRESH_INTERVAL);
}
}
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
}
}
$('#auto-refresh').change(function() {
if (this.checked) {
startAutoRefresh();
} else {
stopAutoRefresh();
}
});
// Toggle expandable sections
function toggleSection(sectionId) {
const section = document.getElementById(sectionId);
const content = section.querySelector('.expandable-content');
const icon = section.querySelector('.bi');
content.classList.toggle('show');
icon.classList.toggle('bi-chevron-down');
icon.classList.toggle('bi-chevron-up');
}
// Filter activity stream
document.querySelectorAll('[data-filter]').forEach(button => {
button.addEventListener('click', function() {
const filter = this.dataset.filter;
document.querySelectorAll('[data-filter]').forEach(btn => btn.classList.remove('active'));
this.classList.add('active');
document.querySelectorAll('.activity-item').forEach(item => {
if (filter === 'all') {
item.style.display = 'block';
} else {
const severity = item.dataset.severity;
item.style.display = severity === filter ? 'block' : 'none';
}
});
});
});
// Block IP function
function blockIP(ip) {
if (confirm(`IP-Adresse ${ip} wirklich blockieren?`)) {
// Implementation for blocking IP
alert(`IP ${ip} wurde blockiert.`);
}
}
// Initialize charts
const trendData = {{ trend_data|tojson }};
const anomalyData = {{ anomaly_distribution|tojson }};
const performanceData = {{ performance_data|tojson }};
// Trend Chart
if (trendData && trendData.length > 0) {
const trendCtx = document.getElementById('trend-chart').getContext('2d');
new Chart(trendCtx, {
type: 'line',
data: {
labels: trendData.map(d => new Date(d.hour).toLocaleTimeString('de-DE', {hour: '2-digit'})),
datasets: [{
label: 'Validierungen',
data: trendData.map(d => d.validations),
borderColor: '#0d6efd',
backgroundColor: 'rgba(13, 110, 253, 0.1)',
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: { beginAtZero: true }
}
}
});
}
// Anomaly Distribution Chart
if (anomalyData && anomalyData.length > 0) {
const anomalyCtx = document.getElementById('anomaly-chart').getContext('2d');
new Chart(anomalyCtx, {
type: 'doughnut',
data: {
labels: anomalyData.map(d => d.anomaly_type),
datasets: [{
data: anomalyData.map(d => d.count),
backgroundColor: ['#ffc107', '#dc3545', '#fd7e14', '#6f42c1']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { boxWidth: 12 }
}
}
}
});
}
// Performance Chart
if (performanceData && performanceData.length > 0) {
const perfCtx = document.getElementById('performance-chart').getContext('2d');
new Chart(perfCtx, {
type: 'line',
data: {
labels: performanceData.map(d => new Date(d.minute).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})),
datasets: [{
label: 'Avg Response Time',
data: performanceData.map(d => d.avg_response_time),
borderColor: '#28a745',
backgroundColor: 'rgba(40, 167, 69, 0.1)',
tension: 0.4
}, {
label: 'Max Response Time',
data: performanceData.map(d => d.max_response_time),
borderColor: '#dc3545',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Response Time (ms)'
}
}
}
}
});
}
// Update last refresh time
function updateLastRefresh() {
const now = new Date();
document.getElementById('last-update').textContent = now.toLocaleTimeString('de-DE');
}
// Start auto-refresh on load
startAutoRefresh();
updateLastRefresh();
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,216 @@
{% extends "base.html" %}
{% block title %}Benutzerprofil{% endblock %}
{% block extra_css %}
<style>
.profile-card {
transition: all 0.3s ease;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.profile-card:hover {
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.profile-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.8;
}
.security-badge {
font-size: 2rem;
margin-right: 1rem;
}
.form-control:focus {
border-color: #6c757d;
box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.25);
}
.password-strength {
height: 4px;
margin-top: 5px;
border-radius: 2px;
transition: all 0.3s ease;
}
.strength-very-weak { background-color: #dc3545; width: 20%; }
.strength-weak { background-color: #fd7e14; width: 40%; }
.strength-medium { background-color: #ffc107; width: 60%; }
.strength-strong { background-color: #28a745; width: 80%; }
.strength-very-strong { background-color: #0d6efd; width: 100%; }
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>👤 Benutzerprofil</h1>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- User Info Stats -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card profile-card h-100">
<div class="card-body text-center">
<div class="profile-icon text-primary">👤</div>
<h5 class="card-title">{{ user.username }}</h5>
<p class="text-muted mb-0">{{ user.email or 'Keine E-Mail angegeben' }}</p>
<small class="text-muted">Mitglied seit: {{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'Unbekannt' }}</small>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card profile-card h-100">
<div class="card-body text-center">
<div class="profile-icon text-info">🔐</div>
<h5 class="card-title">Sicherheitsstatus</h5>
{% if user.totp_enabled %}
<span class="badge bg-success">2FA Aktiv</span>
{% else %}
<span class="badge bg-warning text-dark">2FA Inaktiv</span>
{% endif %}
<p class="text-muted mb-0 mt-2">
<small>Letztes Passwort-Update:<br>{{ user.last_password_change.strftime('%d.%m.%Y') if user.last_password_change else 'Noch nie' }}</small>
</p>
</div>
</div>
</div>
</div>
<!-- Password Change Card -->
<div class="card profile-card mb-4">
<div class="card-body">
<h5 class="card-title d-flex align-items-center">
<span class="security-badge">🔑</span>
Passwort ändern
</h5>
<hr>
<form method="POST" action="{{ url_for('auth.change_password') }}">
<div class="mb-3">
<label for="current_password" class="form-label">Aktuelles Passwort</label>
<input type="password" class="form-control" id="current_password" name="current_password" required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">Neues Passwort</label>
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
<div class="password-strength" id="password-strength"></div>
<div class="form-text" id="password-help">Mindestens 8 Zeichen</div>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Neues Passwort bestätigen</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
<div class="invalid-feedback">Passwörter stimmen nicht überein</div>
</div>
<button type="submit" class="btn btn-primary">🔄 Passwort ändern</button>
</form>
</div>
</div>
<!-- 2FA Card -->
<div class="card profile-card mb-4">
<div class="card-body">
<h5 class="card-title d-flex align-items-center">
<span class="security-badge">🔐</span>
Zwei-Faktor-Authentifizierung (2FA)
</h5>
<hr>
{% if user.totp_enabled %}
<div class="d-flex align-items-center mb-3">
<div class="flex-grow-1">
<h6 class="mb-1">Status: <span class="badge bg-success">Aktiv</span></h6>
<p class="text-muted mb-0">Ihr Account ist durch 2FA geschützt</p>
</div>
<div class="text-success" style="font-size: 3rem;"></div>
</div>
<form method="POST" action="{{ url_for('auth.disable_2fa') }}" onsubmit="return confirm('Sind Sie sicher, dass Sie 2FA deaktivieren möchten? Dies verringert die Sicherheit Ihres Accounts.');">
<div class="mb-3">
<label for="password" class="form-label">Passwort zur Bestätigung</label>
<input type="password" class="form-control" id="password" name="password" required placeholder="Ihr aktuelles Passwort">
</div>
<button type="submit" class="btn btn-danger">🚫 2FA deaktivieren</button>
</form>
{% else %}
<div class="d-flex align-items-center mb-3">
<div class="flex-grow-1">
<h6 class="mb-1">Status: <span class="badge bg-warning text-dark">Inaktiv</span></h6>
<p class="text-muted mb-0">Aktivieren Sie 2FA für zusätzliche Sicherheit</p>
</div>
<div class="text-warning" style="font-size: 3rem;">⚠️</div>
</div>
<p class="text-muted">
Mit 2FA wird bei jeder Anmeldung zusätzlich ein Code aus Ihrer Authenticator-App benötigt.
Dies schützt Ihren Account auch bei kompromittiertem Passwort.
</p>
<a href="{{ url_for('auth.setup_2fa') }}" class="btn btn-success">✨ 2FA einrichten</a>
{% endif %}
</div>
</div>
</div>
<script>
// Password strength indicator
document.getElementById('new_password').addEventListener('input', function(e) {
const password = e.target.value;
let strength = 0;
if (password.length >= 8) strength++;
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength++;
if (password.match(/[0-9]/)) strength++;
if (password.match(/[^a-zA-Z0-9]/)) strength++;
const strengthText = ['Sehr schwach', 'Schwach', 'Mittel', 'Stark', 'Sehr stark'];
const strengthClass = ['strength-very-weak', 'strength-weak', 'strength-medium', 'strength-strong', 'strength-very-strong'];
const textClass = ['text-danger', 'text-warning', 'text-warning', 'text-success', 'text-primary'];
const strengthBar = document.getElementById('password-strength');
const helpText = document.getElementById('password-help');
if (password.length > 0) {
strengthBar.className = `password-strength ${strengthClass[strength]}`;
strengthBar.style.display = 'block';
helpText.textContent = `Stärke: ${strengthText[strength]}`;
helpText.className = `form-text ${textClass[strength]}`;
} else {
strengthBar.style.display = 'none';
helpText.textContent = 'Mindestens 8 Zeichen';
helpText.className = 'form-text';
}
});
// Confirm password validation
document.getElementById('confirm_password').addEventListener('input', function(e) {
const password = document.getElementById('new_password').value;
const confirm = e.target.value;
if (confirm.length > 0) {
if (password !== confirm) {
e.target.classList.add('is-invalid');
e.target.setCustomValidity('Passwörter stimmen nicht überein');
} else {
e.target.classList.remove('is-invalid');
e.target.classList.add('is-valid');
e.target.setCustomValidity('');
}
} else {
e.target.classList.remove('is-invalid', 'is-valid');
}
});
// Also check when password field changes
document.getElementById('new_password').addEventListener('input', function(e) {
const confirm = document.getElementById('confirm_password');
if (confirm.value.length > 0) {
confirm.dispatchEvent(new Event('input'));
}
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,365 @@
{% extends "base.html" %}
{% block title %}Resource Historie{% endblock %}
{% block extra_css %}
<style>
/* Resource Info Card */
.resource-info-card {
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Resource Value Display */
.resource-value {
font-size: 1.5rem;
font-weight: 600;
color: #212529;
background-color: #f8f9fa;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
display: inline-block;
font-family: 'Courier New', monospace;
}
/* Status Badge Large */
.status-badge-large {
padding: 0.5rem 1rem;
font-size: 1rem;
border-radius: 0.5rem;
font-weight: 500;
}
/* Timeline Styling */
.timeline {
position: relative;
padding: 20px 0;
}
.timeline::before {
content: '';
position: absolute;
left: 30px;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(to bottom, #e9ecef 0%, #dee2e6 100%);
}
.timeline-item {
position: relative;
padding-left: 80px;
margin-bottom: 40px;
}
.timeline-marker {
position: absolute;
left: 20px;
top: 0;
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
z-index: 1;
}
.timeline-content {
background: #fff;
border-radius: 10px;
padding: 20px;
box-shadow: 0 3px 6px rgba(0,0,0,0.1);
position: relative;
transition: transform 0.2s ease;
}
.timeline-content:hover {
transform: translateY(-2px);
box-shadow: 0 5px 10px rgba(0,0,0,0.15);
}
.timeline-content::before {
content: '';
position: absolute;
left: -10px;
top: 15px;
width: 0;
height: 0;
border-style: solid;
border-width: 10px 10px 10px 0;
border-color: transparent #fff transparent transparent;
}
/* Action Icons */
.action-icon {
width: 35px;
height: 35px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 1rem;
margin-right: 10px;
}
.action-created { background-color: #d4edda; color: #155724; }
.action-allocated { background-color: #cce5ff; color: #004085; }
.action-deallocated { background-color: #d1ecf1; color: #0c5460; }
.action-quarantined { background-color: #fff3cd; color: #856404; }
.action-released { background-color: #d4edda; color: #155724; }
.action-deleted { background-color: #f8d7da; color: #721c24; }
/* Details Box */
.details-box {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 15px;
margin-top: 15px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.info-item {
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #007bff;
}
.info-label {
font-size: 0.875rem;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.info-value {
font-size: 1.1rem;
font-weight: 500;
color: #212529;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-0">Resource Historie</h1>
<p class="text-muted mb-0">Detaillierte Aktivitätshistorie</p>
</div>
<a href="{{ url_for('resources.resources') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Zurück zur Übersicht
</a>
</div>
<!-- Resource Info Card -->
<div class="card resource-info-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">📋 Resource Details</h5>
</div>
<div class="card-body">
<!-- Main Resource Info -->
<div class="text-center mb-4">
<div class="mb-3">
{% if resource.resource_type == 'domain' %}
<span class="display-1">🌐</span>
{% elif resource.resource_type == 'ipv4' %}
<span class="display-1">🖥️</span>
{% else %}
<span class="display-1">📱</span>
{% endif %}
</div>
<div class="resource-value mb-3">{{ resource.resource_value }}</div>
<div>
{% if resource.status == 'available' %}
<span class="status-badge-large badge bg-success">
✅ Verfügbar
</span>
{% elif resource.status == 'allocated' %}
<span class="status-badge-large badge bg-primary">
🔗 Zugeteilt
</span>
{% else %}
<span class="status-badge-large badge bg-warning text-dark">
⚠️ Quarantäne
</span>
{% endif %}
</div>
</div>
<!-- Detailed Info Grid -->
<div class="info-grid">
<div class="info-item">
<div class="info-label">Ressourcentyp</div>
<div class="info-value">{{ resource.resource_type|upper }}</div>
</div>
<div class="info-item">
<div class="info-label">Erstellt am</div>
<div class="info-value">
{{ resource.created_at.strftime('%d.%m.%Y %H:%M') if resource.created_at else '-' }}
</div>
</div>
<div class="info-item">
<div class="info-label">Status geändert</div>
<div class="info-value">
{{ resource.status_changed_at.strftime('%d.%m.%Y %H:%M') if resource.status_changed_at else '-' }}
</div>
</div>
{% if resource.allocated_to_license %}
<div class="info-item">
<div class="info-label">Zugewiesen an Lizenz</div>
<div class="info-value">
<a href="{{ url_for('licenses.edit_license', license_id=resource.allocated_to_license) }}"
class="text-decoration-none">
{{ license_info.license_key if license_info else 'ID: ' + resource.allocated_to_license|string }}
</a>
</div>
</div>
{% endif %}
{% if resource.quarantine_reason %}
<div class="info-item">
<div class="info-label">Quarantäne-Grund</div>
<div class="info-value">
<span class="badge bg-warning text-dark">{{ resource.quarantine_reason }}</span>
</div>
</div>
{% endif %}
{% if resource.quarantine_until %}
<div class="info-item">
<div class="info-label">Quarantäne bis</div>
<div class="info-value">
{{ resource.quarantine_until.strftime('%d.%m.%Y') }}
</div>
</div>
{% endif %}
</div>
{% if resource.notes %}
<div class="mt-4">
<div class="alert alert-info mb-0">
<h6 class="alert-heading">📝 Notizen</h6>
<p class="mb-0">{{ resource.notes }}</p>
</div>
</div>
{% endif %}
</div>
</div>
<!-- History Timeline -->
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0">⏱️ Aktivitäts-Historie</h5>
</div>
<div class="card-body">
{% if history %}
<div class="timeline">
{% for event in history %}
<div class="timeline-item">
<div class="timeline-marker
{% if event.action == 'created' %}bg-success
{% elif event.action == 'allocated' %}bg-primary
{% elif event.action == 'deallocated' %}bg-info
{% elif event.action == 'quarantined' %}bg-warning
{% elif event.action == 'released' %}bg-success
{% elif event.action == 'deleted' %}bg-danger
{% else %}bg-secondary{% endif %}">
</div>
<div class="timeline-content">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center mb-2">
{% if event.action == 'created' %}
<span class="action-icon action-created">
<i class="fas fa-plus"></i>
</span>
<h6 class="mb-0">Ressource erstellt</h6>
{% elif event.action == 'allocated' %}
<span class="action-icon action-allocated">
<i class="fas fa-link"></i>
</span>
<h6 class="mb-0">An Lizenz zugeteilt</h6>
{% elif event.action == 'deallocated' %}
<span class="action-icon action-deallocated">
<i class="fas fa-unlink"></i>
</span>
<h6 class="mb-0">Von Lizenz freigegeben</h6>
{% elif event.action == 'quarantined' %}
<span class="action-icon action-quarantined">
<i class="fas fa-ban"></i>
</span>
<h6 class="mb-0">In Quarantäne gesetzt</h6>
{% elif event.action == 'released' %}
<span class="action-icon action-released">
<i class="fas fa-check"></i>
</span>
<h6 class="mb-0">Aus Quarantäne entlassen</h6>
{% elif event.action == 'deleted' %}
<span class="action-icon action-deleted">
<i class="fas fa-trash"></i>
</span>
<h6 class="mb-0">Ressource gelöscht</h6>
{% else %}
<h6 class="mb-0">{{ event.action }}</h6>
{% endif %}
</div>
<div class="text-muted small">
<i class="fas fa-user"></i> {{ event.action_by }}
{% if event.ip_address %}
&nbsp;&bull;&nbsp; <i class="fas fa-globe"></i> {{ event.ip_address }}
{% endif %}
{% if event.license_id %}
&nbsp;&bull;&nbsp;
<i class="fas fa-key"></i>
<a href="{{ url_for('licenses.edit_license', license_id=event.license_id) }}">
Lizenz #{{ event.license_id }}
</a>
{% endif %}
</div>
{% if event.details %}
<div class="details-box">
<strong>Details:</strong>
<pre class="mb-0" style="white-space: pre-wrap;">{{ event.details|tojson(indent=2) }}</pre>
</div>
{% endif %}
</div>
<div class="text-end ms-3">
<div class="badge bg-light text-dark">
<i class="far fa-calendar"></i> {{ event.action_at.strftime('%d.%m.%Y') }}
</div>
<div class="small text-muted mt-1">
<i class="far fa-clock"></i> {{ event.action_at.strftime('%H:%M:%S') }}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-history text-muted" style="font-size: 3rem; opacity: 0.5;"></i>
<p class="text-muted mt-3">Keine Historie-Einträge vorhanden.</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,559 @@
{% extends "base.html" %}
{% block title %}Resource Metriken{% endblock %}
{% block extra_css %}
<style>
/* Metric Cards */
.metric-card {
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.2s ease;
height: 100%;
}
.metric-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
}
.metric-card .card-body {
text-align: center;
padding: 2rem;
}
.metric-value {
font-size: 3rem;
font-weight: bold;
line-height: 1;
margin: 0.5rem 0;
}
.metric-label {
font-size: 1rem;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
}
.metric-sublabel {
font-size: 0.875rem;
color: #6c757d;
}
/* Chart Cards */
.chart-card {
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
height: 100%;
}
.chart-card .card-header {
background-color: #f8f9fa;
border-bottom: 2px solid #e9ecef;
font-weight: 600;
}
/* Performance Table */
.performance-table {
font-size: 0.875rem;
}
.performance-table td {
vertical-align: middle;
padding: 0.75rem;
}
.performance-table .resource-link {
color: #007bff;
text-decoration: none;
transition: color 0.2s ease;
}
.performance-table .resource-link:hover {
color: #0056b3;
}
/* Progress Bars */
.progress-custom {
height: 22px;
border-radius: 11px;
font-size: 0.75rem;
font-weight: 600;
}
/* Status Badges */
.status-badge {
padding: 0.35rem 0.65rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
/* Icon Badges */
.icon-badge {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 1.5rem;
margin-bottom: 1rem;
}
.icon-badge.blue { background-color: #e7f3ff; color: #0066cc; }
.icon-badge.green { background-color: #e8f5e9; color: #2e7d32; }
.icon-badge.orange { background-color: #fff3e0; color: #ef6c00; }
.icon-badge.red { background-color: #ffebee; color: #c62828; }
/* Trend Indicator */
.trend-indicator {
display: inline-flex;
align-items: center;
font-size: 0.875rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.trend-up {
background-color: #e8f5e9;
color: #2e7d32;
}
.trend-down {
background-color: #ffebee;
color: #c62828;
}
.trend-neutral {
background-color: #f5f5f5;
color: #616161;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-0">Performance Dashboard</h1>
<p class="text-muted mb-0">Resource Pool Metriken und Analysen</p>
</div>
<div>
<a href="{{ url_for('resources.resource_report') }}" class="btn btn-info">
📄 Report generieren
</a>
<a href="{{ url_for('resources.resources') }}" class="btn btn-secondary">
← Zurück
</a>
</div>
</div>
<!-- Key Metrics -->
<div class="row g-4 mb-4">
<div class="col-lg-3 col-md-6">
<div class="card metric-card">
<div class="card-body">
<div class="icon-badge blue">
📊
</div>
<div class="metric-label">Ressourcen gesamt</div>
<div class="metric-value text-primary">{{ stats.total_resources or 0 }}</div>
<div class="metric-sublabel">Aktive Ressourcen</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card metric-card">
<div class="card-body">
<div class="icon-badge green">
📈
</div>
<div class="metric-label">Ø Performance</div>
<div class="metric-value text-{{ 'success' if stats.avg_performance > 80 else ('warning' if stats.avg_performance > 60 else 'danger') }}">
{{ "%.1f"|format(stats.avg_performance or 0) }}%
</div>
<div class="metric-sublabel">Letzte 30 Tage</div>
{% if stats.performance_trend %}
<div class="mt-2">
<span class="trend-indicator trend-{{ stats.performance_trend }}">
{% if stats.performance_trend == 'up' %}
<i class="fas fa-arrow-up me-1"></i> Steigend
{% elif stats.performance_trend == 'down' %}
<i class="fas fa-arrow-down me-1"></i> Fallend
{% else %}
<i class="fas fa-minus me-1"></i> Stabil
{% endif %}
</span>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card metric-card">
<div class="card-body">
<div class="icon-badge orange">
💰
</div>
<div class="metric-label">ROI</div>
<div class="metric-value text-{{ 'success' if stats.roi > 1 else 'danger' }}">
{{ "%.2f"|format(stats.roi) }}x
</div>
<div class="metric-sublabel">Revenue / Cost</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card metric-card">
<div class="card-body">
<div class="icon-badge red">
⚠️
</div>
<div class="metric-label">Probleme</div>
<div class="metric-value text-{{ 'danger' if stats.total_issues > 10 else ('warning' if stats.total_issues > 5 else 'success') }}">
{{ stats.total_issues or 0 }}
</div>
<div class="metric-sublabel">Letzte 30 Tage</div>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card chart-card">
<div class="card-header">
<h5 class="mb-0">📊 Performance nach Ressourcentyp</h5>
</div>
<div class="card-body">
<canvas id="performanceByTypeChart"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card chart-card">
<div class="card-header">
<h5 class="mb-0">🎯 Auslastung nach Typ</h5>
</div>
<div class="card-body">
<canvas id="utilizationChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Performance Tables -->
<div class="row g-4">
<div class="col-md-6">
<div class="card chart-card">
<div class="card-header bg-success text-white">
<h5 class="mb-0">🏆 Top Performer</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table performance-table mb-0">
<thead class="table-light">
<tr>
<th>Ressource</th>
<th width="80">Typ</th>
<th width="140">Score</th>
<th width="80" class="text-center">ROI</th>
</tr>
</thead>
<tbody>
{% for resource in top_performers %}
<tr>
<td>
<div class="d-flex align-items-center">
<code class="me-2">{{ resource.resource_value }}</code>
<a href="{{ url_for('resources.resource_history', resource_id=resource.id) }}"
class="resource-link" title="Historie anzeigen">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
</td>
<td>
<span class="badge bg-light text-dark">
{% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %}
{{ resource.resource_type|upper }}
</span>
</td>
<td>
<div class="progress progress-custom">
<div class="progress-bar bg-success"
style="width: {{ resource.avg_score }}%">
{{ "%.1f"|format(resource.avg_score) }}%
</div>
</div>
</td>
<td class="text-center">
<span class="badge bg-success">
{{ "%.2f"|format(resource.roi) }}x
</span>
</td>
</tr>
{% endfor %}
{% if not top_performers %}
<tr>
<td colspan="4" class="text-center text-muted py-4">
Keine Performance-Daten verfügbar
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card chart-card">
<div class="card-header bg-danger text-white">
<h5 class="mb-0">⚠️ Problematische Ressourcen</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table performance-table mb-0">
<thead class="table-light">
<tr>
<th>Ressource</th>
<th width="80">Typ</th>
<th width="100" class="text-center">Probleme</th>
<th width="120">Status</th>
</tr>
</thead>
<tbody>
{% for resource in problem_resources %}
<tr>
<td>
<div class="d-flex align-items-center">
<code class="me-2">{{ resource.resource_value }}</code>
<a href="{{ url_for('resources.resource_history', resource_id=resource.id) }}"
class="resource-link" title="Historie anzeigen">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
</td>
<td>
<span class="badge bg-light text-dark">
{% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %}
{{ resource.resource_type|upper }}
</span>
</td>
<td class="text-center">
<span class="badge bg-danger">
{{ resource.total_issues }}
</span>
</td>
<td>
{% if resource.status == 'quarantine' %}
<span class="status-badge bg-warning text-dark">
⚠️ Quarantäne
</span>
{% elif resource.status == 'allocated' %}
<span class="status-badge bg-primary text-white">
🔗 Zugeteilt
</span>
{% else %}
<span class="status-badge bg-success text-white">
✅ Verfügbar
</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not problem_resources %}
<tr>
<td colspan="4" class="text-center text-muted py-4">
Keine problematischen Ressourcen gefunden
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Trend Chart -->
<div class="card chart-card mt-4">
<div class="card-header">
<h5 class="mb-0">📈 30-Tage Performance Trend</h5>
</div>
<div class="card-body">
<canvas id="trendChart" height="100"></canvas>
</div>
</div>
</div>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
<script>
// Chart defaults
Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
// Performance by Type Chart
const performanceCtx = document.getElementById('performanceByTypeChart').getContext('2d');
new Chart(performanceCtx, {
type: 'bar',
data: {
labels: {{ performance_by_type|map(attribute=0)|list|tojson }},
datasets: [{
label: 'Durchschnittliche Performance',
data: {{ performance_by_type|map(attribute=1)|list|tojson }},
backgroundColor: [
'rgba(33, 150, 243, 0.8)',
'rgba(156, 39, 176, 0.8)',
'rgba(76, 175, 80, 0.8)'
],
borderColor: [
'rgb(33, 150, 243)',
'rgb(156, 39, 176)',
'rgb(76, 175, 80)'
],
borderWidth: 2,
borderRadius: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: function(value) {
return value + '%';
}
}
}
}
}
});
// Utilization Chart
const utilizationCtx = document.getElementById('utilizationChart').getContext('2d');
new Chart(utilizationCtx, {
type: 'doughnut',
data: {
labels: {{ utilization_data|map(attribute='type')|list|tojson }},
datasets: [{
data: {{ utilization_data|map(attribute='allocated_percent')|list|tojson }},
backgroundColor: [
'rgba(33, 150, 243, 0.8)',
'rgba(156, 39, 176, 0.8)',
'rgba(76, 175, 80, 0.8)'
],
borderColor: '#fff',
borderWidth: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true
}
},
tooltip: {
callbacks: {
label: function(context) {
return context.label + ': ' + context.parsed + '% ausgelastet';
}
}
}
}
}
});
// Trend Chart
const trendCtx = document.getElementById('trendChart').getContext('2d');
new Chart(trendCtx, {
type: 'line',
data: {
labels: {{ daily_metrics|map(attribute='date')|list|tojson }},
datasets: [{
label: 'Performance Score',
data: {{ daily_metrics|map(attribute='performance')|list|tojson }},
borderColor: 'rgb(76, 175, 80)',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
tension: 0.4,
borderWidth: 3,
pointRadius: 4,
pointHoverRadius: 6,
yAxisID: 'y',
}, {
label: 'Probleme',
data: {{ daily_metrics|map(attribute='issues')|list|tojson }},
borderColor: 'rgb(244, 67, 54)',
backgroundColor: 'rgba(244, 67, 54, 0.1)',
tension: 0.4,
borderWidth: 3,
pointRadius: 4,
pointHoverRadius: 6,
yAxisID: 'y1',
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'top',
labels: {
padding: 20,
usePointStyle: true
}
}
},
scales: {
x: {
grid: {
display: false
}
},
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Performance %'
},
beginAtZero: true,
max: 100,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Anzahl Probleme'
},
beginAtZero: true,
grid: {
drawOnChartArea: false,
}
}
}
}
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,212 @@
{% extends "base.html" %}
{% block title %}Resource Report Generator{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Resource Report Generator</h1>
<a href="{{ url_for('resources.resources') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Zurück
</a>
</div>
<div class="row">
<div class="col-md-8 mx-auto">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Report-Einstellungen</h5>
</div>
<div class="card-body">
<form method="get" action="{{ url_for('resources.resource_report') }}">
<div class="row g-3">
<div class="col-md-6">
<label for="report_type" class="form-label">Report-Typ</label>
<select name="type" id="report_type" class="form-select" required>
<option value="usage">Auslastungsreport</option>
<option value="performance">Performance-Report</option>
<option value="compliance">Compliance-Report</option>
<option value="inventory">Bestands-Report</option>
</select>
</div>
<div class="col-md-6">
<label for="format" class="form-label">Export-Format</label>
<select name="format" id="format" class="form-select" required>
<option value="excel">Excel (.xlsx)</option>
<option value="csv">CSV (.csv)</option>
<option value="pdf">PDF (Vorschau)</option>
</select>
</div>
<div class="col-md-6">
<label for="date_from" class="form-label">Von</label>
<input type="date" name="from" id="date_from" class="form-control"
value="{{ (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d') }}" required>
</div>
<div class="col-md-6">
<label for="date_to" class="form-label">Bis</label>
<input type="date" name="to" id="date_to" class="form-control"
value="{{ datetime.now().strftime('%Y-%m-%d') }}" required>
</div>
</div>
<div class="mt-4">
<h6>Report-Beschreibungen:</h6>
<div id="report_descriptions">
<div class="alert alert-info report-desc" data-type="usage">
<h6><i class="fas fa-chart-line"></i> Auslastungsreport</h6>
<p class="mb-0">Zeigt die Nutzung aller Ressourcen im gewählten Zeitraum.
Enthält Allokations-Historie, durchschnittliche Auslastung und Trends.</p>
</div>
<div class="alert alert-warning report-desc" data-type="performance" style="display: none;">
<h6><i class="fas fa-tachometer-alt"></i> Performance-Report</h6>
<p class="mb-0">Analysiert die Performance-Metriken aller Ressourcen.
Enthält ROI-Berechnungen, Issue-Tracking und Performance-Scores.</p>
</div>
<div class="alert alert-success report-desc" data-type="compliance" style="display: none;">
<h6><i class="fas fa-shield-alt"></i> Compliance-Report</h6>
<p class="mb-0">Überprüft Compliance-Aspekte wie Quarantäne-Gründe,
Sicherheitsvorfälle und Policy-Verletzungen.</p>
</div>
<div class="alert alert-primary report-desc" data-type="inventory" style="display: none;">
<h6><i class="fas fa-boxes"></i> Bestands-Report</h6>
<p class="mb-0">Aktueller Bestand aller Ressourcen mit Status-Übersicht,
Verfügbarkeit und Zuordnungen.</p>
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-outline-secondary" onclick="previewReport()">
<i class="fas fa-eye"></i> Vorschau
</button>
<button type="submit" class="btn btn-primary" name="download" value="true">
<i class="fas fa-download"></i> Report generieren
</button>
</div>
</form>
</div>
</div>
<!-- Letzte Reports -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">Letzte generierte Reports</h5>
</div>
<div class="card-body">
<div class="list-group">
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">Auslastungsreport_2025-06-01.xlsx</h6>
<small>vor 5 Tagen</small>
</div>
<p class="mb-1">Zeitraum: 01.05.2025 - 01.06.2025</p>
<small>Generiert von: {{ username }}</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Vorschau Modal -->
<div class="modal fade" id="previewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Report-Vorschau</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="previewContent">
<p class="text-center">
<i class="fas fa-spinner fa-spin"></i> Lade Vorschau...
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
</div>
</div>
</div>
</div>
<script>
// Report-Typ Beschreibungen
document.getElementById('report_type').addEventListener('change', function() {
const selectedType = this.value;
document.querySelectorAll('.report-desc').forEach(desc => {
desc.style.display = desc.dataset.type === selectedType ? 'block' : 'none';
});
});
// Datum-Validierung
document.getElementById('date_from').addEventListener('change', function() {
document.getElementById('date_to').min = this.value;
});
document.getElementById('date_to').addEventListener('change', function() {
document.getElementById('date_from').max = this.value;
});
// Report-Vorschau
function previewReport() {
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
const content = document.getElementById('previewContent');
// Simuliere Lade-Vorgang
content.innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i> Generiere Vorschau...</p>';
modal.show();
setTimeout(() => {
// Beispiel-Vorschau
content.innerHTML = `
<h5>Report-Vorschau</h5>
<table class="table table-sm">
<thead>
<tr>
<th>Ressourcentyp</th>
<th>Gesamt</th>
<th>Verfügbar</th>
<th>Zugeteilt</th>
<th>Auslastung</th>
</tr>
</thead>
<tbody>
<tr>
<td>Domain</td>
<td>150</td>
<td>45</td>
<td>100</td>
<td>66.7%</td>
</tr>
<tr>
<td>IPv4</td>
<td>100</td>
<td>20</td>
<td>75</td>
<td>75.0%</td>
</tr>
<tr>
<td>Phone</td>
<td>50</td>
<td>15</td>
<td>30</td>
<td>60.0%</td>
</tr>
</tbody>
</table>
<p class="text-muted">Dies ist eine vereinfachte Vorschau. Der vollständige Report enthält weitere Details.</p>
`;
}, 1000);
}
// Initialisierung
document.addEventListener('DOMContentLoaded', function() {
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 30);
document.getElementById('date_from').value = thirtyDaysAgo.toISOString().split('T')[0];
document.getElementById('date_to').value = today.toISOString().split('T')[0];
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,896 @@
{% extends "base.html" %}
{% block title %}Resource Pool{% endblock %}
{% block extra_css %}
<style>
/* Statistik-Karten Design wie Dashboard */
.stat-card {
transition: all 0.3s ease;
cursor: pointer;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
height: 100%;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.stat-card .card-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
opacity: 0.8;
}
.stat-card .card-value {
font-size: 2.5rem;
font-weight: bold;
margin: 0.5rem 0;
}
.stat-card .card-label {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
}
/* Resource Type Icons */
.resource-icon {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 1.2rem;
}
.resource-icon.domain {
background-color: #e3f2fd;
color: #1976d2;
}
.resource-icon.ipv4 {
background-color: #f3e5f5;
color: #7b1fa2;
}
.resource-icon.phone {
background-color: #e8f5e9;
color: #388e3c;
}
/* Status Badges */
.status-badge {
padding: 0.35rem 0.65rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.status-available {
background-color: #d4edda;
color: #155724;
}
.status-allocated {
background-color: #d1ecf1;
color: #0c5460;
}
.status-quarantine {
background-color: #fff3cd;
color: #856404;
}
/* Progress Bar Custom */
.progress-custom {
height: 25px;
background-color: #f0f0f0;
border-radius: 10px;
}
.progress-bar-custom {
font-size: 0.75rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
/* Table Styling */
.table-custom {
border: none;
}
.table-custom thead th {
background-color: #f8f9fa;
border-bottom: 2px solid #dee2e6;
font-weight: 600;
text-transform: uppercase;
font-size: 0.875rem;
color: #495057;
padding: 1rem;
}
.table-custom tbody tr {
transition: all 0.2s ease;
}
.table-custom tbody tr:hover {
background-color: #f8f9fa;
}
.table-custom td {
padding: 1rem;
vertical-align: middle;
}
/* Action Buttons */
.btn-action {
width: 35px;
height: 35px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
margin: 0 2px;
transition: all 0.2s ease;
}
.btn-action:hover {
transform: scale(1.1);
}
/* Copy Button */
.copy-btn {
background: none;
border: none;
color: #6c757d;
padding: 0.25rem 0.5rem;
transition: color 0.2s ease;
}
.copy-btn:hover {
color: #28a745;
}
.copy-btn.copied {
color: #28a745;
}
/* Filter Card */
.filter-card {
background-color: #f8f9fa;
border: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem;
color: #6c757d;
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
/* Dropdown Verbesserungen */
.dropdown-menu {
min-width: 220px;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
border: none;
}
.dropdown-item {
padding: 0.5rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
transition: all 0.2s ease;
}
.dropdown-item:hover {
background-color: #f8f9fa;
transform: translateX(2px);
}
.dropdown-item i {
width: 20px;
text-align: center;
}
/* Quick Action Buttons */
.btn-success {
font-weight: 500;
}
/* Sortable Headers */
thead th a {
display: block;
position: relative;
padding-right: 20px;
}
thead th a:hover {
color: #0d6efd !important;
}
thead th a i {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
font-size: 0.8rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Header -->
<div class="mb-4">
<h1 class="mb-0">Ressourcen Pool</h1>
<p class="text-muted mb-0">Verwalten Sie Domains, IPs und Telefonnummern</p>
</div>
<!-- Test/Live Mode Toggle -->
<div class="card mb-3">
<div class="card-body py-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="showTestResources"
{% if show_fake %}checked{% endif %}
onchange="toggleTestResources()">
<label class="form-check-label" for="showTestResources">
Fake-Ressourcen anzeigen
</label>
</div>
</div>
</div>
<!-- Statistik-Karten -->
<div class="row g-4 mb-4">
{% for type, data in stats.items() %}
<div class="col-md-4">
<div class="card stat-card">
<div class="card-body text-center">
<div class="card-icon">
{% if type == 'domain' %}
🌐
{% elif type == 'ipv4' %}
🖥️
{% else %}
📱
{% endif %}
</div>
<h5 class="text-muted mb-2">{{ type|upper }}</h5>
<div class="card-value text-primary">{{ data.available }}</div>
<div class="card-label text-muted mb-3">von {{ data.total }} verfügbar</div>
<div class="progress progress-custom">
<div class="progress-bar bg-success progress-bar-custom"
style="width: {{ data.available_percent }}%"
data-bs-toggle="tooltip"
title="{{ data.available }} verfügbar">
{{ data.available_percent }}%
</div>
<div class="progress-bar bg-info progress-bar-custom"
style="width: {{ (data.allocated / data.total * 100) if data.total > 0 else 0 }}%"
data-bs-toggle="tooltip"
title="{{ data.allocated }} zugeteilt">
{% if data.allocated > 0 %}{{ data.allocated }}{% endif %}
</div>
<div class="progress-bar bg-warning progress-bar-custom"
style="width: {{ (data.quarantined / data.total * 100) if data.total > 0 else 0 }}%"
data-bs-toggle="tooltip"
title="{{ data.quarantined }} in Quarantäne">
{% if data.quarantined > 0 %}{{ data.quarantined }}{% endif %}
</div>
</div>
<div class="mt-3">
{% if data.available_percent < 20 %}
<span class="badge bg-danger">⚠️ Niedriger Bestand</span>
{% elif data.available_percent < 50 %}
<span class="badge bg-warning text-dark">⚡ Bestand prüfen</span>
{% else %}
<span class="badge bg-success">✅ Gut gefüllt</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Filter -->
<div class="card filter-card mb-4">
<div class="card-body">
<form method="get" action="{{ url_for('resources.resources') }}" id="filterForm">
<input type="hidden" name="show_fake" value="{{ 'true' if show_fake else 'false' }}">
<div class="row g-3">
<div class="col-md-3">
<label for="type" class="form-label">🏷️ Typ</label>
<select name="type" id="type" class="form-select">
<option value="">Alle Typen</option>
<option value="domain" {% if resource_type == 'domain' %}selected{% endif %}>🌐 Domain</option>
<option value="ipv4" {% if resource_type == 'ipv4' %}selected{% endif %}>🖥️ IPv4</option>
<option value="phone" {% if resource_type == 'phone' %}selected{% endif %}>📱 Telefon</option>
</select>
</div>
<div class="col-md-3">
<label for="status" class="form-label">📊 Status</label>
<select name="status" id="status" class="form-select">
<option value="">Alle Status</option>
<option value="available" {% if status_filter == 'available' %}selected{% endif %}>✅ Verfügbar</option>
<option value="allocated" {% if status_filter == 'allocated' %}selected{% endif %}>🔗 Zugeteilt</option>
<option value="quarantine" {% if status_filter == 'quarantine' %}selected{% endif %}>⚠️ Quarantäne</option>
</select>
</div>
<div class="col-md-4">
<label for="search" class="form-label">🔍 Suche</label>
<input type="text" name="search" id="search" class="form-control"
placeholder="Ressource suchen..." value="{{ search }}">
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<a href="{{ url_for('resources.resources', show_fake=show_fake) }}" class="btn btn-secondary w-100">
🔄 Zurücksetzen
</a>
</div>
</div>
</form>
</div>
</div>
<!-- Ressourcen-Tabelle -->
<div class="card">
<div class="card-header bg-white">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">📋 Ressourcen-Liste</h5>
<div class="d-flex align-items-center gap-2">
<div class="dropdown">
<button class="btn btn-sm btn-secondary dropdown-toggle"
type="button"
id="exportDropdown"
data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-download"></i> Export
</button>
<ul class="dropdown-menu" aria-labelledby="exportDropdown">
<li><a class="dropdown-item" href="{{ url_for('resources.resources_report', format='excel', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}">
<i class="bi bi-file-earmark-excel text-success"></i> Excel Export</a></li>
</ul>
</div>
<span class="badge bg-secondary">{{ total }} Einträge</span>
</div>
</div>
</div>
<div class="card-body p-0">
{% if resources %}
<div class="table-responsive">
<table class="table table-custom mb-0">
<thead>
<tr>
<th width="80">
<a href="{{ url_for('resources.resources', sort='id', order='desc' if sort_by == 'id' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
class="text-decoration-none text-dark sort-link">
ID
{% if sort_by == 'id' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th width="120">
<a href="{{ url_for('resources.resources', sort='type', order='desc' if sort_by == 'type' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
class="text-decoration-none text-dark sort-link">
Typ
{% if sort_by == 'type' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th>
<a href="{{ url_for('resources.resources', sort='resource', order='desc' if sort_by == 'resource' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
class="text-decoration-none text-dark sort-link">
Ressource
{% if sort_by == 'resource' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th width="140">
<a href="{{ url_for('resources.resources', sort='status', order='desc' if sort_by == 'status' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
class="text-decoration-none text-dark sort-link">
Status
{% if sort_by == 'status' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th>
<a href="{{ url_for('resources.resources', sort='assigned', order='desc' if sort_by == 'assigned' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
class="text-decoration-none text-dark sort-link">
Zugewiesen an
{% if sort_by == 'assigned' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th width="180">
<a href="{{ url_for('resources.resources', sort='changed', order='desc' if sort_by == 'changed' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
class="text-decoration-none text-dark sort-link">
Letzte Änderung
{% if sort_by == 'changed' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th width="200" class="text-center">Aktionen</th>
</tr>
</thead>
<tbody>
{% for resource in resources %}
<tr>
<td>
<span class="text-muted">#{{ resource.id }}</span>
</td>
<td>
<div class="resource-icon {{ resource.resource_type }}">
{% if resource.resource_type == 'domain' %}
🌐
{% elif resource.resource_type == 'ipv4' %}
🖥️
{% else %}
📱
{% endif %}
</div>
</td>
<td>
<div class="d-flex align-items-center">
<code class="me-2">{{ resource.resource_value }}</code>
<button class="copy-btn" onclick="copyToClipboard('{{ resource.resource_value }}', this)"
title="Kopieren">
<i class="bi bi-clipboard"></i>
</button>
</div>
</td>
<td>
{% if resource.status == 'available' %}
<span class="status-badge status-available">
✅ Verfügbar
</span>
{% elif resource.status == 'allocated' %}
<span class="status-badge status-allocated">
🔗 Zugeteilt
</span>
{% else %}
<span class="status-badge status-quarantine">
⚠️ Quarantäne
</span>
{% if resource.status_changed_by %}
<div class="small text-muted mt-1">{{ resource.status_changed_by }}</div>
{% endif %}
{% endif %}
</td>
<td>
{% if resource.customer_name %}
<div>
<a href="{{ url_for('customers.customers_licenses', show_fake=show_fake) }}"
class="text-decoration-none">
<strong>{{ resource.customer_name }}</strong>
</a>
</div>
<div class="small text-muted">
<a href="{{ url_for('licenses.edit_license', license_id=resource.allocated_to_license) }}?ref=resources{{ '&show_fake=true' if show_fake else '' }}"
class="text-decoration-none text-muted">
{{ resource.allocated_to_license }}
</a>
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if resource.status_changed_at %}
<div class="small">
<div>{{ resource.status_changed_at.strftime('%d.%m.%Y') }}</div>
<div class="text-muted">{{ resource.status_changed_at.strftime('%H:%M Uhr') }}</div>
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="text-center">
{% if resource.status == 'quarantine' %}
<!-- Quick Action für Quarantäne -->
<form method="post" action="{{ url_for('resources.release', show_fake=show_fake, type=resource_type, status=status_filter, search=search) }}"
style="display: inline-block; margin-right: 5px;">
<input type="hidden" name="resource_ids" value="{{ resource.id }}">
<input type="hidden" name="show_fake" value="{{ show_fake }}">
<button type="submit"
class="btn btn-sm btn-success">
<i class="bi bi-check-circle"></i> Freigeben
</button>
</form>
{% endif %}
<!-- Dropdown für weitere Aktionen -->
<div class="dropdown" style="display: inline-block;">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle"
type="button"
id="dropdownMenuButton{{ resource.id }}"
data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-three-dots-vertical"></i> Aktionen
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton{{ resource.id }}">
<!-- Historie immer verfügbar -->
<li>
<a class="dropdown-item"
href="{{ url_for('resources.resource_history', resource_id=resource.id) }}">
<i class="bi bi-clock-history text-info"></i> Historie anzeigen
</a>
</li>
<li><hr class="dropdown-divider"></li>
{% if resource.status == 'available' %}
<!-- Aktionen für verfügbare Ressourcen -->
<li>
<button class="dropdown-item"
onclick="showQuarantineModal({{ resource.id }})">
<i class="bi bi-exclamation-triangle text-warning"></i> In Quarantäne setzen
</button>
</li>
{% elif resource.status == 'allocated' %}
<!-- Aktionen für zugeteilte Ressourcen -->
{% if resource.allocated_to_license %}
<li>
<a class="dropdown-item"
href="{{ url_for('licenses.edit_license', license_id=resource.allocated_to_license) }}?ref=resources{{ '&show_fake=true' if show_fake else '' }}">
<i class="bi bi-file-text text-primary"></i> Lizenz bearbeiten
</a>
</li>
{% endif %}
{% if resource.id %}
<li>
<a class="dropdown-item"
href="{{ url_for('customers.customers_licenses', customer_id=resource.id, show_fake=show_fake) }}">
<i class="bi bi-person text-primary"></i> Kunde anzeigen
</a>
</li>
{% endif %}
{% elif resource.status == 'quarantine' %}
<!-- Aktionen für Quarantäne-Ressourcen -->
<li>
<form method="post" action="{{ url_for('resources.release', show_fake=show_fake, type=resource_type, status=status_filter, search=search) }}"
style="display: contents;">
<input type="hidden" name="resource_ids" value="{{ resource.id }}">
<input type="hidden" name="show_fake" value="{{ show_fake }}">
<button type="submit" class="dropdown-item">
<i class="bi bi-check-circle text-success"></i> Ressource freigeben
</button>
</form>
</li>
{% if resource[9] %}
<li>
<button class="dropdown-item"
onclick="extendQuarantine({{ resource.id }})">
<i class="bi bi-calendar-plus text-warning"></i> Quarantäne verlängern
</button>
</li>
{% endif %}
{% endif %}
<li><hr class="dropdown-divider"></li>
<!-- Kopieren immer verfügbar -->
<li>
<button class="dropdown-item"
onclick="copyToClipboard('{{ resource.resource_value }}', this)">
<i class="bi bi-clipboard text-secondary"></i> Ressource kopieren
</button>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-inbox"></i>
<h4>Keine Ressourcen gefunden</h4>
<p>Ändern Sie Ihre Filterkriterien oder fügen Sie neue Ressourcen hinzu.</p>
</div>
{% endif %}
</div>
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('resources.resources', page=1, type=resource_type, status=status_filter, search=search, show_fake=show_fake, sort=sort_by, order=sort_order) }}">
<i class="bi bi-chevron-double-left"></i> Erste
</a>
</li>
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('resources.resources', page=page-1, type=resource_type, status=status_filter, search=search, show_fake=show_fake, sort=sort_by, order=sort_order) }}">
<i class="bi bi-chevron-left"></i> Zurück
</a>
</li>
{% for p in range(1, total_pages + 1) %}
{% if p == page or (p >= page - 2 and p <= page + 2) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link"
href="{{ url_for('resources.resources', page=p, type=resource_type, status=status_filter, search=search, show_fake=show_fake, sort=sort_by, order=sort_order) }}">
{{ p }}
</a>
</li>
{% endif %}
{% endfor %}
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('resources.resources', page=page+1, type=resource_type, status=status_filter, search=search, show_fake=show_fake, sort=sort_by, order=sort_order) }}">
Weiter <i class="bi bi-chevron-right"></i>
</a>
</li>
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('resources.resources', page=total_pages, type=resource_type, status=status_filter, search=search, show_fake=show_fake, sort=sort_by, order=sort_order) }}">
Letzte <i class="bi bi-chevron-double-right"></i>
</a>
</li>
</ul>
</nav>
{% endif %}
<!-- Kürzliche Aktivitäten -->
{% if recent_activities %}
<div class="card mt-4">
<div class="card-header bg-white">
<h5 class="mb-0">⏰ Kürzliche Aktivitäten</h5>
</div>
<div class="card-body">
<div class="timeline">
{% for activity in recent_activities %}
<div class="d-flex mb-3">
<div class="me-3">
{% if activity[0] == 'created' %}
<span class="badge bg-success rounded-pill"></span>
{% elif activity[0] == 'allocated' %}
<span class="badge bg-info rounded-pill">🔗</span>
{% elif activity[0] == 'deallocated' %}
<span class="badge bg-secondary rounded-pill">🔓</span>
{% elif activity[0] == 'quarantined' %}
<span class="badge bg-warning rounded-pill">⚠️</span>
{% else %}
<span class="badge bg-primary rounded-pill"></span>
{% endif %}
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between">
<div>
<strong>{{ activity[4] }}</strong> ({{ activity[3] }}) - {{ activity[0] }}
{% if activity[1] %}
<span class="text-muted">von {{ activity[1] }}</span>
{% endif %}
</div>
<small class="text-muted">
{{ activity[2].strftime('%d.%m.%Y %H:%M') if activity[2] else '' }}
</small>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
<!-- Quarantäne Modal -->
<div class="modal fade" id="quarantineModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" id="quarantineForm">
<!-- Filter-Parameter als Hidden Fields -->
<input type="hidden" name="show_fake" value="{{ show_fake }}">
<input type="hidden" name="type" value="{{ resource_type }}">
<input type="hidden" name="status" value="{{ status_filter }}">
<input type="hidden" name="search" value="{{ search }}">
<div class="modal-header">
<h5 class="modal-title">⚠️ Ressource in Quarantäne setzen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="reason" class="form-label">Grund</label>
<select name="reason" id="reason" class="form-select" required>
<option value="">Bitte wählen...</option>
<option value="review">🔍 Überprüfung</option>
<option value="abuse">⚠️ Missbrauch</option>
<option value="defect">❌ Defekt</option>
<option value="maintenance">🔧 Wartung</option>
<option value="blacklisted">🚫 Blacklisted</option>
<option value="expired">⏰ Abgelaufen</option>
</select>
</div>
<div class="mb-3">
<label for="until_date" class="form-label">Bis wann? (optional)</label>
<input type="date" name="until_date" id="until_date" class="form-control"
min="{{ datetime.now().strftime('%Y-%m-%d') }}">
</div>
<div class="mb-3">
<label for="notes" class="form-label">Notizen</label>
<textarea name="notes" id="notes" class="form-control" rows="3"
placeholder="Zusätzliche Informationen..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Abbrechen
</button>
<button type="submit" class="btn btn-warning">
⚠️ In Quarantäne setzen
</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Debug Bootstrap
console.log('Bootstrap loaded:', typeof bootstrap !== 'undefined');
if (typeof bootstrap !== 'undefined') {
console.log('Bootstrap version:', bootstrap.Dropdown.VERSION);
}
// Scroll-Position speichern und wiederherstellen
(function() {
// Speichere Scroll-Position vor dem Verlassen der Seite
window.addEventListener('beforeunload', function() {
sessionStorage.setItem('resourcesScrollPos', window.scrollY);
});
// Stelle Scroll-Position wieder her
const scrollPos = sessionStorage.getItem('resourcesScrollPos');
if (scrollPos) {
window.scrollTo(0, parseInt(scrollPos));
sessionStorage.removeItem('resourcesScrollPos');
}
})();
// Live-Filtering
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('filterForm');
const inputs = form.querySelectorAll('select, input[type="text"]');
inputs.forEach(input => {
if (input.type === 'text') {
let timeout;
input.addEventListener('input', function() {
clearTimeout(timeout);
timeout = setTimeout(() => form.submit(), 300);
});
} else {
input.addEventListener('change', () => form.submit());
}
});
// Bootstrap Tooltips initialisieren
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
// Bootstrap Dropdowns explizit initialisieren
var dropdownElementList = [].slice.call(document.querySelectorAll('.dropdown-toggle'))
var dropdownList = dropdownElementList.map(function (dropdownToggleEl) {
return new bootstrap.Dropdown(dropdownToggleEl)
});
// Export Dropdown manuell aktivieren falls nötig
const exportBtn = document.getElementById('exportDropdown');
if (exportBtn) {
exportBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const dropdown = bootstrap.Dropdown.getOrCreateInstance(this);
dropdown.toggle();
});
}
// Sort-Links: Scroll-Position speichern
document.querySelectorAll('.sort-link').forEach(link => {
link.addEventListener('click', function(e) {
sessionStorage.setItem('resourcesScrollPos', window.scrollY);
});
});
});
// Copy to Clipboard mit besserem Feedback
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(() => {
// Original Text speichern
const originalText = button.innerHTML;
button.innerHTML = '<i class="bi bi-check"></i> Kopiert!';
button.classList.add('copied');
// Nach 2 Sekunden zurücksetzen
setTimeout(() => {
button.innerHTML = originalText;
button.classList.remove('copied');
}, 2000);
});
}
// Quarantäne Modal
function showQuarantineModal(resourceId) {
const modalElement = document.getElementById('quarantineModal');
const modal = new bootstrap.Modal(modalElement);
const form = modalElement.querySelector('form');
// URL mit aktuellen Filtern
const currentUrl = new URL(window.location);
const params = new URLSearchParams(currentUrl.search);
form.setAttribute('action', `/resources/quarantine/${resourceId}?${params.toString()}`);
modal.show();
}
// Toggle Testressourcen
function toggleTestResources() {
const showTest = document.getElementById('showTestResources').checked;
const currentUrl = new URL(window.location);
currentUrl.searchParams.set('show_fake', showTest);
window.location.href = currentUrl.toString();
}
// Quarantäne verlängern
function extendQuarantine(resourceId) {
if (confirm('Möchten Sie die Quarantäne für diese Ressource verlängern?')) {
// TODO: Implementiere Modal für Quarantäne-Verlängerung
alert('Diese Funktion wird noch implementiert.');
}
}
// Fallback für Dropdowns
document.addEventListener('click', function(e) {
// Prüfe ob ein Dropdown-Toggle geklickt wurde
if (e.target.closest('.dropdown-toggle')) {
const button = e.target.closest('.dropdown-toggle');
const dropdown = button.nextElementSibling;
if (dropdown && dropdown.classList.contains('dropdown-menu')) {
// Toggle show class
dropdown.classList.toggle('show');
button.setAttribute('aria-expanded', dropdown.classList.contains('show'));
// Schließe andere Dropdowns
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
if (menu !== dropdown) {
menu.classList.remove('show');
menu.previousElementSibling.setAttribute('aria-expanded', 'false');
}
});
}
} else {
// Schließe alle Dropdowns wenn außerhalb geklickt
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
menu.classList.remove('show');
menu.previousElementSibling.setAttribute('aria-expanded', 'false');
});
}
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}Session-Tracking{% endblock %}
{% macro active_sortable_header(label, field, current_sort, current_order) %}
<th>
{% if current_sort == field %}
<a href="{{ url_for('sessions.sessions', active_sort=field, active_order='desc' if current_order == 'asc' else 'asc', ended_sort=ended_sort, ended_order=ended_order) }}"
class="server-sortable">
{% else %}
<a href="{{ url_for('sessions.sessions', active_sort=field, active_order='asc', ended_sort=ended_sort, ended_order=ended_order) }}"
class="server-sortable">
{% endif %}
{{ label }}
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
{% if current_sort == field %}
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
{% else %}
{% endif %}
</span>
</a>
</th>
{% endmacro %}
{% macro ended_sortable_header(label, field, current_sort, current_order) %}
<th>
{% if current_sort == field %}
<a href="{{ url_for('sessions.sessions', active_sort=active_sort, active_order=active_order, ended_sort=field, ended_order='desc' if current_order == 'asc' else 'asc') }}"
class="server-sortable">
{% else %}
<a href="{{ url_for('sessions.sessions', active_sort=active_sort, active_order=active_order, ended_sort=field, ended_order='asc') }}"
class="server-sortable">
{% endif %}
{{ label }}
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
{% if current_sort == field %}
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
{% else %}
{% endif %}
</span>
</a>
</th>
{% endmacro %}
{% block extra_css %}
<style>
.session-active { background-color: #d4edda; }
.session-warning { background-color: #fff3cd; }
.session-inactive { background-color: #f8f9fa; }
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Session-Tracking</h2>
<!-- Export Buttons -->
<div>
<span class="text-muted me-2">Export:</span>
<div class="btn-group" role="group">
<a href="{{ url_for('export.export_sessions', active_only='true', format='excel') }}" class="btn btn-success btn-sm">
<i class="bi bi-file-earmark-excel"></i> Aktive (Excel)
</a>
<a href="{{ url_for('export.export_sessions', active_only='true', format='csv') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-file-earmark-text"></i> Aktive (CSV)
</a>
<a href="{{ url_for('export.export_sessions', format='excel') }}" class="btn btn-success btn-sm">
<i class="bi bi-file-earmark-excel"></i> Alle (Excel)
</a>
<a href="{{ url_for('export.export_sessions', format='csv') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-file-earmark-text"></i> Alle (CSV)
</a>
</div>
</div>
</div>
<!-- Aktive Sessions -->
<div class="card mb-4">
<div class="card-header bg-success text-white">
<h5 class="mb-0">🟢 Aktive Sessions ({{ active_sessions|length }})</h5>
</div>
<div class="card-body">
{% if active_sessions %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Kunde</th>
<th>Lizenz</th>
<th>IP-Adresse</th>
<th>Gestartet</th>
<th>Letzter Heartbeat</th>
<th>Inaktiv seit</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for session in active_sessions %}
<tr class="{% if session[8] > 5 %}session-warning{% else %}session-active{% endif %}">
<td>{{ session[3] }}</td>
<td><small><code>{{ session[2][:12] }}...</code></small></td>
<td>{{ session[4] or '-' }}</td>
<td>{{ session[6].strftime('%d.%m %H:%M') }}</td>
<td>{{ session[7].strftime('%d.%m %H:%M') }}</td>
<td>
{% if session[8] < 1 %}
<span class="badge bg-success">Aktiv</span>
{% elif session[8] < 5 %}
<span class="badge bg-warning">{{ session[8]|round|int }} Min.</span>
{% else %}
<span class="badge bg-danger">{{ session[8]|round|int }} Min.</span>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('sessions.end_session', session_id=session[0]) }}" style="display: inline;">
<button type="submit" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Session wirklich beenden?');">
⏹️ Beenden
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<small class="text-muted">
Sessions gelten als inaktiv nach 5 Minuten ohne Heartbeat
</small>
{% else %}
<p class="text-muted mb-0">Keine aktiven Sessions vorhanden.</p>
{% endif %}
</div>
</div>
<!-- Beendete Sessions -->
<div class="card">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0">⏸️ Beendete Sessions (letzte 24 Stunden)</h5>
</div>
<div class="card-body">
{% if recent_sessions %}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Kunde</th>
<th>Lizenz</th>
<th>IP-Adresse</th>
<th>Gestartet</th>
<th>Beendet</th>
<th>Dauer</th>
</tr>
</thead>
<tbody>
{% for session in recent_sessions %}
<tr class="session-inactive">
<td>{{ session[3] }}</td>
<td><small><code>{{ session[2][:12] }}...</code></small></td>
<td>{{ session[4] or '-' }}</td>
<td>{{ session[5].strftime('%d.%m %H:%M') }}</td>
<td>{{ session[6].strftime('%d.%m %H:%M') }}</td>
<td>
{% if session[7] < 60 %}
{{ session[7]|round|int }} Min.
{% else %}
{{ (session[7]/60)|round(1) }} Std.
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Keine beendeten Sessions in den letzten 24 Stunden.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,210 @@
{% extends "base.html" %}
{% block title %}2FA Einrichten{% endblock %}
{% block extra_css %}
<style>
.setup-card {
transition: all 0.3s ease;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.setup-card:hover {
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 35px;
height: 35px;
background-color: #0d6efd;
color: white;
border-radius: 50%;
font-weight: bold;
margin-right: 10px;
}
.app-icon {
width: 40px;
height: 40px;
object-fit: contain;
margin-right: 10px;
}
.qr-container {
background: white;
padding: 20px;
border-radius: 10px;
display: inline-block;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.secret-code {
font-family: monospace;
font-size: 1.2rem;
letter-spacing: 2px;
background-color: #f8f9fa;
padding: 10px 15px;
border-radius: 5px;
word-break: break-all;
}
.code-input {
font-size: 2rem;
letter-spacing: 0.5rem;
text-align: center;
font-family: monospace;
font-weight: bold;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>🔐 2FA einrichten</h1>
<a href="{{ url_for('auth.profile') }}" class="btn btn-secondary">← Zurück zum Profil</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Step 1: Install App -->
<div class="card setup-card mb-4">
<div class="card-body">
<h5 class="card-title">
<span class="step-number">1</span>
Authenticator-App installieren
</h5>
<p class="ms-5">Wählen Sie eine der folgenden Apps für Ihr Smartphone:</p>
<div class="row ms-4">
<div class="col-md-4 mb-2">
<div class="d-flex align-items-center">
<span style="font-size: 2rem; margin-right: 10px;">📱</span>
<div>
<strong>Google Authenticator</strong><br>
<small class="text-muted">Android / iOS</small>
</div>
</div>
</div>
<div class="col-md-4 mb-2">
<div class="d-flex align-items-center">
<span style="font-size: 2rem; margin-right: 10px;">🔷</span>
<div>
<strong>Microsoft Authenticator</strong><br>
<small class="text-muted">Android / iOS</small>
</div>
</div>
</div>
<div class="col-md-4 mb-2">
<div class="d-flex align-items-center">
<span style="font-size: 2rem; margin-right: 10px;">🔴</span>
<div>
<strong>Authy</strong><br>
<small class="text-muted">Android / iOS / Desktop</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Step 2: Scan QR Code -->
<div class="card setup-card mb-4">
<div class="card-body">
<h5 class="card-title">
<span class="step-number">2</span>
QR-Code scannen oder Code eingeben
</h5>
<div class="row mt-4">
<div class="col-md-6 text-center mb-4">
<p class="fw-bold">Option A: QR-Code scannen</p>
<div class="qr-container">
<img src="data:image/png;base64,{{ qr_code }}" alt="2FA QR Code" style="max-width: 250px;">
</div>
<p class="text-muted mt-2">
<small>Öffnen Sie Ihre Authenticator-App und scannen Sie diesen Code</small>
</p>
</div>
<div class="col-md-6 mb-4">
<p class="fw-bold">Option B: Code manuell eingeben</p>
<div class="mb-3">
<label class="text-muted small">Account-Name:</label>
<div class="alert alert-light py-2">
<strong>V2 Admin Panel</strong>
</div>
</div>
<div class="mb-3">
<label class="text-muted small">Geheimer Schlüssel:</label>
<div class="secret-code">{{ totp_secret }}</div>
<button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="copySecret()">
📋 Schlüssel kopieren
</button>
</div>
<div class="alert alert-warning">
<small>
<strong>⚠️ Wichtiger Hinweis:</strong><br>
Speichern Sie diesen Code sicher. Er ist Ihre einzige Möglichkeit,
2FA auf einem neuen Gerät einzurichten.
</small>
</div>
</div>
</div>
</div>
</div>
<!-- Step 3: Verify -->
<div class="card setup-card mb-4">
<div class="card-body">
<h5 class="card-title">
<span class="step-number">3</span>
Code verifizieren
</h5>
<p class="ms-5">Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein:</p>
<form method="POST" action="{{ url_for('auth.enable_2fa') }}" class="ms-5 me-5">
<div class="row align-items-center">
<div class="col-md-6 mb-3">
<input type="text"
class="form-control code-input"
id="token"
name="token"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
autocomplete="off"
autofocus
required>
<div class="form-text text-center">Der Code ändert sich alle 30 Sekunden</div>
</div>
<div class="col-md-6 mb-3">
<button type="submit" class="btn btn-success btn-lg w-100">
✅ 2FA aktivieren
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
function copySecret() {
const secret = '{{ totp_secret }}';
navigator.clipboard.writeText(secret).then(function() {
alert('Code wurde in die Zwischenablage kopiert!');
});
}
// Auto-format the code input
document.getElementById('token').addEventListener('input', function(e) {
// Remove non-digits
e.target.value = e.target.value.replace(/\D/g, '');
});
</script>
{% endblock %}

Datei anzeigen

@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2FA Verifizierung - Admin Panel</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
background: white;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.logo {
text-align: center;
font-size: 3rem;
margin-bottom: 1rem;
}
.code-input {
text-align: center;
font-size: 1.5rem;
letter-spacing: 0.5rem;
font-family: monospace;
}
</style>
</head>
<body>
<div class="login-container">
<div class="logo">🔐</div>
<h2 class="text-center mb-4">Zwei-Faktor-Authentifizierung</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
<div class="mb-3">
<label for="token" class="form-label">Authentifizierungscode eingeben</label>
<input type="text"
class="form-control code-input"
id="token"
name="token"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
autocomplete="off"
autofocus
required>
<div class="form-text text-center mt-2">
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">Verifizieren</button>
</div>
<div class="text-center mt-3">
<details>
<summary class="text-muted" style="cursor: pointer;">Backup-Code verwenden</summary>
<div class="mt-2">
<p class="small text-muted">
Falls Sie keinen Zugriff auf Ihre Authenticator-App haben,
können Sie einen 8-stelligen Backup-Code eingeben.
</p>
<input type="text"
class="form-control code-input mt-2"
id="backup-token"
placeholder="ABCD1234"
maxlength="8"
pattern="[A-Z0-9]{8}"
style="letter-spacing: 0.25rem;">
</div>
</details>
</div>
<hr class="my-4">
<div class="text-center">
<a href="{{ url_for('auth.logout') }}" class="text-muted">Abbrechen und zur Anmeldung zurück</a>
</div>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Auto-format the code input
document.getElementById('token').addEventListener('input', function(e) {
// Remove non-digits
e.target.value = e.target.value.replace(/\D/g, '');
});
// Handle backup code input
document.getElementById('backup-token').addEventListener('input', function(e) {
// Convert to uppercase and remove non-alphanumeric
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
// If backup code is being used, copy to main token field
if (e.target.value.length > 0) {
document.getElementById('token').value = e.target.value;
document.getElementById('token').removeAttribute('pattern');
document.getElementById('token').setAttribute('maxlength', '8');
}
});
// Reset main field when typing in it
document.getElementById('token').addEventListener('focus', function(e) {
document.getElementById('backup-token').value = '';
e.target.setAttribute('pattern', '[0-9]{6}');
e.target.setAttribute('maxlength', '6');
});
</script>
</body>
</html>

Datei anzeigen

@@ -0,0 +1 @@
# Utils module initialization

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

37
v2_adminpanel/utils/audit.py Normale Datei
Datei anzeigen

@@ -0,0 +1,37 @@
import logging
from flask import session, request
from psycopg2.extras import Json
from db import get_db_connection, get_db_cursor
from utils.network import get_client_ip
logger = logging.getLogger(__name__)
def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None):
"""Log changes to the audit log"""
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
try:
username = session.get('username', 'system')
ip_address = get_client_ip() if request else None
user_agent = request.headers.get('User-Agent') if request else None
# Debug logging
logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}")
# Convert dictionaries to JSONB
old_json = Json(old_values) if old_values else None
new_json = Json(new_values) if new_values else None
cur.execute("""
INSERT INTO audit_log
(username, action, entity_type, entity_id, old_values, new_values,
ip_address, user_agent, additional_info)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (username, action, entity_type, entity_id, old_json, new_json,
ip_address, user_agent, Json(additional_info) if isinstance(additional_info, dict) else additional_info))
conn.commit()
except Exception as e:
logger.error(f"Audit log error: {e}")
conn.rollback()

370
v2_adminpanel/utils/backup.py Normale Datei
Datei anzeigen

@@ -0,0 +1,370 @@
import os
import time
import gzip
import logging
import subprocess
from pathlib import Path
from datetime import datetime
from zoneinfo import ZoneInfo
from cryptography.fernet import Fernet
from db import get_db_connection, get_db_cursor
from config import BACKUP_DIR, DATABASE_CONFIG, EMAIL_ENABLED, BACKUP_ENCRYPTION_KEY
from utils.audit import log_audit
from utils.github_backup import GitHubBackupManager, create_server_backup as create_server_backup_impl
logger = logging.getLogger(__name__)
def get_or_create_encryption_key():
"""Get or create an encryption key"""
key_file = BACKUP_DIR / ".backup_key"
# Try to read key from environment variable
if BACKUP_ENCRYPTION_KEY:
try:
# Validate the key
Fernet(BACKUP_ENCRYPTION_KEY.encode())
return BACKUP_ENCRYPTION_KEY.encode()
except:
pass
# If no valid key in ENV, check file
if key_file.exists():
return key_file.read_bytes()
# Create new key
key = Fernet.generate_key()
key_file.write_bytes(key)
logger.info("New backup encryption key created")
return key
def create_backup(backup_type="manual", created_by=None):
"""Create an encrypted database backup"""
start_time = time.time()
timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")
filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc"
filepath = BACKUP_DIR / filename
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
# Create backup entry
cur.execute("""
INSERT INTO backup_history
(filename, filepath, backup_type, status, created_by, is_encrypted)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (filename, str(filepath), backup_type, 'in_progress',
created_by or 'system', True))
backup_id = cur.fetchone()[0]
conn.commit()
try:
# PostgreSQL dump command
dump_command = [
'pg_dump',
'-h', DATABASE_CONFIG['host'],
'-p', DATABASE_CONFIG['port'],
'-U', DATABASE_CONFIG['user'],
'-d', DATABASE_CONFIG['dbname'],
'--no-password',
'--verbose'
]
# Set PGPASSWORD
env = os.environ.copy()
env['PGPASSWORD'] = DATABASE_CONFIG['password']
# Execute dump
result = subprocess.run(dump_command, capture_output=True, text=True, env=env)
if result.returncode != 0:
raise Exception(f"pg_dump failed: {result.stderr}")
dump_data = result.stdout.encode('utf-8')
# Compress
compressed_data = gzip.compress(dump_data)
# Encrypt
key = get_or_create_encryption_key()
f = Fernet(key)
encrypted_data = f.encrypt(compressed_data)
# Save
filepath.write_bytes(encrypted_data)
# Collect statistics
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'")
tables_count = cur.fetchone()[0]
cur.execute("SELECT SUM(n_live_tup) FROM pg_stat_user_tables")
records_count = cur.fetchone()[0] or 0
duration = time.time() - start_time
filesize = filepath.stat().st_size
# Update backup entry
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
UPDATE backup_history
SET status = %s, filesize = %s, tables_count = %s,
records_count = %s, duration_seconds = %s
WHERE id = %s
""", ('success', filesize, tables_count, records_count, duration, backup_id))
conn.commit()
# Audit log
log_audit('BACKUP', 'database', backup_id,
additional_info=f"Backup created: {filename} ({filesize} bytes)")
# Email notification (if configured)
send_backup_notification(True, filename, filesize, duration)
logger.info(f"Backup successfully created: {filename}")
return True, filename
except Exception as e:
# Log error
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
UPDATE backup_history
SET status = %s, error_message = %s, duration_seconds = %s
WHERE id = %s
""", ('failed', str(e), time.time() - start_time, backup_id))
conn.commit()
logger.error(f"Backup failed: {e}")
send_backup_notification(False, filename, error=str(e))
return False, str(e)
def restore_backup(backup_id, encryption_key=None):
"""Restore a backup"""
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
# Get backup info
cur.execute("""
SELECT filename, filepath, is_encrypted
FROM backup_history
WHERE id = %s
""", (backup_id,))
backup_info = cur.fetchone()
if not backup_info:
raise Exception("Backup not found")
filename, filepath, is_encrypted = backup_info
filepath = Path(filepath)
if not filepath.exists():
raise Exception("Backup file not found")
try:
# Read file
encrypted_data = filepath.read_bytes()
# Decrypt
if is_encrypted:
key = encryption_key.encode() if encryption_key else get_or_create_encryption_key()
try:
f = Fernet(key)
compressed_data = f.decrypt(encrypted_data)
except:
raise Exception("Decryption failed. Wrong password?")
else:
compressed_data = encrypted_data
# Decompress
dump_data = gzip.decompress(compressed_data)
sql_commands = dump_data.decode('utf-8')
# Restore database
restore_command = [
'psql',
'-h', DATABASE_CONFIG['host'],
'-p', DATABASE_CONFIG['port'],
'-U', DATABASE_CONFIG['user'],
'-d', DATABASE_CONFIG['dbname'],
'--no-password'
]
env = os.environ.copy()
env['PGPASSWORD'] = DATABASE_CONFIG['password']
result = subprocess.run(restore_command, input=sql_commands,
capture_output=True, text=True, env=env)
if result.returncode != 0:
raise Exception(f"Restore failed: {result.stderr}")
# Audit log
log_audit('RESTORE', 'database', backup_id,
additional_info=f"Backup restored: {filename}")
return True, "Backup successfully restored"
except Exception as e:
logger.error(f"Restore failed: {e}")
return False, str(e)
def send_backup_notification(success, filename, filesize=None, duration=None, error=None):
"""Send email notification (if configured)"""
if not EMAIL_ENABLED:
return
# Email function prepared but disabled
# TODO: Implement when email server is configured
logger.info(f"Email notification prepared: Backup {'successful' if success else 'failed'}")
def create_backup_with_github(backup_type="manual", created_by=None, push_to_github=True, delete_local=True):
"""Create backup and optionally push to GitHub"""
# Create the backup
success, result = create_backup(backup_type, created_by)
if not success:
return success, result
filename = result
filepath = BACKUP_DIR / filename
if push_to_github:
try:
# Move to database-backups directory
db_backup_dir = Path("/opt/v2-Docker/database-backups")
db_backup_dir.mkdir(exist_ok=True)
target_path = db_backup_dir / filename
filepath.rename(target_path)
# Push to GitHub
github = GitHubBackupManager()
git_success, git_result = github.push_backup(target_path, "database")
if git_success:
logger.info(f"Backup pushed to GitHub: {filename}")
# Delete local file if requested
if delete_local:
target_path.unlink()
logger.info(f"Local backup deleted: {filename}")
# Update database record
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
UPDATE backup_history
SET github_uploaded = TRUE,
local_deleted = %s,
github_path = %s
WHERE filename = %s
""", (delete_local, f"database-backups/{filename}", filename))
conn.commit()
else:
logger.error(f"Failed to push to GitHub: {git_result}")
# Move file back
target_path.rename(filepath)
except Exception as e:
logger.error(f"GitHub upload error: {str(e)}")
return True, f"{filename} (GitHub upload failed: {str(e)})"
return True, filename
def create_server_backup(created_by=None, push_to_github=True, delete_local=True):
"""Create full server backup"""
start_time = time.time()
# Create backup entry
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
INSERT INTO backup_history
(backup_type, status, created_by, is_server_backup)
VALUES (%s, %s, %s, %s)
RETURNING id
""", ('server', 'in_progress', created_by or 'system', True))
backup_id = cur.fetchone()[0]
conn.commit()
try:
# Create server backup
success, result = create_server_backup_impl(created_by)
if not success:
raise Exception(result)
backup_file = result
filename = os.path.basename(backup_file)
filesize = os.path.getsize(backup_file)
# Update backup entry
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
UPDATE backup_history
SET status = %s, filename = %s, filepath = %s,
filesize = %s, duration_seconds = %s
WHERE id = %s
""", ('success', filename, backup_file, filesize,
time.time() - start_time, backup_id))
conn.commit()
if push_to_github:
try:
# Push to GitHub
github = GitHubBackupManager()
git_success, git_result = github.push_backup(backup_file, "server")
if git_success:
logger.info(f"Server backup pushed to GitHub: {filename}")
# Delete local file if requested
if delete_local:
os.unlink(backup_file)
logger.info(f"Local server backup deleted: {filename}")
# Update database record
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
UPDATE backup_history
SET github_uploaded = TRUE,
local_deleted = %s,
github_path = %s
WHERE id = %s
""", (delete_local, f"server-backups/{filename}", backup_id))
conn.commit()
else:
logger.error(f"Failed to push server backup to GitHub: {git_result}")
except Exception as e:
logger.error(f"GitHub upload error for server backup: {str(e)}")
# Audit log
log_audit('BACKUP', 'server', backup_id,
additional_info=f"Server backup created: {filename} ({filesize} bytes)")
return True, filename
except Exception as e:
# Log error
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
UPDATE backup_history
SET status = %s, error_message = %s, duration_seconds = %s
WHERE id = %s
""", ('failed', str(e), time.time() - start_time, backup_id))
conn.commit()
logger.error(f"Server backup failed: {e}")
return False, str(e)

203
v2_adminpanel/utils/export.py Normale Datei
Datei anzeigen

@@ -0,0 +1,203 @@
import pandas as pd
from io import BytesIO, StringIO
from datetime import datetime
from zoneinfo import ZoneInfo
from openpyxl.utils import get_column_letter
from flask import send_file
import csv
def create_excel_export(data, columns, filename_prefix="export"):
"""Create an Excel file from data"""
df = pd.DataFrame(data, columns=columns)
# Create Excel file in memory
output = BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, index=False, sheet_name='Data')
# Auto-adjust column widths
worksheet = writer.sheets['Data']
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)
# Generate filename with timestamp
timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
filename = f"{filename_prefix}_{timestamp}.xlsx"
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
def create_csv_export(data, columns, filename_prefix="export"):
"""Create a CSV file from data"""
# Create CSV in memory
output = StringIO()
writer = csv.writer(output)
# Write header
writer.writerow(columns)
# Write data
writer.writerows(data)
# Convert to bytes
output.seek(0)
output_bytes = BytesIO(output.getvalue().encode('utf-8-sig')) # UTF-8 with BOM for Excel compatibility
# Generate filename with timestamp
timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
filename = f"{filename_prefix}_{timestamp}.csv"
return send_file(
output_bytes,
mimetype='text/csv',
as_attachment=True,
download_name=filename
)
def format_datetime_for_export(dt):
"""Format datetime for export"""
if dt:
if isinstance(dt, str):
try:
dt = datetime.fromisoformat(dt)
except:
return dt
# Remove timezone info for Excel compatibility
if hasattr(dt, 'replace') and dt.tzinfo is not None:
dt = dt.replace(tzinfo=None)
return dt.strftime('%Y-%m-%d %H:%M:%S')
return ''
def prepare_license_export_data(licenses):
"""Prepare license data for export"""
export_data = []
for license in licenses:
export_data.append([
license[0], # ID
license[1], # Key
license[2], # Customer Name
license[3], # Email
'Aktiv' if license[4] else 'Inaktiv', # Active
license[5], # Max Users
format_datetime_for_export(license[6]), # Valid From
format_datetime_for_export(license[7]), # Valid Until
format_datetime_for_export(license[8]), # Created At
license[9], # Device Limit
license[10] or 0, # Current Devices
'Fake' if license[11] else 'Full' # Is Test License
])
return export_data
def prepare_customer_export_data(customers):
"""Prepare customer data for export"""
export_data = []
for customer in customers:
export_data.append([
customer[0], # ID
customer[1], # Name
customer[2], # Email
customer[3], # Company
customer[4], # Address
customer[5], # Phone
format_datetime_for_export(customer[6]), # Created At
customer[7] or 0, # License Count
customer[8] or 0 # Active License Count
])
return export_data
def prepare_session_export_data(sessions):
"""Prepare session data for export"""
export_data = []
for session in sessions:
export_data.append([
session[0], # ID
session[1], # License Key
session[2], # Username
session[3], # Computer Name
session[4], # Hardware ID
format_datetime_for_export(session[5]), # Login Time
format_datetime_for_export(session[6]), # Last Activity
'Aktiv' if session[7] else 'Beendet', # Active
session[8], # IP Address
session[9], # App Version
session[10], # Customer Name
session[11] # Email
])
return export_data
def prepare_audit_export_data(audit_logs):
"""Prepare audit log data for export"""
export_data = []
for log in audit_logs:
export_data.append([
log['id'],
format_datetime_for_export(log['timestamp']),
log['username'],
log['action'],
log['entity_type'],
log['entity_id'] or '',
log['ip_address'] or '',
log['user_agent'] or '',
str(log['old_values']) if log['old_values'] else '',
str(log['new_values']) if log['new_values'] else '',
log['additional_info'] or ''
])
return export_data
def create_batch_export(licenses):
"""Create Excel export for batch licenses"""
export_data = []
for license in licenses:
export_data.append({
'Lizenzschlüssel': license['license_key'],
'Kunde': license.get('customer_name', ''),
'Email': license.get('customer_email', ''),
'Lizenztyp': license.get('license_type', 'full').upper(),
'Geräte-Limit': license.get('device_limit', 3),
'Gültig von': format_datetime_for_export(license.get('valid_from')),
'Gültig bis': format_datetime_for_export(license.get('valid_until')),
'Status': 'Aktiv' if license.get('is_active', True) else 'Inaktiv',
'Fake-Lizenz': 'Ja' if license.get('is_test', False) else 'Nein'
})
df = pd.DataFrame(export_data)
# Create Excel file in memory
output = BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, index=False, sheet_name='Batch-Lizenzen')
# Auto-adjust column widths
worksheet = writer.sheets['Batch-Lizenzen']
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)
# Generate filename with timestamp
timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
filename = f"batch_licenses_{timestamp}.xlsx"
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)

Datei anzeigen

@@ -0,0 +1,181 @@
import os
import subprocess
import logging
from pathlib import Path
from datetime import datetime
import json
logger = logging.getLogger(__name__)
class GitHubBackupManager:
def __init__(self):
self.repo_path = Path("/opt/v2-Docker")
self.backup_remote = "backup"
self.git_lfs_path = "/home/root/.local/bin"
def _run_git_command(self, cmd, cwd=None):
"""Execute git command with proper PATH"""
env = os.environ.copy()
env['PATH'] = f"{self.git_lfs_path}:{env['PATH']}"
if cwd is None:
cwd = self.repo_path
try:
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
cwd=cwd,
env=env
)
if result.returncode != 0:
logger.error(f"Git command failed: {cmd}")
logger.error(f"Error: {result.stderr}")
return False, result.stderr
return True, result.stdout
except Exception as e:
logger.error(f"Git command exception: {str(e)}")
return False, str(e)
def pull_latest(self):
"""Pull latest from backup repository"""
success, output = self._run_git_command(f"git pull {self.backup_remote} main --rebase")
return success, output
def push_backup(self, file_path, backup_type="database"):
"""Push backup file to GitHub"""
try:
# First pull to avoid conflicts
success, output = self.pull_latest()
if not success and "CONFLICT" not in output:
logger.warning(f"Pull failed but continuing: {output}")
# Add file
relative_path = Path(file_path).relative_to(self.repo_path)
success, output = self._run_git_command(f"git add {relative_path}")
if not success:
return False, f"Failed to add file: {output}"
# Commit
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
commit_msg = f"Backup {backup_type} - {timestamp}"
success, output = self._run_git_command(f'git commit -m "{commit_msg}"')
if not success:
return False, f"Failed to commit: {output}"
# Push
success, output = self._run_git_command(f"git push {self.backup_remote} main")
if not success:
# Try force push if normal push fails
success, output = self._run_git_command(f"git push {self.backup_remote} main --force-with-lease")
if not success:
return False, f"Failed to push: {output}"
return True, "Backup successfully pushed to GitHub"
except Exception as e:
logger.error(f"GitHub push error: {str(e)}")
return False, str(e)
def list_github_backups(self, backup_type="all"):
"""List backups from GitHub repository"""
try:
# Fetch latest
self._run_git_command(f"git fetch {self.backup_remote}")
# List files in backup directories
backups = []
if backup_type in ["all", "database"]:
db_path = "database-backups"
success, output = self._run_git_command(f"git ls-tree -r {self.backup_remote}/main --name-only | grep '^{db_path}/'")
if success and output:
for file in output.strip().split('\n'):
if file:
backups.append({
'type': 'database',
'path': file,
'filename': os.path.basename(file)
})
if backup_type in ["all", "server"]:
server_path = "server-backups"
success, output = self._run_git_command(f"git ls-tree -r {self.backup_remote}/main --name-only | grep '^{server_path}/'")
if success and output:
for file in output.strip().split('\n'):
if file:
backups.append({
'type': 'server',
'path': file,
'filename': os.path.basename(file)
})
return True, backups
except Exception as e:
logger.error(f"List GitHub backups error: {str(e)}")
return False, str(e)
def download_from_github(self, file_path, local_path):
"""Download a backup file from GitHub"""
try:
# Fetch latest
self._run_git_command(f"git fetch {self.backup_remote}")
# Get file from remote
success, output = self._run_git_command(
f"git show {self.backup_remote}/main:{file_path} > {local_path}"
)
if not success:
return False, f"Failed to download: {output}"
return True, local_path
except Exception as e:
logger.error(f"Download from GitHub error: {str(e)}")
return False, str(e)
def create_server_backup(created_by="system"):
"""Create a full server backup"""
try:
# Run the backup script
backup_script = Path("/opt/v2-Docker/create_full_backup.sh")
if not backup_script.exists():
return False, "Backup script not found"
env = os.environ.copy()
env['PATH'] = f"/home/root/.local/bin:{env['PATH']}"
result = subprocess.run(
["./create_full_backup.sh"],
cwd="/opt/v2-Docker",
capture_output=True,
text=True,
env=env
)
if result.returncode != 0:
return False, f"Backup script failed: {result.stderr}"
# Extract backup file path from output
output_lines = result.stdout.strip().split('\n')
backup_file = None
for line in output_lines:
if "Backup file:" in line:
backup_file = line.split("Backup file:")[1].strip()
break
if not backup_file:
return False, "Could not determine backup file path"
return True, backup_file
except Exception as e:
logger.error(f"Server backup error: {str(e)}")
return False, str(e)

Datei anzeigen

@@ -0,0 +1,50 @@
import re
import secrets
from datetime import datetime
from zoneinfo import ZoneInfo
def generate_license_key(license_type='full'):
"""
Generate a license key in format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ
AF = Account Factory (Product identifier)
F/T = F for Full version, T for Test version
YYYY = Year
MM = Month
XXXX-YYYY-ZZZZ = Random alphanumeric characters
"""
# Allowed characters (without confusing ones like 0/O, 1/I/l)
chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
# Date part
now = datetime.now(ZoneInfo("Europe/Berlin"))
date_part = now.strftime('%Y%m')
type_char = 'F' if license_type == 'full' else 'T'
# Generate random parts (3 blocks of 4 characters)
parts = []
for _ in range(3):
part = ''.join(secrets.choice(chars) for _ in range(4))
parts.append(part)
# Assemble key
key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}"
return key
def validate_license_key(key):
"""
Validate the License Key Format
Expected: AF-F-YYYYMM-XXXX-YYYY-ZZZZ or AF-T-YYYYMM-XXXX-YYYY-ZZZZ
"""
if not key:
return False
# Pattern for the new format
# AF- (fixed) + F or T + - + 6 digits (YYYYMM) + - + 4 characters + - + 4 characters + - + 4 characters
pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
# Uppercase for comparison
return bool(re.match(pattern, key.upper()))

Datei anzeigen

@@ -0,0 +1,23 @@
import logging
from flask import request
logger = logging.getLogger(__name__)
def get_client_ip():
"""Get the real IP address of the client"""
# Debug logging
logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, "
f"X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, "
f"Remote-Addr: {request.remote_addr}")
# Try X-Real-IP first (set by nginx)
if request.headers.get('X-Real-IP'):
return request.headers.get('X-Real-IP')
# Then X-Forwarded-For
elif request.headers.get('X-Forwarded-For'):
# X-Forwarded-For can contain multiple IPs, take the first one
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
# Fallback to remote_addr
else:
return request.remote_addr

Datei anzeigen

@@ -0,0 +1,114 @@
"""
Helper functions for managing partitioned tables
"""
import psycopg2
from datetime import datetime
from dateutil.relativedelta import relativedelta
import logging
logger = logging.getLogger(__name__)
def ensure_partition_exists(conn, table_name, timestamp):
"""
Ensure a partition exists for the given timestamp.
Creates the partition if it doesn't exist.
Args:
conn: Database connection
table_name: Base table name (e.g., 'license_heartbeats')
timestamp: Timestamp to check partition for
Returns:
bool: True if partition exists or was created, False on error
"""
try:
cursor = conn.cursor()
# Get year and month from timestamp
if isinstance(timestamp, str):
timestamp = datetime.fromisoformat(timestamp)
year = timestamp.year
month = timestamp.month
# Partition name
partition_name = f"{table_name}_{year}_{month:02d}"
# Check if partition exists
cursor.execute("""
SELECT EXISTS (
SELECT 1
FROM pg_tables
WHERE tablename = %s
)
""", (partition_name,))
if cursor.fetchone()[0]:
return True
# Create partition
start_date = f"{year}-{month:02d}-01"
if month == 12:
end_date = f"{year + 1}-01-01"
else:
end_date = f"{year}-{month + 1:02d}-01"
cursor.execute(f"""
CREATE TABLE IF NOT EXISTS {partition_name} PARTITION OF {table_name}
FOR VALUES FROM ('{start_date}') TO ('{end_date}')
""")
conn.commit()
logger.info(f"Created partition {partition_name}")
cursor.close()
return True
except Exception as e:
logger.error(f"Error ensuring partition exists: {e}")
return False
def create_future_partitions(conn, table_name, months_ahead=6):
"""
Create partitions for the next N months
Args:
conn: Database connection
table_name: Base table name
months_ahead: Number of months to create partitions for
"""
current_date = datetime.now()
for i in range(months_ahead + 1):
target_date = current_date + relativedelta(months=i)
ensure_partition_exists(conn, table_name, target_date)
def check_table_exists(conn, table_name):
"""
Check if a table exists in the database
Args:
conn: Database connection
table_name: Table name to check
Returns:
bool: True if table exists, False otherwise
"""
try:
cursor = conn.cursor()
cursor.execute("""
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_name = %s
)
""", (table_name,))
exists = cursor.fetchone()[0]
cursor.close()
return exists
except Exception as e:
logger.error(f"Error checking if table exists: {e}")
return False

Datei anzeigen

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