nur backups
Dieser Commit ist enthalten in:
@@ -1,33 +0,0 @@
|
||||
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"]
|
||||
@@ -1,456 +0,0 @@
|
||||
# Error Handling Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide describes the error handling system implemented in the v2_adminpanel application. The system provides:
|
||||
|
||||
- Centralized error handling with custom exception hierarchy
|
||||
- Input validation framework
|
||||
- Structured logging
|
||||
- Monitoring and alerting
|
||||
- Consistent error responses
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Custom Exception Hierarchy
|
||||
|
||||
```
|
||||
BaseApplicationException
|
||||
├── ValidationException
|
||||
│ ├── InputValidationError
|
||||
│ ├── BusinessRuleViolation
|
||||
│ └── DataIntegrityError
|
||||
├── AuthenticationException
|
||||
│ ├── InvalidCredentialsError
|
||||
│ ├── SessionExpiredError
|
||||
│ └── InsufficientPermissionsError
|
||||
├── DatabaseException
|
||||
│ ├── ConnectionError
|
||||
│ ├── QueryError
|
||||
│ └── TransactionError
|
||||
├── ExternalServiceException
|
||||
│ ├── APIError
|
||||
│ └── TimeoutError
|
||||
└── ResourceException
|
||||
├── ResourceNotFoundError
|
||||
├── ResourceConflictError
|
||||
└── ResourceLimitExceeded
|
||||
```
|
||||
|
||||
### 2. Core Components
|
||||
|
||||
- **core/exceptions.py**: Custom exception classes
|
||||
- **core/error_handlers.py**: Global error handlers and decorators
|
||||
- **core/validators.py**: Input validation framework
|
||||
- **core/logging_config.py**: Structured logging setup
|
||||
- **core/monitoring.py**: Error metrics and alerting
|
||||
- **middleware/error_middleware.py**: Request-level error handling
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 1. Raising Custom Exceptions
|
||||
|
||||
```python
|
||||
from core.exceptions import (
|
||||
InputValidationError,
|
||||
ResourceNotFoundError,
|
||||
BusinessRuleViolation
|
||||
)
|
||||
|
||||
# Validation error
|
||||
if not email_is_valid:
|
||||
raise InputValidationError(
|
||||
field='email',
|
||||
message='Invalid email format',
|
||||
value=email_value,
|
||||
expected_type='email'
|
||||
)
|
||||
|
||||
# Resource not found
|
||||
user = db.get_user(user_id)
|
||||
if not user:
|
||||
raise ResourceNotFoundError(
|
||||
resource_type='User',
|
||||
resource_id=user_id
|
||||
)
|
||||
|
||||
# Business rule violation
|
||||
if active_licenses >= license_limit:
|
||||
raise BusinessRuleViolation(
|
||||
rule='license_limit',
|
||||
message='License limit exceeded',
|
||||
context={
|
||||
'current': active_licenses,
|
||||
'limit': license_limit
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Using Error Decorators
|
||||
|
||||
```python
|
||||
from core.error_handlers import handle_errors, validate_request
|
||||
from core.validators import validate
|
||||
|
||||
@handle_errors(
|
||||
catch=(psycopg2.Error,),
|
||||
message='Database operation failed',
|
||||
user_message='Datenbankfehler aufgetreten',
|
||||
redirect_to='admin.dashboard'
|
||||
)
|
||||
def update_customer(customer_id):
|
||||
# Database operations
|
||||
pass
|
||||
|
||||
@validate_request(
|
||||
required_fields={
|
||||
'email': str,
|
||||
'age': int,
|
||||
'active': bool
|
||||
}
|
||||
)
|
||||
def create_user():
|
||||
# Request data is validated
|
||||
pass
|
||||
|
||||
@validate({
|
||||
'email': {
|
||||
'type': 'email',
|
||||
'required': True
|
||||
},
|
||||
'password': {
|
||||
'type': 'password',
|
||||
'required': True
|
||||
},
|
||||
'age': {
|
||||
'type': 'integer',
|
||||
'required': True,
|
||||
'min_value': 18,
|
||||
'max_value': 120
|
||||
}
|
||||
})
|
||||
def register_user():
|
||||
# Access validated data
|
||||
data = request.validated_data
|
||||
# Use data safely
|
||||
```
|
||||
|
||||
### 3. Input Validation
|
||||
|
||||
```python
|
||||
from core.validators import Validators
|
||||
|
||||
# Email validation
|
||||
email = Validators.email(user_input, field_name='email')
|
||||
|
||||
# Phone validation
|
||||
phone = Validators.phone(user_input, field_name='phone')
|
||||
|
||||
# License key validation
|
||||
license_key = Validators.license_key(user_input)
|
||||
|
||||
# Integer with constraints
|
||||
age = Validators.integer(
|
||||
user_input,
|
||||
field_name='age',
|
||||
min_value=0,
|
||||
max_value=150
|
||||
)
|
||||
|
||||
# String with constraints
|
||||
username = Validators.string(
|
||||
user_input,
|
||||
field_name='username',
|
||||
min_length=3,
|
||||
max_length=50,
|
||||
safe_only=True
|
||||
)
|
||||
|
||||
# Password validation
|
||||
password = Validators.password(user_input)
|
||||
|
||||
# Custom enum validation
|
||||
status = Validators.enum(
|
||||
user_input,
|
||||
field_name='status',
|
||||
allowed_values=['active', 'inactive', 'pending']
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Error Context Manager
|
||||
|
||||
```python
|
||||
from core.error_handlers import ErrorContext
|
||||
|
||||
with ErrorContext(
|
||||
operation='create_license',
|
||||
resource_type='License',
|
||||
resource_id=license_key
|
||||
):
|
||||
# Operations that might fail
|
||||
db.insert_license(license_data)
|
||||
# Errors are automatically logged with context
|
||||
```
|
||||
|
||||
### 5. Logging
|
||||
|
||||
```python
|
||||
from core.logging_config import get_logger, log_error, log_security_event
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Standard logging
|
||||
logger.info('User created', extra={
|
||||
'user_id': user.id,
|
||||
'email': user.email
|
||||
})
|
||||
|
||||
# Error logging
|
||||
try:
|
||||
risky_operation()
|
||||
except Exception as e:
|
||||
log_error(
|
||||
logger,
|
||||
'Risky operation failed',
|
||||
error=e,
|
||||
user_id=user.id,
|
||||
operation='risky_operation'
|
||||
)
|
||||
|
||||
# Security event logging
|
||||
log_security_event(
|
||||
'INVALID_LOGIN_ATTEMPT',
|
||||
'Multiple failed login attempts',
|
||||
username=username,
|
||||
ip_address=request.remote_addr,
|
||||
attempt_count=5
|
||||
)
|
||||
```
|
||||
|
||||
## Error Response Format
|
||||
|
||||
### JSON Responses (API)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Invalid input provided",
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"request_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"details": {
|
||||
"field": "email",
|
||||
"value": "invalid-email",
|
||||
"expected_type": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Responses
|
||||
|
||||
- User-friendly error messages
|
||||
- Error code and request ID for support
|
||||
- Helpful suggestions for resolution
|
||||
- Navigation options (back, dashboard, retry)
|
||||
|
||||
## Monitoring and Alerts
|
||||
|
||||
### Metrics Exposed
|
||||
|
||||
- `app_errors_total`: Total error count by code, status, endpoint
|
||||
- `app_error_rate`: Errors per minute by error code
|
||||
- `app_validation_errors_total`: Validation errors by field
|
||||
- `app_auth_failures_total`: Authentication failures
|
||||
- `app_database_errors_total`: Database errors
|
||||
- `app_request_duration_seconds`: Request duration histogram
|
||||
|
||||
### Alert Thresholds
|
||||
|
||||
- Error rate > 10/min: Critical alert
|
||||
- Auth failure rate > 5/min: Security alert
|
||||
- DB error rate > 3/min: Infrastructure alert
|
||||
- Response time 95th percentile > 2s: Performance alert
|
||||
|
||||
### Accessing Metrics
|
||||
|
||||
- Prometheus metrics: `/metrics`
|
||||
- Active alerts: `/api/alerts`
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use Specific Exceptions
|
||||
|
||||
```python
|
||||
# Bad
|
||||
raise Exception("User not found")
|
||||
|
||||
# Good
|
||||
raise ResourceNotFoundError('User', user_id)
|
||||
```
|
||||
|
||||
### 2. Provide Context
|
||||
|
||||
```python
|
||||
# Bad
|
||||
raise ValidationException("Invalid data")
|
||||
|
||||
# Good
|
||||
raise InputValidationError(
|
||||
field='email',
|
||||
message='Email domain not allowed',
|
||||
value=email,
|
||||
expected_type='corporate_email'
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Handle Database Errors
|
||||
|
||||
```python
|
||||
# Bad
|
||||
result = db.execute(query)
|
||||
|
||||
# Good
|
||||
try:
|
||||
result = db.execute(query)
|
||||
except psycopg2.IntegrityError as e:
|
||||
if e.pgcode == '23505':
|
||||
raise DataIntegrityError(
|
||||
entity='User',
|
||||
constraint='unique_email',
|
||||
message='Email already exists'
|
||||
)
|
||||
raise
|
||||
```
|
||||
|
||||
### 4. Validate Early
|
||||
|
||||
```python
|
||||
# Bad
|
||||
def process_order(data):
|
||||
# Process without validation
|
||||
total = data['quantity'] * data['price']
|
||||
|
||||
# Good
|
||||
@validate({
|
||||
'quantity': {'type': 'integer', 'min_value': 1},
|
||||
'price': {'type': 'float', 'min_value': 0}
|
||||
})
|
||||
def process_order():
|
||||
data = request.validated_data
|
||||
total = data['quantity'] * data['price']
|
||||
```
|
||||
|
||||
### 5. Log Security Events
|
||||
|
||||
```python
|
||||
# Failed login attempts
|
||||
log_security_event(
|
||||
'LOGIN_FAILURE',
|
||||
f'Failed login for user {username}',
|
||||
username=username,
|
||||
ip_address=request.remote_addr
|
||||
)
|
||||
|
||||
# Suspicious activity
|
||||
log_security_event(
|
||||
'SUSPICIOUS_ACTIVITY',
|
||||
'Rapid API requests detected',
|
||||
ip_address=request.remote_addr,
|
||||
request_count=count,
|
||||
time_window=60
|
||||
)
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Converting Existing Error Handling
|
||||
|
||||
1. **Replace generic exceptions**:
|
||||
```python
|
||||
# Old
|
||||
except Exception as e:
|
||||
flash(f"Error: {str(e)}", "error")
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# New
|
||||
except DatabaseException as e:
|
||||
# Already handled by global handler
|
||||
raise
|
||||
```
|
||||
|
||||
2. **Update validation**:
|
||||
```python
|
||||
# Old
|
||||
email = request.form.get('email')
|
||||
if not email or '@' not in email:
|
||||
flash("Invalid email", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
# New
|
||||
from core.validators import Validators
|
||||
try:
|
||||
email = Validators.email(request.form.get('email'))
|
||||
except InputValidationError as e:
|
||||
# Handled automatically
|
||||
raise
|
||||
```
|
||||
|
||||
3. **Use decorators**:
|
||||
```python
|
||||
# Old
|
||||
@app.route('/api/user', methods=['POST'])
|
||||
def create_user():
|
||||
try:
|
||||
# validation code
|
||||
# database operations
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# New
|
||||
@app.route('/api/user', methods=['POST'])
|
||||
@validate({
|
||||
'email': {'type': 'email', 'required': True},
|
||||
'name': {'type': 'string', 'required': True, 'min_length': 2}
|
||||
})
|
||||
def create_user():
|
||||
data = request.validated_data
|
||||
# Use validated data directly
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
pytest v2_adminpanel/tests/test_error_handling.py -v
|
||||
```
|
||||
|
||||
Test coverage includes:
|
||||
- Exception creation and properties
|
||||
- Error handler responses (JSON/HTML)
|
||||
- Validation functions
|
||||
- Decorators
|
||||
- Monitoring and metrics
|
||||
- Alert generation
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Import errors**: Ensure you import from `core` package
|
||||
2. **Validation not working**: Check decorator order (validate must be closest to function)
|
||||
3. **Logs not appearing**: Verify LOG_LEVEL environment variable
|
||||
4. **Metrics missing**: Ensure prometheus_client is installed
|
||||
|
||||
### Debug Mode
|
||||
|
||||
In development, set:
|
||||
```python
|
||||
app.config['DEBUG'] = True
|
||||
```
|
||||
|
||||
This will:
|
||||
- Include detailed error information
|
||||
- Show stack traces
|
||||
- Log to console with readable format
|
||||
Binäre Datei nicht angezeigt.
@@ -1,168 +0,0 @@
|
||||
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)
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1 +0,0 @@
|
||||
# Auth module initialization
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
@@ -1,11 +0,0 @@
|
||||
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'))
|
||||
@@ -1,124 +0,0 @@
|
||||
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}")
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
@@ -1,70 +0,0 @@
|
||||
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'
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
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
|
||||
@@ -1,356 +0,0 @@
|
||||
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
|
||||
@@ -1,190 +0,0 @@
|
||||
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
|
||||
}
|
||||
)
|
||||
@@ -1,246 +0,0 @@
|
||||
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
|
||||
}
|
||||
)
|
||||
@@ -1,435 +0,0 @@
|
||||
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
|
||||
@@ -1,84 +0,0 @@
|
||||
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
|
||||
@@ -1,704 +0,0 @@
|
||||
-- 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();
|
||||
@@ -1,6 +0,0 @@
|
||||
# Lead Management Module
|
||||
from flask import Blueprint
|
||||
|
||||
leads_bp = Blueprint('leads', __name__, template_folder='templates')
|
||||
|
||||
from . import routes
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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
|
||||
@@ -1,359 +0,0 @@
|
||||
# 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
|
||||
@@ -1,397 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,171 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,239 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,622 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,159 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,189 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,367 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1 +0,0 @@
|
||||
from .error_middleware import ErrorHandlingMiddleware
|
||||
@@ -1,54 +0,0 @@
|
||||
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
|
||||
}
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
-- 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 $$;
|
||||
@@ -1,72 +0,0 @@
|
||||
-- Add constraint to ensure licenses always inherit is_fake from their customer
|
||||
-- This migration adds a trigger to automatically sync is_fake status
|
||||
|
||||
-- Function to sync is_fake status
|
||||
CREATE OR REPLACE FUNCTION sync_license_fake_status()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- When inserting or updating a license, get is_fake from customer
|
||||
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND NEW.customer_id != OLD.customer_id) THEN
|
||||
SELECT is_fake INTO NEW.is_fake
|
||||
FROM customers
|
||||
WHERE id = NEW.customer_id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger for licenses table
|
||||
DROP TRIGGER IF EXISTS sync_license_fake_before_insert_update ON licenses;
|
||||
CREATE TRIGGER sync_license_fake_before_insert_update
|
||||
BEFORE INSERT OR UPDATE ON licenses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION sync_license_fake_status();
|
||||
|
||||
-- Function to update licenses when customer is_fake changes
|
||||
CREATE OR REPLACE FUNCTION sync_customer_fake_to_licenses()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- When customer is_fake changes, update all their licenses
|
||||
IF TG_OP = 'UPDATE' AND NEW.is_fake != OLD.is_fake THEN
|
||||
UPDATE licenses
|
||||
SET is_fake = NEW.is_fake
|
||||
WHERE customer_id = NEW.id;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger for customers table
|
||||
DROP TRIGGER IF EXISTS sync_customer_fake_after_update ON customers;
|
||||
CREATE TRIGGER sync_customer_fake_after_update
|
||||
AFTER UPDATE ON customers
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION sync_customer_fake_to_licenses();
|
||||
|
||||
-- Verify current data is consistent (should return 0)
|
||||
DO $$
|
||||
DECLARE
|
||||
mismatch_count INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO mismatch_count
|
||||
FROM licenses l
|
||||
JOIN customers c ON l.customer_id = c.id
|
||||
WHERE l.is_fake != c.is_fake;
|
||||
|
||||
IF mismatch_count > 0 THEN
|
||||
RAISE NOTICE 'Found % mismatches. Fixing...', mismatch_count;
|
||||
|
||||
-- Fix any existing mismatches
|
||||
UPDATE licenses l
|
||||
SET is_fake = c.is_fake
|
||||
FROM customers c
|
||||
WHERE l.customer_id = c.id
|
||||
AND l.is_fake != c.is_fake;
|
||||
|
||||
RAISE NOTICE 'Fixed all mismatches.';
|
||||
ELSE
|
||||
RAISE NOTICE 'No mismatches found. Data is consistent.';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,58 +0,0 @@
|
||||
-- Migration: Add June 2025 partition for license_heartbeats table
|
||||
-- This migration adds the missing partition for the current month (June 2025)
|
||||
|
||||
-- Check if the partition already exists before creating it
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Check if the June 2025 partition exists
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_tables
|
||||
WHERE tablename = 'license_heartbeats_2025_06'
|
||||
) THEN
|
||||
-- Create the June 2025 partition
|
||||
EXECUTE 'CREATE TABLE license_heartbeats_2025_06 PARTITION OF license_heartbeats
|
||||
FOR VALUES FROM (''2025-06-01'') TO (''2025-07-01'')';
|
||||
|
||||
RAISE NOTICE 'Created partition license_heartbeats_2025_06';
|
||||
ELSE
|
||||
RAISE NOTICE 'Partition license_heartbeats_2025_06 already exists';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Also create partitions for the next few months to avoid future issues
|
||||
DO $$
|
||||
DECLARE
|
||||
partition_name text;
|
||||
start_date date;
|
||||
end_date date;
|
||||
i integer;
|
||||
BEGIN
|
||||
-- Create partitions for the next 6 months
|
||||
FOR i IN 0..6 LOOP
|
||||
start_date := date_trunc('month', CURRENT_DATE + (i || ' months')::interval);
|
||||
end_date := start_date + interval '1 month';
|
||||
partition_name := 'license_heartbeats_' || to_char(start_date, 'YYYY_MM');
|
||||
|
||||
-- Check if partition already exists
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_tables
|
||||
WHERE tablename = partition_name
|
||||
) THEN
|
||||
EXECUTE format('CREATE TABLE %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)',
|
||||
partition_name, start_date, end_date);
|
||||
|
||||
RAISE NOTICE 'Created partition %', partition_name;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Verify the partitions were created
|
||||
SELECT
|
||||
schemaname,
|
||||
tablename,
|
||||
tableowner
|
||||
FROM pg_tables
|
||||
WHERE tablename LIKE 'license_heartbeats_%'
|
||||
ORDER BY tablename;
|
||||
@@ -1,17 +0,0 @@
|
||||
-- Cleanup orphaned API-related tables
|
||||
-- Since admin panel is exclusively for Account Forger, we only need system_api_key table
|
||||
|
||||
-- Drop tables that depend on api_clients
|
||||
DROP TABLE IF EXISTS rate_limits CASCADE;
|
||||
DROP TABLE IF EXISTS license_events CASCADE;
|
||||
|
||||
-- Drop orphaned API tables
|
||||
DROP TABLE IF EXISTS api_clients CASCADE;
|
||||
DROP TABLE IF EXISTS api_keys CASCADE;
|
||||
|
||||
-- Add comments to document the single API key system
|
||||
COMMENT ON TABLE system_api_key IS 'Single API key table for Account Forger authentication. This is the ONLY API key system in use.';
|
||||
|
||||
-- Log the cleanup
|
||||
INSERT INTO audit_log (username, action, entity_type, details, ip_address)
|
||||
VALUES ('SYSTEM', 'CLEANUP', 'database', 'Removed orphaned API tables: api_keys, api_clients, rate_limits, license_events', '127.0.0.1');
|
||||
@@ -1,107 +0,0 @@
|
||||
-- Lead Management Tables Migration
|
||||
-- This creates all necessary tables for the lead management system
|
||||
|
||||
-- Enable UUID extension if not already enabled
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- 1. Lead Institutions (only name required)
|
||||
CREATE TABLE IF NOT EXISTS lead_institutions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
-- Metadata for future extensions without schema changes
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(100),
|
||||
UNIQUE(name)
|
||||
);
|
||||
|
||||
-- Index for fast lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_institutions_name ON lead_institutions(name);
|
||||
|
||||
-- 2. Lead Contacts
|
||||
CREATE TABLE IF NOT EXISTS lead_contacts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
institution_id UUID NOT NULL REFERENCES lead_institutions(id) ON DELETE CASCADE,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
position VARCHAR(255),
|
||||
-- Extra fields for future extensions
|
||||
extra_fields JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_contacts_institution ON lead_contacts(institution_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_contacts_name ON lead_contacts(last_name, first_name);
|
||||
|
||||
-- 3. Flexible Contact Details (phones, emails, etc.)
|
||||
CREATE TABLE IF NOT EXISTS lead_contact_details (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contact_id UUID NOT NULL REFERENCES lead_contacts(id) ON DELETE CASCADE,
|
||||
detail_type VARCHAR(50) NOT NULL, -- 'phone', 'email', 'social', etc.
|
||||
detail_value VARCHAR(255) NOT NULL,
|
||||
detail_label VARCHAR(50), -- 'Mobil', 'Geschäftlich', 'Privat', etc.
|
||||
is_primary BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes for fast queries
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_details_contact_type ON lead_contact_details(contact_id, detail_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_details_value ON lead_contact_details(detail_value);
|
||||
|
||||
-- 4. Versioned Notes with History
|
||||
CREATE TABLE IF NOT EXISTS lead_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contact_id UUID NOT NULL REFERENCES lead_contacts(id) ON DELETE CASCADE,
|
||||
note_text TEXT NOT NULL,
|
||||
version INTEGER DEFAULT 1,
|
||||
is_current BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(100),
|
||||
parent_note_id UUID REFERENCES lead_notes(id),
|
||||
CHECK (note_text <> '')
|
||||
);
|
||||
|
||||
-- Indexes for note queries
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_notes_contact_current ON lead_notes(contact_id, is_current);
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_notes_created ON lead_notes(created_at DESC);
|
||||
|
||||
-- Full text search preparation
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_contacts_search ON lead_contacts
|
||||
USING gin(to_tsvector('german',
|
||||
COALESCE(first_name, '') || ' ' ||
|
||||
COALESCE(last_name, '') || ' ' ||
|
||||
COALESCE(position, '')
|
||||
));
|
||||
|
||||
-- Update timestamp trigger function
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply update trigger to tables with updated_at
|
||||
CREATE TRIGGER update_lead_institutions_updated_at
|
||||
BEFORE UPDATE ON lead_institutions
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_lead_contacts_updated_at
|
||||
BEFORE UPDATE ON lead_contacts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Add comments for documentation
|
||||
COMMENT ON TABLE lead_institutions IS 'Organizations/Companies for lead management';
|
||||
COMMENT ON TABLE lead_contacts IS 'Contact persons within institutions';
|
||||
COMMENT ON TABLE lead_contact_details IS 'Flexible contact details (phone, email, etc.)';
|
||||
COMMENT ON TABLE lead_notes IS 'Versioned notes with full history';
|
||||
|
||||
COMMENT ON COLUMN lead_contact_details.detail_type IS 'Type of detail: phone, email, social, etc.';
|
||||
COMMENT ON COLUMN lead_notes.is_current IS 'Only current version is shown, old versions kept for history';
|
||||
COMMENT ON COLUMN lead_notes.parent_note_id IS 'References original note for version tracking';
|
||||
@@ -1,79 +0,0 @@
|
||||
-- Migration: Create license_heartbeats partitioned table
|
||||
-- Date: 2025-06-19
|
||||
-- Description: Creates the license_heartbeats table with monthly partitioning
|
||||
|
||||
-- Create the partitioned table
|
||||
CREATE TABLE IF NOT EXISTS license_heartbeats (
|
||||
id BIGSERIAL,
|
||||
license_id INTEGER NOT NULL,
|
||||
hardware_id VARCHAR(255) NOT NULL,
|
||||
ip_address INET NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
session_data JSONB,
|
||||
PRIMARY KEY (id, timestamp),
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE
|
||||
) PARTITION BY RANGE (timestamp);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_license_heartbeats_license_id_timestamp
|
||||
ON license_heartbeats (license_id, timestamp DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_license_heartbeats_timestamp
|
||||
ON license_heartbeats (timestamp DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_license_heartbeats_hardware_id
|
||||
ON license_heartbeats (hardware_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_license_heartbeats_ip_address
|
||||
ON license_heartbeats (ip_address);
|
||||
|
||||
-- Create partitions for current and next month
|
||||
DO $$
|
||||
DECLARE
|
||||
current_year INTEGER;
|
||||
current_month INTEGER;
|
||||
next_year INTEGER;
|
||||
next_month INTEGER;
|
||||
partition_name TEXT;
|
||||
start_date DATE;
|
||||
end_date DATE;
|
||||
BEGIN
|
||||
-- Get current date info
|
||||
current_year := EXTRACT(YEAR FROM CURRENT_DATE);
|
||||
current_month := EXTRACT(MONTH FROM CURRENT_DATE);
|
||||
|
||||
-- Calculate next month
|
||||
IF current_month = 12 THEN
|
||||
next_year := current_year + 1;
|
||||
next_month := 1;
|
||||
ELSE
|
||||
next_year := current_year;
|
||||
next_month := current_month + 1;
|
||||
END IF;
|
||||
|
||||
-- Create current month partition
|
||||
partition_name := 'license_heartbeats_' || current_year || '_' || LPAD(current_month::TEXT, 2, '0');
|
||||
start_date := DATE_TRUNC('month', CURRENT_DATE);
|
||||
end_date := DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month';
|
||||
|
||||
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)',
|
||||
partition_name, start_date, end_date);
|
||||
|
||||
-- Create next month partition
|
||||
partition_name := 'license_heartbeats_' || next_year || '_' || LPAD(next_month::TEXT, 2, '0');
|
||||
start_date := end_date;
|
||||
end_date := start_date + INTERVAL '1 month';
|
||||
|
||||
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)',
|
||||
partition_name, start_date, end_date);
|
||||
|
||||
RAISE NOTICE 'Created partitions for current and next month';
|
||||
END $$;
|
||||
|
||||
-- Add comment to the table
|
||||
COMMENT ON TABLE license_heartbeats IS 'Stores heartbeat data from license validations for real-time monitoring';
|
||||
COMMENT ON COLUMN license_heartbeats.license_id IS 'Foreign key to licenses table';
|
||||
COMMENT ON COLUMN license_heartbeats.hardware_id IS 'Hardware identifier of the device';
|
||||
COMMENT ON COLUMN license_heartbeats.ip_address IS 'IP address from which the heartbeat was sent';
|
||||
COMMENT ON COLUMN license_heartbeats.timestamp IS 'Timestamp of the heartbeat';
|
||||
COMMENT ON COLUMN license_heartbeats.session_data IS 'Additional session data in JSON format';
|
||||
@@ -1,9 +0,0 @@
|
||||
-- Remove duplicate API key from client_configs table
|
||||
-- Since admin panel is exclusively for Account Forger, we only need system_api_key
|
||||
|
||||
-- Remove the api_key column from client_configs
|
||||
ALTER TABLE client_configs DROP COLUMN IF EXISTS api_key;
|
||||
|
||||
-- Update description
|
||||
COMMENT ON TABLE client_configs IS 'Configuration for Account Forger client (versions, timeouts)';
|
||||
COMMENT ON TABLE system_api_key IS 'Single API key for Account Forger authentication';
|
||||
@@ -1,48 +0,0 @@
|
||||
-- Migration script to rename is_test columns to is_fake
|
||||
-- This separates fake/demo data from test licenses
|
||||
|
||||
-- 1. Rename columns in all tables
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Rename is_test to is_fake in customers table
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'customers' AND column_name = 'is_test') THEN
|
||||
ALTER TABLE customers RENAME COLUMN is_test TO is_fake;
|
||||
END IF;
|
||||
|
||||
-- Rename is_test to is_fake in licenses table
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'is_test') THEN
|
||||
ALTER TABLE licenses RENAME COLUMN is_test TO is_fake;
|
||||
END IF;
|
||||
|
||||
-- Rename is_test to is_fake in resource_pools table
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'resource_pools' AND column_name = 'is_test') THEN
|
||||
ALTER TABLE resource_pools RENAME COLUMN is_test TO is_fake;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 2. Rename indexes
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Rename index for customers
|
||||
IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_customers_is_test') THEN
|
||||
ALTER INDEX idx_customers_is_test RENAME TO idx_customers_is_fake;
|
||||
END IF;
|
||||
|
||||
-- Rename index for licenses
|
||||
IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_licenses_is_test') THEN
|
||||
ALTER INDEX idx_licenses_is_test RENAME TO idx_licenses_is_fake;
|
||||
END IF;
|
||||
|
||||
-- Rename index for resource_pools
|
||||
IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_resource_pools_is_test') THEN
|
||||
ALTER INDEX idx_resource_pools_is_test RENAME TO idx_resource_pools_is_fake;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 3. Add comments to clarify the purpose
|
||||
COMMENT ON COLUMN customers.is_fake IS 'Marks fake/demo data, not to be confused with test licenses';
|
||||
COMMENT ON COLUMN licenses.is_fake IS 'Marks fake/demo data, not to be confused with test license type';
|
||||
COMMENT ON COLUMN resource_pools.is_fake IS 'Marks fake/demo resources';
|
||||
@@ -1,178 +0,0 @@
|
||||
# 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 []
|
||||
@@ -1,17 +0,0 @@
|
||||
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
|
||||
@@ -1,2 +0,0 @@
|
||||
# Routes module initialization
|
||||
# This module contains all Flask blueprints organized by functionality
|
||||
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@@ -1,377 +0,0 @@
|
||||
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')
|
||||
})
|
||||
@@ -1,439 +0,0 @@
|
||||
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")
|
||||
@@ -1,466 +0,0 @@
|
||||
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()
|
||||
@@ -1,495 +0,0 @@
|
||||
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.hardware_id,
|
||||
ad.ip_address,
|
||||
ad.anomaly_type as event_type,
|
||||
ad.severity,
|
||||
ad.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()
|
||||
@@ -1,506 +0,0 @@
|
||||
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)
|
||||
@@ -1,428 +0,0 @@
|
||||
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.ip_address,
|
||||
ad.hardware_id,
|
||||
ad.anomaly_type,
|
||||
ad.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")
|
||||
@@ -1,721 +0,0 @@
|
||||
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)
|
||||
@@ -1,429 +0,0 @@
|
||||
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()
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,20 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,47 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,439 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,391 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,228 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,301 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,705 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,514 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,156 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,98 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,71 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,185 +0,0 @@
|
||||
{% 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
@@ -1,477 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,103 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,88 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,55 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,578 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,445 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,241 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,373 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,151 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,455 +0,0 @@
|
||||
{% 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">×</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 %}
|
||||
@@ -1,125 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,322 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,453 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,698 +0,0 @@
|
||||
{% 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> </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 %}
|
||||
@@ -1,609 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,216 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,365 +0,0 @@
|
||||
{% 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 %}
|
||||
• <i class="fas fa-globe"></i> {{ event.ip_address }}
|
||||
{% endif %}
|
||||
{% if event.license_id %}
|
||||
•
|
||||
<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 %}
|
||||
@@ -1,559 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,212 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,896 +0,0 @@
|
||||
{% 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"> </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 %}
|
||||
@@ -1,183 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,210 +0,0 @@
|
||||
{% 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 %}
|
||||
Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden Mehr anzeigen
In neuem Issue referenzieren
Einen Benutzer sperren