Initial commit
Dieser Commit ist enthalten in:
33
v2_adminpanel/Dockerfile
Normale Datei
33
v2_adminpanel/Dockerfile
Normale Datei
@@ -0,0 +1,33 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Locale für deutsche Sprache und UTF-8 setzen
|
||||
ENV LANG=de_DE.UTF-8
|
||||
ENV LC_ALL=de_DE.UTF-8
|
||||
ENV PYTHONIOENCODING=utf-8
|
||||
|
||||
# Zeitzone auf Europe/Berlin setzen
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System-Dependencies inkl. PostgreSQL-Tools installieren
|
||||
RUN apt-get update && apt-get install -y \
|
||||
locales \
|
||||
postgresql-client \
|
||||
tzdata \
|
||||
&& sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \
|
||||
&& locale-gen \
|
||||
&& update-locale LANG=de_DE.UTF-8 \
|
||||
&& ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \
|
||||
&& echo "Europe/Berlin" > /etc/timezone \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
456
v2_adminpanel/ERROR_HANDLING_GUIDE.md
Normale Datei
456
v2_adminpanel/ERROR_HANDLING_GUIDE.md
Normale Datei
@@ -0,0 +1,456 @@
|
||||
# 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
v2_adminpanel/__pycache__/app.cpython-312.pyc
Normale Datei
BIN
v2_adminpanel/__pycache__/app.cpython-312.pyc
Normale Datei
Binäre Datei nicht angezeigt.
168
v2_adminpanel/app.py
Normale Datei
168
v2_adminpanel/app.py
Normale Datei
@@ -0,0 +1,168 @@
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
# Add current directory to Python path to ensure modules can be imported
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from flask import Flask, render_template, session
|
||||
from flask_session import Session
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from prometheus_flask_exporter import PrometheusMetrics
|
||||
|
||||
# Import our configuration and utilities
|
||||
import config
|
||||
from utils.backup import create_backup
|
||||
|
||||
# Import error handling system
|
||||
from core.error_handlers import init_error_handlers
|
||||
from core.logging_config import setup_logging
|
||||
from core.monitoring import init_monitoring
|
||||
from middleware.error_middleware import ErrorHandlingMiddleware
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Initialize Prometheus metrics
|
||||
metrics = PrometheusMetrics(app)
|
||||
metrics.info('admin_panel_info', 'Admin Panel Information', version='1.0.0')
|
||||
# Load configuration from config module
|
||||
app.config['SECRET_KEY'] = config.SECRET_KEY
|
||||
app.config['SESSION_TYPE'] = config.SESSION_TYPE
|
||||
app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII
|
||||
app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY
|
||||
app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE
|
||||
app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME
|
||||
app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST
|
||||
Session(app)
|
||||
|
||||
# ProxyFix für korrekte IP-Adressen hinter Nginx
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
|
||||
)
|
||||
|
||||
# Configuration is now loaded from config module
|
||||
|
||||
# Initialize error handling system
|
||||
setup_logging(app)
|
||||
init_error_handlers(app)
|
||||
init_monitoring(app)
|
||||
ErrorHandlingMiddleware(app)
|
||||
|
||||
# Logging konfigurieren
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Initialize scheduler from scheduler module
|
||||
from scheduler import init_scheduler
|
||||
scheduler = init_scheduler()
|
||||
|
||||
# Import and register blueprints
|
||||
try:
|
||||
from routes.auth_routes import auth_bp
|
||||
from routes.admin_routes import admin_bp
|
||||
from routes.api_routes import api_bp
|
||||
from routes.batch_routes import batch_bp
|
||||
from routes.customer_routes import customer_bp
|
||||
from routes.export_routes import export_bp
|
||||
from routes.license_routes import license_bp
|
||||
from routes.resource_routes import resource_bp
|
||||
from routes.session_routes import session_bp
|
||||
from routes.monitoring_routes import monitoring_bp
|
||||
from leads import leads_bp
|
||||
print("All blueprints imported successfully!")
|
||||
except Exception as e:
|
||||
print(f"Blueprint import error: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Register all blueprints
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(batch_bp)
|
||||
app.register_blueprint(customer_bp)
|
||||
app.register_blueprint(export_bp)
|
||||
app.register_blueprint(license_bp)
|
||||
app.register_blueprint(resource_bp)
|
||||
app.register_blueprint(session_bp)
|
||||
app.register_blueprint(monitoring_bp)
|
||||
app.register_blueprint(leads_bp, url_prefix='/leads')
|
||||
|
||||
# Template filters
|
||||
@app.template_filter('nl2br')
|
||||
def nl2br_filter(s):
|
||||
"""Convert newlines to <br> tags"""
|
||||
return s.replace('\n', '<br>\n') if s else ''
|
||||
|
||||
# Debug routes to test
|
||||
@app.route('/test-customers-licenses')
|
||||
def test_route():
|
||||
return "Test route works! If you see this, routing is working."
|
||||
|
||||
@app.route('/direct-customers-licenses')
|
||||
def direct_customers_licenses():
|
||||
"""Direct route without blueprint"""
|
||||
try:
|
||||
return render_template("customers_licenses.html", customers=[])
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@app.route('/debug-routes')
|
||||
def debug_routes():
|
||||
"""Show all registered routes"""
|
||||
routes = []
|
||||
for rule in app.url_map.iter_rules():
|
||||
routes.append(f"{rule.endpoint}: {rule.rule}")
|
||||
return "<br>".join(sorted(routes))
|
||||
|
||||
# Scheduled backup job is now handled by scheduler module
|
||||
|
||||
|
||||
# Error handlers are now managed by the error handling system in core/error_handlers.py
|
||||
|
||||
|
||||
# Context processors
|
||||
@app.context_processor
|
||||
def inject_global_vars():
|
||||
"""Inject global variables into all templates"""
|
||||
return {
|
||||
'current_year': datetime.now().year,
|
||||
'app_version': '2.0.0',
|
||||
'is_logged_in': session.get('logged_in', False),
|
||||
'username': session.get('username', '')
|
||||
}
|
||||
|
||||
|
||||
# Simple test route that should always work
|
||||
@app.route('/simple-test')
|
||||
def simple_test():
|
||||
return "Simple test works!"
|
||||
|
||||
@app.route('/test-db')
|
||||
def test_db():
|
||||
"""Test database connection"""
|
||||
try:
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(
|
||||
host=os.getenv("POSTGRES_HOST", "postgres"),
|
||||
port=os.getenv("POSTGRES_PORT", "5432"),
|
||||
dbname=os.getenv("POSTGRES_DB"),
|
||||
user=os.getenv("POSTGRES_USER"),
|
||||
password=os.getenv("POSTGRES_PASSWORD")
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM customers")
|
||||
count = cur.fetchone()[0]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return f"Database works! Customers count: {count}"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return f"Database error: {str(e)}<br><pre>{traceback.format_exc()}</pre>"
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000)
|
||||
52
v2_adminpanel/apply_lead_migration.py
Normale Datei
52
v2_adminpanel/apply_lead_migration.py
Normale Datei
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Apply Lead Management Tables Migration
|
||||
"""
|
||||
import psycopg2
|
||||
import os
|
||||
from db import get_db_connection
|
||||
|
||||
def apply_migration():
|
||||
"""Apply the lead tables migration"""
|
||||
try:
|
||||
# Read migration SQL
|
||||
migration_file = os.path.join(os.path.dirname(__file__),
|
||||
'migrations', 'create_lead_tables.sql')
|
||||
|
||||
with open(migration_file, 'r') as f:
|
||||
migration_sql = f.read()
|
||||
|
||||
# Connect and execute
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
print("Applying lead management tables migration...")
|
||||
cur.execute(migration_sql)
|
||||
|
||||
# Verify tables were created
|
||||
cur.execute("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name LIKE 'lead_%'
|
||||
ORDER BY table_name
|
||||
""")
|
||||
|
||||
tables = cur.fetchall()
|
||||
print(f"\nCreated {len(tables)} tables:")
|
||||
for table in tables:
|
||||
print(f" - {table[0]}")
|
||||
|
||||
cur.close()
|
||||
|
||||
print("\n✅ Migration completed successfully!")
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"❌ Migration file not found: {migration_file}")
|
||||
except psycopg2.Error as e:
|
||||
print(f"❌ Database error: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
apply_migration()
|
||||
92
v2_adminpanel/apply_license_heartbeats_migration.py
Normale Datei
92
v2_adminpanel/apply_license_heartbeats_migration.py
Normale Datei
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Apply the license_heartbeats table migration
|
||||
"""
|
||||
|
||||
import os
|
||||
import psycopg2
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_db_connection():
|
||||
"""Get database connection"""
|
||||
return psycopg2.connect(
|
||||
host=os.environ.get('POSTGRES_HOST', 'postgres'),
|
||||
database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'),
|
||||
user=os.environ.get('POSTGRES_USER', 'postgres'),
|
||||
password=os.environ.get('POSTGRES_PASSWORD', 'postgres')
|
||||
)
|
||||
|
||||
def apply_migration():
|
||||
"""Apply the license_heartbeats migration"""
|
||||
conn = None
|
||||
try:
|
||||
logger.info("Connecting to database...")
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Read migration file
|
||||
migration_file = os.path.join(os.path.dirname(__file__), 'migrations', 'create_license_heartbeats_table.sql')
|
||||
logger.info(f"Reading migration file: {migration_file}")
|
||||
|
||||
with open(migration_file, 'r') as f:
|
||||
migration_sql = f.read()
|
||||
|
||||
# Execute migration
|
||||
logger.info("Executing migration...")
|
||||
cur.execute(migration_sql)
|
||||
|
||||
# Verify table was created
|
||||
cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'license_heartbeats'
|
||||
)
|
||||
""")
|
||||
|
||||
if cur.fetchone()[0]:
|
||||
logger.info("✓ license_heartbeats table created successfully!")
|
||||
|
||||
# Check partitions
|
||||
cur.execute("""
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE tablename LIKE 'license_heartbeats_%'
|
||||
ORDER BY tablename
|
||||
""")
|
||||
|
||||
partitions = cur.fetchall()
|
||||
logger.info(f"✓ Created {len(partitions)} partitions:")
|
||||
for partition in partitions:
|
||||
logger.info(f" - {partition[0]}")
|
||||
else:
|
||||
logger.error("✗ Failed to create license_heartbeats table")
|
||||
return False
|
||||
|
||||
conn.commit()
|
||||
logger.info("✓ Migration completed successfully!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Migration failed: {str(e)}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("=== Applying license_heartbeats migration ===")
|
||||
logger.info(f"Timestamp: {datetime.now()}")
|
||||
|
||||
if apply_migration():
|
||||
logger.info("=== Migration successful! ===")
|
||||
else:
|
||||
logger.error("=== Migration failed! ===")
|
||||
exit(1)
|
||||
122
v2_adminpanel/apply_partition_migration.py
Normale Datei
122
v2_adminpanel/apply_partition_migration.py
Normale Datei
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Apply partition migration for license_heartbeats table.
|
||||
This script creates missing partitions for the current and future months.
|
||||
"""
|
||||
|
||||
import psycopg2
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
def get_db_connection():
|
||||
"""Get database connection"""
|
||||
return psycopg2.connect(
|
||||
host=os.environ.get('POSTGRES_HOST', 'postgres'),
|
||||
database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'),
|
||||
user=os.environ.get('POSTGRES_USER', 'postgres'),
|
||||
password=os.environ.get('POSTGRES_PASSWORD', 'postgres')
|
||||
)
|
||||
|
||||
def create_partition(cursor, year, month):
|
||||
"""Create a partition for the given year and month"""
|
||||
partition_name = f"license_heartbeats_{year}_{month:02d}"
|
||||
start_date = f"{year}-{month:02d}-01"
|
||||
|
||||
# Calculate end date (first day of next month)
|
||||
if month == 12:
|
||||
end_date = f"{year + 1}-01-01"
|
||||
else:
|
||||
end_date = f"{year}-{month + 1:02d}-01"
|
||||
|
||||
# Check if partition already exists
|
||||
cursor.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_tables
|
||||
WHERE tablename = %s
|
||||
)
|
||||
""", (partition_name,))
|
||||
|
||||
exists = cursor.fetchone()[0]
|
||||
|
||||
if not exists:
|
||||
try:
|
||||
cursor.execute(f"""
|
||||
CREATE TABLE {partition_name} PARTITION OF license_heartbeats
|
||||
FOR VALUES FROM ('{start_date}') TO ('{end_date}')
|
||||
""")
|
||||
print(f"✓ Created partition {partition_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Error creating partition {partition_name}: {e}")
|
||||
return False
|
||||
else:
|
||||
print(f"- Partition {partition_name} already exists")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Main function"""
|
||||
print("Applying license_heartbeats partition migration...")
|
||||
print("-" * 50)
|
||||
|
||||
try:
|
||||
# Connect to database
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if license_heartbeats table exists
|
||||
cursor.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'license_heartbeats'
|
||||
)
|
||||
""")
|
||||
|
||||
if not cursor.fetchone()[0]:
|
||||
print("✗ Error: license_heartbeats table does not exist!")
|
||||
print(" Please run the init.sql script first.")
|
||||
return 1
|
||||
|
||||
# Get current date
|
||||
current_date = datetime.now()
|
||||
partitions_created = 0
|
||||
|
||||
# Create partitions for the next 6 months (including current month)
|
||||
for i in range(7):
|
||||
target_date = current_date + relativedelta(months=i)
|
||||
if create_partition(cursor, target_date.year, target_date.month):
|
||||
partitions_created += 1
|
||||
|
||||
# Commit changes
|
||||
conn.commit()
|
||||
|
||||
print("-" * 50)
|
||||
print(f"✓ Migration complete. Created {partitions_created} new partitions.")
|
||||
|
||||
# List all partitions
|
||||
cursor.execute("""
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE tablename LIKE 'license_heartbeats_%'
|
||||
ORDER BY tablename
|
||||
""")
|
||||
|
||||
partitions = cursor.fetchall()
|
||||
print(f"\nTotal partitions: {len(partitions)}")
|
||||
for partition in partitions:
|
||||
print(f" - {partition[0]}")
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {e}")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
1
v2_adminpanel/auth/__init__.py
Normale Datei
1
v2_adminpanel/auth/__init__.py
Normale Datei
@@ -0,0 +1 @@
|
||||
# Auth module initialization
|
||||
44
v2_adminpanel/auth/decorators.py
Normale Datei
44
v2_adminpanel/auth/decorators.py
Normale Datei
@@ -0,0 +1,44 @@
|
||||
from functools import wraps
|
||||
from flask import session, redirect, url_for, flash, request
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
import logging
|
||||
from utils.audit import log_audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'logged_in' not in session:
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# Check if session has expired
|
||||
if 'last_activity' in session:
|
||||
last_activity = datetime.fromisoformat(session['last_activity'])
|
||||
time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity
|
||||
|
||||
# Debug logging
|
||||
logger.info(f"Session check for {session.get('username', 'unknown')}: "
|
||||
f"Last activity: {last_activity}, "
|
||||
f"Time since: {time_since_activity.total_seconds()} seconds")
|
||||
|
||||
if time_since_activity > timedelta(minutes=5):
|
||||
# Session expired - Logout
|
||||
username = session.get('username', 'unbekannt')
|
||||
logger.info(f"Session timeout for user {username} - auto logout")
|
||||
# Audit log for automatic logout (before session.clear()!)
|
||||
try:
|
||||
log_audit('AUTO_LOGOUT', 'session',
|
||||
additional_info={'reason': 'Session timeout (5 minutes)', 'username': username})
|
||||
except:
|
||||
pass
|
||||
session.clear()
|
||||
flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# Activity is NOT automatically updated
|
||||
# Only on explicit user actions (done by heartbeat)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
11
v2_adminpanel/auth/password.py
Normale Datei
11
v2_adminpanel/auth/password.py
Normale Datei
@@ -0,0 +1,11 @@
|
||||
import bcrypt
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
"""Hash a password using bcrypt"""
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(password, hashed):
|
||||
"""Verify a password against its hash"""
|
||||
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
||||
124
v2_adminpanel/auth/rate_limiting.py
Normale Datei
124
v2_adminpanel/auth/rate_limiting.py
Normale Datei
@@ -0,0 +1,124 @@
|
||||
import random
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import request
|
||||
from db import execute_query, get_db_connection, get_db_cursor
|
||||
from config import FAIL_MESSAGES, MAX_LOGIN_ATTEMPTS, BLOCK_DURATION_HOURS, EMAIL_ENABLED
|
||||
from utils.audit import log_audit
|
||||
from utils.network import get_client_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_ip_blocked(ip_address):
|
||||
"""Check if an IP address is blocked"""
|
||||
result = execute_query(
|
||||
"""
|
||||
SELECT blocked_until FROM login_attempts
|
||||
WHERE ip_address = %s AND blocked_until IS NOT NULL
|
||||
""",
|
||||
(ip_address,),
|
||||
fetch_one=True
|
||||
)
|
||||
|
||||
if result and result[0]:
|
||||
if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None):
|
||||
return True, result[0]
|
||||
return False, None
|
||||
|
||||
|
||||
def record_failed_attempt(ip_address, username):
|
||||
"""Record a failed login attempt"""
|
||||
# Random error message
|
||||
error_message = random.choice(FAIL_MESSAGES)
|
||||
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
try:
|
||||
# Check if IP already exists
|
||||
cur.execute("""
|
||||
SELECT attempt_count FROM login_attempts
|
||||
WHERE ip_address = %s
|
||||
""", (ip_address,))
|
||||
|
||||
result = cur.fetchone()
|
||||
|
||||
if result:
|
||||
# Update existing entry
|
||||
new_count = result[0] + 1
|
||||
blocked_until = None
|
||||
|
||||
if new_count >= MAX_LOGIN_ATTEMPTS:
|
||||
blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS)
|
||||
# Email notification (if enabled)
|
||||
if EMAIL_ENABLED:
|
||||
send_security_alert_email(ip_address, username, new_count)
|
||||
|
||||
cur.execute("""
|
||||
UPDATE login_attempts
|
||||
SET attempt_count = %s,
|
||||
last_attempt = CURRENT_TIMESTAMP,
|
||||
blocked_until = %s,
|
||||
last_username_tried = %s,
|
||||
last_error_message = %s
|
||||
WHERE ip_address = %s
|
||||
""", (new_count, blocked_until, username, error_message, ip_address))
|
||||
else:
|
||||
# Create new entry
|
||||
cur.execute("""
|
||||
INSERT INTO login_attempts
|
||||
(ip_address, attempt_count, last_username_tried, last_error_message)
|
||||
VALUES (%s, 1, %s, %s)
|
||||
""", (ip_address, username, error_message))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit log
|
||||
log_audit('LOGIN_FAILED', 'user',
|
||||
additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Rate limiting error: {e}")
|
||||
conn.rollback()
|
||||
|
||||
return error_message
|
||||
|
||||
|
||||
def reset_login_attempts(ip_address):
|
||||
"""Reset login attempts for an IP"""
|
||||
execute_query(
|
||||
"DELETE FROM login_attempts WHERE ip_address = %s",
|
||||
(ip_address,)
|
||||
)
|
||||
|
||||
|
||||
def get_login_attempts(ip_address):
|
||||
"""Get the number of login attempts for an IP"""
|
||||
result = execute_query(
|
||||
"SELECT attempt_count FROM login_attempts WHERE ip_address = %s",
|
||||
(ip_address,),
|
||||
fetch_one=True
|
||||
)
|
||||
return result[0] if result else 0
|
||||
|
||||
|
||||
def send_security_alert_email(ip_address, username, attempt_count):
|
||||
"""Send a security alert email"""
|
||||
subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche"
|
||||
body = f"""
|
||||
WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt!
|
||||
|
||||
IP-Adresse: {ip_address}
|
||||
Versuchter Benutzername: {username}
|
||||
Anzahl Versuche: {attempt_count}
|
||||
Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
Die IP-Adresse wurde für 24 Stunden gesperrt.
|
||||
|
||||
Dies ist eine automatische Nachricht vom v2-Docker Admin Panel.
|
||||
"""
|
||||
|
||||
# TODO: Email sending implementation when SMTP is configured
|
||||
logger.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}")
|
||||
print(f"E-Mail würde gesendet: {subject}")
|
||||
57
v2_adminpanel/auth/two_factor.py
Normale Datei
57
v2_adminpanel/auth/two_factor.py
Normale Datei
@@ -0,0 +1,57 @@
|
||||
import pyotp
|
||||
import qrcode
|
||||
import random
|
||||
import string
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import base64
|
||||
|
||||
|
||||
def generate_totp_secret():
|
||||
"""Generate a new TOTP secret"""
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def generate_qr_code(username, totp_secret):
|
||||
"""Generate QR code for TOTP setup"""
|
||||
totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
|
||||
name=username,
|
||||
issuer_name='V2 Admin Panel'
|
||||
)
|
||||
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(totp_uri)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buf = BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def verify_totp(totp_secret, token):
|
||||
"""Verify a TOTP token"""
|
||||
totp = pyotp.TOTP(totp_secret)
|
||||
return totp.verify(token, valid_window=1)
|
||||
|
||||
|
||||
def generate_backup_codes(count=8):
|
||||
"""Generate backup codes for 2FA recovery"""
|
||||
codes = []
|
||||
for _ in range(count):
|
||||
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||||
codes.append(code)
|
||||
return codes
|
||||
|
||||
|
||||
def hash_backup_code(code):
|
||||
"""Hash a backup code for storage"""
|
||||
return hashlib.sha256(code.encode()).hexdigest()
|
||||
|
||||
|
||||
def verify_backup_code(code, hashed_codes):
|
||||
"""Verify a backup code against stored hashes"""
|
||||
code_hash = hashlib.sha256(code.encode()).hexdigest()
|
||||
return code_hash in hashed_codes
|
||||
70
v2_adminpanel/config.py
Normale Datei
70
v2_adminpanel/config.py
Normale Datei
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Flask Configuration
|
||||
SECRET_KEY = os.urandom(24)
|
||||
SESSION_TYPE = 'filesystem'
|
||||
JSON_AS_ASCII = False
|
||||
JSONIFY_MIMETYPE = 'application/json; charset=utf-8'
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(minutes=5)
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "true").lower() == "true" # Default True for HTTPS
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
SESSION_COOKIE_NAME = 'admin_session'
|
||||
SESSION_REFRESH_EACH_REQUEST = False
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_CONFIG = {
|
||||
'host': os.getenv("POSTGRES_HOST", "postgres"),
|
||||
'port': os.getenv("POSTGRES_PORT", "5432"),
|
||||
'dbname': os.getenv("POSTGRES_DB"),
|
||||
'user': os.getenv("POSTGRES_USER"),
|
||||
'password': os.getenv("POSTGRES_PASSWORD"),
|
||||
'options': '-c client_encoding=UTF8'
|
||||
}
|
||||
|
||||
# Backup Configuration
|
||||
BACKUP_DIR = Path("/app/backups")
|
||||
BACKUP_DIR.mkdir(exist_ok=True)
|
||||
BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY")
|
||||
|
||||
# Rate Limiting Configuration
|
||||
FAIL_MESSAGES = [
|
||||
"NOPE!",
|
||||
"ACCESS DENIED, TRY HARDER",
|
||||
"WRONG! 🚫",
|
||||
"COMPUTER SAYS NO",
|
||||
"YOU FAILED"
|
||||
]
|
||||
MAX_LOGIN_ATTEMPTS = 5
|
||||
BLOCK_DURATION_HOURS = 24
|
||||
CAPTCHA_AFTER_ATTEMPTS = 2
|
||||
|
||||
# reCAPTCHA Configuration
|
||||
RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY')
|
||||
RECAPTCHA_SECRET_KEY = os.getenv('RECAPTCHA_SECRET_KEY')
|
||||
|
||||
# Email Configuration
|
||||
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "false").lower() == "true"
|
||||
|
||||
# Admin Users (for backward compatibility)
|
||||
ADMIN_USERS = {
|
||||
os.getenv("ADMIN1_USERNAME"): os.getenv("ADMIN1_PASSWORD"),
|
||||
os.getenv("ADMIN2_USERNAME"): os.getenv("ADMIN2_PASSWORD")
|
||||
}
|
||||
|
||||
# Scheduler Configuration
|
||||
SCHEDULER_CONFIG = {
|
||||
'backup_hour': 3,
|
||||
'backup_minute': 0
|
||||
}
|
||||
|
||||
# Logging Configuration
|
||||
LOGGING_CONFIG = {
|
||||
'level': 'INFO',
|
||||
'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
}
|
||||
0
v2_adminpanel/core/__init__.py
Normale Datei
0
v2_adminpanel/core/__init__.py
Normale Datei
273
v2_adminpanel/core/error_handlers.py
Normale Datei
273
v2_adminpanel/core/error_handlers.py
Normale Datei
@@ -0,0 +1,273 @@
|
||||
import logging
|
||||
import traceback
|
||||
from functools import wraps
|
||||
from typing import Optional, Dict, Any, Callable, Union
|
||||
from flask import (
|
||||
Flask, request, jsonify, render_template, flash, redirect,
|
||||
url_for, current_app, g
|
||||
)
|
||||
from werkzeug.exceptions import HTTPException
|
||||
import psycopg2
|
||||
|
||||
from .exceptions import (
|
||||
BaseApplicationException, DatabaseException, ValidationException,
|
||||
AuthenticationException, ResourceException, QueryError,
|
||||
ConnectionError, TransactionError
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_error_handlers(app: Flask) -> None:
|
||||
@app.before_request
|
||||
def before_request():
|
||||
g.request_id = request.headers.get('X-Request-ID',
|
||||
BaseApplicationException('', '', 0).request_id)
|
||||
|
||||
@app.errorhandler(BaseApplicationException)
|
||||
def handle_application_error(error: BaseApplicationException):
|
||||
return _handle_error(error)
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_error(error: HTTPException):
|
||||
return _handle_error(error)
|
||||
|
||||
@app.errorhandler(psycopg2.Error)
|
||||
def handle_database_error(error: psycopg2.Error):
|
||||
db_exception = _convert_psycopg2_error(error)
|
||||
return _handle_error(db_exception)
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_unexpected_error(error: Exception):
|
||||
logger.error(
|
||||
f"Unexpected error: {str(error)}",
|
||||
exc_info=True,
|
||||
extra={'request_id': getattr(g, 'request_id', 'unknown')}
|
||||
)
|
||||
|
||||
if current_app.debug:
|
||||
raise
|
||||
|
||||
generic_error = BaseApplicationException(
|
||||
message="An unexpected error occurred",
|
||||
code="INTERNAL_ERROR",
|
||||
status_code=500,
|
||||
user_message="Ein unerwarteter Fehler ist aufgetreten"
|
||||
)
|
||||
return _handle_error(generic_error)
|
||||
|
||||
|
||||
def _handle_error(error: Union[BaseApplicationException, HTTPException, Exception]) -> tuple:
|
||||
if isinstance(error, HTTPException):
|
||||
status_code = error.code
|
||||
error_dict = {
|
||||
'error': {
|
||||
'code': error.name.upper().replace(' ', '_'),
|
||||
'message': error.description or str(error),
|
||||
'request_id': getattr(g, 'request_id', 'unknown')
|
||||
}
|
||||
}
|
||||
user_message = error.description or str(error)
|
||||
elif isinstance(error, BaseApplicationException):
|
||||
status_code = error.status_code
|
||||
error_dict = error.to_dict(include_details=current_app.debug)
|
||||
error_dict['error']['request_id'] = getattr(g, 'request_id', error.request_id)
|
||||
user_message = error.user_message
|
||||
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}: {error.message}",
|
||||
extra={
|
||||
'error_code': error.code,
|
||||
'details': error.details,
|
||||
'request_id': error_dict['error']['request_id']
|
||||
}
|
||||
)
|
||||
else:
|
||||
status_code = 500
|
||||
error_dict = {
|
||||
'error': {
|
||||
'code': 'INTERNAL_ERROR',
|
||||
'message': 'An internal error occurred',
|
||||
'request_id': getattr(g, 'request_id', 'unknown')
|
||||
}
|
||||
}
|
||||
user_message = "Ein interner Fehler ist aufgetreten"
|
||||
|
||||
if _is_json_request():
|
||||
return jsonify(error_dict), status_code
|
||||
else:
|
||||
if status_code == 404:
|
||||
return render_template('404.html'), 404
|
||||
elif status_code >= 500:
|
||||
return render_template('500.html', error=user_message), status_code
|
||||
else:
|
||||
flash(user_message, 'error')
|
||||
return render_template('error.html',
|
||||
error=user_message,
|
||||
error_code=error_dict['error']['code'],
|
||||
request_id=error_dict['error']['request_id']), status_code
|
||||
|
||||
|
||||
def _convert_psycopg2_error(error: psycopg2.Error) -> DatabaseException:
|
||||
error_code = getattr(error, 'pgcode', None)
|
||||
error_message = str(error).split('\n')[0]
|
||||
|
||||
if isinstance(error, psycopg2.OperationalError):
|
||||
return ConnectionError(
|
||||
message=f"Database connection failed: {error_message}",
|
||||
host=None
|
||||
)
|
||||
elif isinstance(error, psycopg2.IntegrityError):
|
||||
if error_code == '23505':
|
||||
return ValidationException(
|
||||
message="Duplicate entry violation",
|
||||
details={'constraint': error_message},
|
||||
user_message="Dieser Eintrag existiert bereits"
|
||||
)
|
||||
elif error_code == '23503':
|
||||
return ValidationException(
|
||||
message="Foreign key violation",
|
||||
details={'constraint': error_message},
|
||||
user_message="Referenzierte Daten existieren nicht"
|
||||
)
|
||||
else:
|
||||
return ValidationException(
|
||||
message="Data integrity violation",
|
||||
details={'error_code': error_code},
|
||||
user_message="Datenintegritätsfehler"
|
||||
)
|
||||
elif isinstance(error, psycopg2.DataError):
|
||||
return ValidationException(
|
||||
message="Invalid data format",
|
||||
details={'error': error_message},
|
||||
user_message="Ungültiges Datenformat"
|
||||
)
|
||||
else:
|
||||
return QueryError(
|
||||
message=error_message,
|
||||
query="[query hidden for security]",
|
||||
error_code=error_code
|
||||
)
|
||||
|
||||
|
||||
def _is_json_request() -> bool:
|
||||
return (request.is_json or
|
||||
request.path.startswith('/api/') or
|
||||
request.accept_mimetypes.best == 'application/json')
|
||||
|
||||
|
||||
def handle_errors(
|
||||
catch: tuple = (Exception,),
|
||||
message: str = "Operation failed",
|
||||
user_message: Optional[str] = None,
|
||||
redirect_to: Optional[str] = None
|
||||
) -> Callable:
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except catch as e:
|
||||
if isinstance(e, BaseApplicationException):
|
||||
raise
|
||||
|
||||
logger.error(
|
||||
f"Error in {func.__name__}: {str(e)}",
|
||||
exc_info=True,
|
||||
extra={'request_id': getattr(g, 'request_id', 'unknown')}
|
||||
)
|
||||
|
||||
if _is_json_request():
|
||||
return jsonify({
|
||||
'error': {
|
||||
'code': 'OPERATION_FAILED',
|
||||
'message': user_message or message,
|
||||
'request_id': getattr(g, 'request_id', 'unknown')
|
||||
}
|
||||
}), 500
|
||||
else:
|
||||
flash(user_message or message, 'error')
|
||||
if redirect_to:
|
||||
return redirect(url_for(redirect_to))
|
||||
return redirect(request.referrer or url_for('admin.dashboard'))
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def validate_request(
|
||||
required_fields: Optional[Dict[str, type]] = None,
|
||||
optional_fields: Optional[Dict[str, type]] = None
|
||||
) -> Callable:
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
|
||||
if required_fields:
|
||||
for field, expected_type in required_fields.items():
|
||||
if field not in data:
|
||||
raise ValidationException(
|
||||
message=f"Missing required field: {field}",
|
||||
field=field,
|
||||
user_message=f"Pflichtfeld fehlt: {field}"
|
||||
)
|
||||
|
||||
try:
|
||||
if expected_type != str:
|
||||
if expected_type == int:
|
||||
int(data[field])
|
||||
elif expected_type == float:
|
||||
float(data[field])
|
||||
elif expected_type == bool:
|
||||
if isinstance(data[field], str):
|
||||
if data[field].lower() not in ['true', 'false', '1', '0']:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationException(
|
||||
message=f"Invalid type for field {field}",
|
||||
field=field,
|
||||
value=data[field],
|
||||
details={'expected_type': expected_type.__name__},
|
||||
user_message=f"Ungültiger Typ für Feld {field}"
|
||||
)
|
||||
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
class ErrorContext:
|
||||
def __init__(
|
||||
self,
|
||||
operation: str,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[Any] = None
|
||||
):
|
||||
self.operation = operation
|
||||
self.resource_type = resource_type
|
||||
self.resource_id = resource_id
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if exc_val is None:
|
||||
return False
|
||||
|
||||
if isinstance(exc_val, BaseApplicationException):
|
||||
return False
|
||||
|
||||
logger.error(
|
||||
f"Error during {self.operation}",
|
||||
exc_info=True,
|
||||
extra={
|
||||
'operation': self.operation,
|
||||
'resource_type': self.resource_type,
|
||||
'resource_id': self.resource_id,
|
||||
'request_id': getattr(g, 'request_id', 'unknown')
|
||||
}
|
||||
)
|
||||
|
||||
return False
|
||||
356
v2_adminpanel/core/exceptions.py
Normale Datei
356
v2_adminpanel/core/exceptions.py
Normale Datei
@@ -0,0 +1,356 @@
|
||||
import uuid
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BaseApplicationException(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: str,
|
||||
status_code: int = 500,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
user_message: Optional[str] = None
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
self.details = details or {}
|
||||
self.user_message = user_message or message
|
||||
self.timestamp = datetime.utcnow()
|
||||
self.request_id = str(uuid.uuid4())
|
||||
|
||||
def to_dict(self, include_details: bool = False) -> Dict[str, Any]:
|
||||
result = {
|
||||
'error': {
|
||||
'code': self.code,
|
||||
'message': self.user_message,
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
'request_id': self.request_id
|
||||
}
|
||||
}
|
||||
|
||||
if include_details and self.details:
|
||||
result['error']['details'] = self.details
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ValidationException(BaseApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
field: Optional[str] = None,
|
||||
value: Any = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
user_message: Optional[str] = None
|
||||
):
|
||||
details = details or {}
|
||||
if field:
|
||||
details['field'] = field
|
||||
if value is not None:
|
||||
details['value'] = str(value)
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
code='VALIDATION_ERROR',
|
||||
status_code=400,
|
||||
details=details,
|
||||
user_message=user_message or "Ungültige Eingabe"
|
||||
)
|
||||
|
||||
|
||||
class InputValidationError(ValidationException):
|
||||
def __init__(
|
||||
self,
|
||||
field: str,
|
||||
message: str,
|
||||
value: Any = None,
|
||||
expected_type: Optional[str] = None
|
||||
):
|
||||
details = {'expected_type': expected_type} if expected_type else None
|
||||
super().__init__(
|
||||
message=f"Invalid input for field '{field}': {message}",
|
||||
field=field,
|
||||
value=value,
|
||||
details=details,
|
||||
user_message=f"Ungültiger Wert für Feld '{field}'"
|
||||
)
|
||||
|
||||
|
||||
class BusinessRuleViolation(ValidationException):
|
||||
def __init__(
|
||||
self,
|
||||
rule: str,
|
||||
message: str,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
details={'rule': rule, 'context': context or {}},
|
||||
user_message="Geschäftsregel verletzt"
|
||||
)
|
||||
|
||||
|
||||
class DataIntegrityError(ValidationException):
|
||||
def __init__(
|
||||
self,
|
||||
entity: str,
|
||||
constraint: str,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
details = details or {}
|
||||
details.update({'entity': entity, 'constraint': constraint})
|
||||
super().__init__(
|
||||
message=message,
|
||||
details=details,
|
||||
user_message="Datenintegritätsfehler"
|
||||
)
|
||||
|
||||
|
||||
class AuthenticationException(BaseApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
user_message: Optional[str] = None
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
code='AUTHENTICATION_ERROR',
|
||||
status_code=401,
|
||||
details=details,
|
||||
user_message=user_message or "Authentifizierung fehlgeschlagen"
|
||||
)
|
||||
|
||||
|
||||
class InvalidCredentialsError(AuthenticationException):
|
||||
def __init__(self, username: Optional[str] = None):
|
||||
details = {'username': username} if username else None
|
||||
super().__init__(
|
||||
message="Invalid username or password",
|
||||
details=details,
|
||||
user_message="Ungültiger Benutzername oder Passwort"
|
||||
)
|
||||
|
||||
|
||||
class SessionExpiredError(AuthenticationException):
|
||||
def __init__(self, session_id: Optional[str] = None):
|
||||
details = {'session_id': session_id} if session_id else None
|
||||
super().__init__(
|
||||
message="Session has expired",
|
||||
details=details,
|
||||
user_message="Ihre Sitzung ist abgelaufen"
|
||||
)
|
||||
|
||||
|
||||
class InsufficientPermissionsError(AuthenticationException):
|
||||
def __init__(
|
||||
self,
|
||||
required_permission: str,
|
||||
user_permissions: Optional[list] = None
|
||||
):
|
||||
super().__init__(
|
||||
message=f"User lacks required permission: {required_permission}",
|
||||
details={
|
||||
'required': required_permission,
|
||||
'user_permissions': user_permissions or []
|
||||
},
|
||||
user_message="Unzureichende Berechtigungen für diese Aktion"
|
||||
)
|
||||
self.status_code = 403
|
||||
|
||||
|
||||
class DatabaseException(BaseApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
query: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
user_message: Optional[str] = None
|
||||
):
|
||||
details = details or {}
|
||||
if query:
|
||||
details['query_hash'] = str(hash(query))
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
code='DATABASE_ERROR',
|
||||
status_code=500,
|
||||
details=details,
|
||||
user_message=user_message or "Datenbankfehler aufgetreten"
|
||||
)
|
||||
|
||||
|
||||
class ConnectionError(DatabaseException):
|
||||
def __init__(self, message: str, host: Optional[str] = None):
|
||||
details = {'host': host} if host else None
|
||||
super().__init__(
|
||||
message=message,
|
||||
details=details,
|
||||
user_message="Datenbankverbindung fehlgeschlagen"
|
||||
)
|
||||
|
||||
|
||||
class QueryError(DatabaseException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
query: str,
|
||||
error_code: Optional[str] = None
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
query=query,
|
||||
details={'error_code': error_code} if error_code else None,
|
||||
user_message="Datenbankabfrage fehlgeschlagen"
|
||||
)
|
||||
|
||||
|
||||
class TransactionError(DatabaseException):
|
||||
def __init__(self, message: str, operation: str):
|
||||
super().__init__(
|
||||
message=message,
|
||||
details={'operation': operation},
|
||||
user_message="Transaktion fehlgeschlagen"
|
||||
)
|
||||
|
||||
|
||||
class ExternalServiceException(BaseApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
service_name: str,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
user_message: Optional[str] = None
|
||||
):
|
||||
details = details or {}
|
||||
details['service'] = service_name
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
code='EXTERNAL_SERVICE_ERROR',
|
||||
status_code=502,
|
||||
details=details,
|
||||
user_message=user_message or f"Fehler beim Zugriff auf {service_name}"
|
||||
)
|
||||
|
||||
|
||||
class APIError(ExternalServiceException):
|
||||
def __init__(
|
||||
self,
|
||||
service_name: str,
|
||||
endpoint: str,
|
||||
status_code: int,
|
||||
message: str
|
||||
):
|
||||
super().__init__(
|
||||
service_name=service_name,
|
||||
message=message,
|
||||
details={
|
||||
'endpoint': endpoint,
|
||||
'response_status': status_code
|
||||
},
|
||||
user_message=f"API-Fehler bei {service_name}"
|
||||
)
|
||||
|
||||
|
||||
class TimeoutError(ExternalServiceException):
|
||||
def __init__(
|
||||
self,
|
||||
service_name: str,
|
||||
timeout_seconds: int,
|
||||
operation: str
|
||||
):
|
||||
super().__init__(
|
||||
service_name=service_name,
|
||||
message=f"Timeout after {timeout_seconds}s while {operation}",
|
||||
details={
|
||||
'timeout_seconds': timeout_seconds,
|
||||
'operation': operation
|
||||
},
|
||||
user_message=f"Zeitüberschreitung bei {service_name}"
|
||||
)
|
||||
|
||||
|
||||
class ResourceException(BaseApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
resource_type: str,
|
||||
resource_id: Any = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
user_message: Optional[str] = None
|
||||
):
|
||||
details = details or {}
|
||||
details.update({
|
||||
'resource_type': resource_type,
|
||||
'resource_id': str(resource_id) if resource_id else None
|
||||
})
|
||||
|
||||
super().__init__(
|
||||
message=message,
|
||||
code='RESOURCE_ERROR',
|
||||
status_code=404,
|
||||
details=details,
|
||||
user_message=user_message or "Ressourcenfehler"
|
||||
)
|
||||
|
||||
|
||||
class ResourceNotFoundError(ResourceException):
|
||||
def __init__(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: Any = None,
|
||||
search_criteria: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
details = {'search_criteria': search_criteria} if search_criteria else None
|
||||
super().__init__(
|
||||
message=f"{resource_type} not found",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details=details,
|
||||
user_message=f"{resource_type} nicht gefunden"
|
||||
)
|
||||
self.status_code = 404
|
||||
|
||||
|
||||
class ResourceConflictError(ResourceException):
|
||||
def __init__(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: Any,
|
||||
conflict_reason: str
|
||||
):
|
||||
super().__init__(
|
||||
message=f"Conflict with {resource_type}: {conflict_reason}",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details={'conflict_reason': conflict_reason},
|
||||
user_message=f"Konflikt mit {resource_type}"
|
||||
)
|
||||
self.status_code = 409
|
||||
|
||||
|
||||
class ResourceLimitExceeded(ResourceException):
|
||||
def __init__(
|
||||
self,
|
||||
resource_type: str,
|
||||
limit: int,
|
||||
current: int,
|
||||
requested: Optional[int] = None
|
||||
):
|
||||
details = {
|
||||
'limit': limit,
|
||||
'current': current,
|
||||
'requested': requested
|
||||
}
|
||||
super().__init__(
|
||||
message=f"{resource_type} limit exceeded: {current}/{limit}",
|
||||
resource_type=resource_type,
|
||||
details=details,
|
||||
user_message=f"Limit für {resource_type} überschritten"
|
||||
)
|
||||
self.status_code = 429
|
||||
190
v2_adminpanel/core/logging_config.py
Normale Datei
190
v2_adminpanel/core/logging_config.py
Normale Datei
@@ -0,0 +1,190 @@
|
||||
import logging
|
||||
import logging.handlers
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
from flask import g, request, has_request_context
|
||||
|
||||
|
||||
class StructuredFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
log_data = {
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'message': record.getMessage(),
|
||||
'module': record.module,
|
||||
'function': record.funcName,
|
||||
'line': record.lineno
|
||||
}
|
||||
|
||||
if has_request_context():
|
||||
log_data['request'] = {
|
||||
'method': request.method,
|
||||
'path': request.path,
|
||||
'remote_addr': request.remote_addr,
|
||||
'user_agent': request.user_agent.string,
|
||||
'request_id': getattr(g, 'request_id', 'unknown')
|
||||
}
|
||||
|
||||
if hasattr(record, 'request_id'):
|
||||
log_data['request_id'] = record.request_id
|
||||
|
||||
if hasattr(record, 'error_code'):
|
||||
log_data['error_code'] = record.error_code
|
||||
|
||||
if hasattr(record, 'details') and record.details:
|
||||
log_data['details'] = self._sanitize_details(record.details)
|
||||
|
||||
if record.exc_info:
|
||||
log_data['exception'] = {
|
||||
'type': record.exc_info[0].__name__,
|
||||
'message': str(record.exc_info[1]),
|
||||
'traceback': self.formatException(record.exc_info)
|
||||
}
|
||||
|
||||
return json.dumps(log_data, ensure_ascii=False)
|
||||
|
||||
def _sanitize_details(self, details: Dict[str, Any]) -> Dict[str, Any]:
|
||||
sensitive_fields = {
|
||||
'password', 'secret', 'token', 'api_key', 'authorization',
|
||||
'credit_card', 'ssn', 'pin'
|
||||
}
|
||||
|
||||
sanitized = {}
|
||||
for key, value in details.items():
|
||||
if any(field in key.lower() for field in sensitive_fields):
|
||||
sanitized[key] = '[REDACTED]'
|
||||
elif isinstance(value, dict):
|
||||
sanitized[key] = self._sanitize_details(value)
|
||||
else:
|
||||
sanitized[key] = value
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
class ErrorLevelFilter(logging.Filter):
|
||||
def __init__(self, min_level=logging.ERROR):
|
||||
self.min_level = min_level
|
||||
|
||||
def filter(self, record):
|
||||
return record.levelno >= self.min_level
|
||||
|
||||
|
||||
def setup_logging(app):
|
||||
log_level = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||
log_dir = os.getenv('LOG_DIR', 'logs')
|
||||
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, log_level))
|
||||
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
|
||||
if app.debug:
|
||||
console_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
else:
|
||||
console_formatter = StructuredFormatter()
|
||||
|
||||
console_handler.setFormatter(console_formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
app_log_handler = logging.handlers.RotatingFileHandler(
|
||||
os.path.join(log_dir, 'app.log'),
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=10
|
||||
)
|
||||
app_log_handler.setLevel(logging.DEBUG)
|
||||
app_log_handler.setFormatter(StructuredFormatter())
|
||||
root_logger.addHandler(app_log_handler)
|
||||
|
||||
error_log_handler = logging.handlers.RotatingFileHandler(
|
||||
os.path.join(log_dir, 'errors.log'),
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=10
|
||||
)
|
||||
error_log_handler.setLevel(logging.ERROR)
|
||||
error_log_handler.setFormatter(StructuredFormatter())
|
||||
error_log_handler.addFilter(ErrorLevelFilter())
|
||||
root_logger.addHandler(error_log_handler)
|
||||
|
||||
security_logger = logging.getLogger('security')
|
||||
security_handler = logging.handlers.RotatingFileHandler(
|
||||
os.path.join(log_dir, 'security.log'),
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=20
|
||||
)
|
||||
security_handler.setFormatter(StructuredFormatter())
|
||||
security_logger.addHandler(security_handler)
|
||||
security_logger.setLevel(logging.INFO)
|
||||
|
||||
werkzeug_logger = logging.getLogger('werkzeug')
|
||||
werkzeug_logger.setLevel(logging.WARNING)
|
||||
|
||||
@app.before_request
|
||||
def log_request_info():
|
||||
logger = logging.getLogger('request')
|
||||
logger.info(
|
||||
'Request started',
|
||||
extra={
|
||||
'request_id': getattr(g, 'request_id', 'unknown'),
|
||||
'details': {
|
||||
'method': request.method,
|
||||
'path': request.path,
|
||||
'query_params': dict(request.args),
|
||||
'content_length': request.content_length
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@app.after_request
|
||||
def log_response_info(response):
|
||||
logger = logging.getLogger('request')
|
||||
logger.info(
|
||||
'Request completed',
|
||||
extra={
|
||||
'request_id': getattr(g, 'request_id', 'unknown'),
|
||||
'details': {
|
||||
'status_code': response.status_code,
|
||||
'content_length': response.content_length or 0
|
||||
}
|
||||
}
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
def log_error(logger: logging.Logger, message: str, error: Exception = None, **kwargs):
|
||||
extra = kwargs.copy()
|
||||
|
||||
if error:
|
||||
extra['error_type'] = type(error).__name__
|
||||
extra['error_message'] = str(error)
|
||||
|
||||
if hasattr(error, 'code'):
|
||||
extra['error_code'] = error.code
|
||||
if hasattr(error, 'details'):
|
||||
extra['details'] = error.details
|
||||
|
||||
logger.error(message, exc_info=error, extra=extra)
|
||||
|
||||
|
||||
def log_security_event(event_type: str, message: str, **details):
|
||||
logger = logging.getLogger('security')
|
||||
logger.warning(
|
||||
f"Security Event: {event_type} - {message}",
|
||||
extra={
|
||||
'security_event': event_type,
|
||||
'details': details,
|
||||
'request_id': getattr(g, 'request_id', 'unknown') if has_request_context() else None
|
||||
}
|
||||
)
|
||||
246
v2_adminpanel/core/monitoring.py
Normale Datei
246
v2_adminpanel/core/monitoring.py
Normale Datei
@@ -0,0 +1,246 @@
|
||||
import time
|
||||
import functools
|
||||
from typing import Dict, Any, Optional, List
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Lock
|
||||
import logging
|
||||
|
||||
from prometheus_client import Counter, Histogram, Gauge, generate_latest
|
||||
from flask import g, request, Response
|
||||
|
||||
from .exceptions import BaseApplicationException
|
||||
from .logging_config import log_security_event
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorMetrics:
|
||||
def __init__(self):
|
||||
self.error_counter = Counter(
|
||||
'app_errors_total',
|
||||
'Total number of errors',
|
||||
['error_code', 'status_code', 'endpoint']
|
||||
)
|
||||
|
||||
self.error_rate = Gauge(
|
||||
'app_error_rate',
|
||||
'Error rate per minute',
|
||||
['error_code']
|
||||
)
|
||||
|
||||
self.request_duration = Histogram(
|
||||
'app_request_duration_seconds',
|
||||
'Request duration in seconds',
|
||||
['method', 'endpoint', 'status_code']
|
||||
)
|
||||
|
||||
self.validation_errors = Counter(
|
||||
'app_validation_errors_total',
|
||||
'Total validation errors',
|
||||
['field', 'endpoint']
|
||||
)
|
||||
|
||||
self.auth_failures = Counter(
|
||||
'app_auth_failures_total',
|
||||
'Total authentication failures',
|
||||
['reason', 'endpoint']
|
||||
)
|
||||
|
||||
self.db_errors = Counter(
|
||||
'app_database_errors_total',
|
||||
'Total database errors',
|
||||
['error_type', 'operation']
|
||||
)
|
||||
|
||||
self._error_history = defaultdict(lambda: deque(maxlen=60))
|
||||
self._lock = Lock()
|
||||
|
||||
def record_error(self, error: BaseApplicationException, endpoint: str = None):
|
||||
endpoint = endpoint or request.endpoint or 'unknown'
|
||||
|
||||
self.error_counter.labels(
|
||||
error_code=error.code,
|
||||
status_code=error.status_code,
|
||||
endpoint=endpoint
|
||||
).inc()
|
||||
|
||||
with self._lock:
|
||||
self._error_history[error.code].append(datetime.utcnow())
|
||||
self._update_error_rates()
|
||||
|
||||
if error.code == 'VALIDATION_ERROR' and 'field' in error.details:
|
||||
self.validation_errors.labels(
|
||||
field=error.details['field'],
|
||||
endpoint=endpoint
|
||||
).inc()
|
||||
elif error.code == 'AUTHENTICATION_ERROR':
|
||||
reason = error.__class__.__name__
|
||||
self.auth_failures.labels(
|
||||
reason=reason,
|
||||
endpoint=endpoint
|
||||
).inc()
|
||||
elif error.code == 'DATABASE_ERROR':
|
||||
error_type = error.__class__.__name__
|
||||
operation = error.details.get('operation', 'unknown')
|
||||
self.db_errors.labels(
|
||||
error_type=error_type,
|
||||
operation=operation
|
||||
).inc()
|
||||
|
||||
def _update_error_rates(self):
|
||||
now = datetime.utcnow()
|
||||
one_minute_ago = now - timedelta(minutes=1)
|
||||
|
||||
for error_code, timestamps in self._error_history.items():
|
||||
recent_count = sum(1 for ts in timestamps if ts >= one_minute_ago)
|
||||
self.error_rate.labels(error_code=error_code).set(recent_count)
|
||||
|
||||
|
||||
class AlertManager:
|
||||
def __init__(self):
|
||||
self.alerts = []
|
||||
self.alert_thresholds = {
|
||||
'error_rate': 10,
|
||||
'auth_failure_rate': 5,
|
||||
'db_error_rate': 3,
|
||||
'response_time_95th': 2.0
|
||||
}
|
||||
self._lock = Lock()
|
||||
|
||||
def check_alerts(self, metrics: ErrorMetrics):
|
||||
new_alerts = []
|
||||
|
||||
for error_code, rate in self._get_current_error_rates(metrics).items():
|
||||
if rate > self.alert_thresholds['error_rate']:
|
||||
new_alerts.append({
|
||||
'type': 'high_error_rate',
|
||||
'severity': 'critical',
|
||||
'error_code': error_code,
|
||||
'rate': rate,
|
||||
'threshold': self.alert_thresholds['error_rate'],
|
||||
'message': f'High error rate for {error_code}: {rate}/min',
|
||||
'timestamp': datetime.utcnow()
|
||||
})
|
||||
|
||||
auth_failure_rate = self._get_auth_failure_rate(metrics)
|
||||
if auth_failure_rate > self.alert_thresholds['auth_failure_rate']:
|
||||
new_alerts.append({
|
||||
'type': 'auth_failures',
|
||||
'severity': 'warning',
|
||||
'rate': auth_failure_rate,
|
||||
'threshold': self.alert_thresholds['auth_failure_rate'],
|
||||
'message': f'High authentication failure rate: {auth_failure_rate}/min',
|
||||
'timestamp': datetime.utcnow()
|
||||
})
|
||||
|
||||
log_security_event(
|
||||
'HIGH_AUTH_FAILURE_RATE',
|
||||
f'Authentication failure rate exceeded threshold',
|
||||
rate=auth_failure_rate,
|
||||
threshold=self.alert_thresholds['auth_failure_rate']
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self.alerts.extend(new_alerts)
|
||||
self.alerts = [a for a in self.alerts
|
||||
if a['timestamp'] > datetime.utcnow() - timedelta(hours=24)]
|
||||
|
||||
return new_alerts
|
||||
|
||||
def _get_current_error_rates(self, metrics: ErrorMetrics) -> Dict[str, float]:
|
||||
rates = {}
|
||||
with metrics._lock:
|
||||
now = datetime.utcnow()
|
||||
one_minute_ago = now - timedelta(minutes=1)
|
||||
|
||||
for error_code, timestamps in metrics._error_history.items():
|
||||
rates[error_code] = sum(1 for ts in timestamps if ts >= one_minute_ago)
|
||||
|
||||
return rates
|
||||
|
||||
def _get_auth_failure_rate(self, metrics: ErrorMetrics) -> float:
|
||||
return sum(
|
||||
sample.value
|
||||
for sample in metrics.auth_failures._child_samples()
|
||||
) / 60.0
|
||||
|
||||
def get_active_alerts(self) -> List[Dict[str, Any]]:
|
||||
with self._lock:
|
||||
return list(self.alerts)
|
||||
|
||||
|
||||
error_metrics = ErrorMetrics()
|
||||
alert_manager = AlertManager()
|
||||
|
||||
|
||||
def init_monitoring(app):
|
||||
@app.before_request
|
||||
def before_request():
|
||||
g.start_time = time.time()
|
||||
|
||||
@app.after_request
|
||||
def after_request(response):
|
||||
if hasattr(g, 'start_time'):
|
||||
duration = time.time() - g.start_time
|
||||
error_metrics.request_duration.labels(
|
||||
method=request.method,
|
||||
endpoint=request.endpoint or 'unknown',
|
||||
status_code=response.status_code
|
||||
).observe(duration)
|
||||
|
||||
return response
|
||||
|
||||
@app.route('/metrics')
|
||||
def metrics():
|
||||
alert_manager.check_alerts(error_metrics)
|
||||
return Response(generate_latest(), mimetype='text/plain')
|
||||
|
||||
@app.route('/api/alerts')
|
||||
def get_alerts():
|
||||
alerts = alert_manager.get_active_alerts()
|
||||
return {
|
||||
'alerts': alerts,
|
||||
'total': len(alerts),
|
||||
'critical': len([a for a in alerts if a['severity'] == 'critical']),
|
||||
'warning': len([a for a in alerts if a['severity'] == 'warning'])
|
||||
}
|
||||
|
||||
|
||||
def monitor_performance(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return result
|
||||
finally:
|
||||
duration = time.time() - start_time
|
||||
if duration > 1.0:
|
||||
logger.warning(
|
||||
f"Slow function execution: {func.__name__}",
|
||||
extra={
|
||||
'function': func.__name__,
|
||||
'duration': duration,
|
||||
'request_id': getattr(g, 'request_id', 'unknown')
|
||||
}
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def track_error(error: BaseApplicationException):
|
||||
error_metrics.record_error(error)
|
||||
|
||||
if error.status_code >= 500:
|
||||
logger.error(
|
||||
f"Critical error occurred: {error.code}",
|
||||
extra={
|
||||
'error_code': error.code,
|
||||
'message': error.message,
|
||||
'details': error.details,
|
||||
'request_id': error.request_id
|
||||
}
|
||||
)
|
||||
435
v2_adminpanel/core/validators.py
Normale Datei
435
v2_adminpanel/core/validators.py
Normale Datei
@@ -0,0 +1,435 @@
|
||||
import re
|
||||
from typing import Any, Optional, List, Dict, Callable, Union
|
||||
from datetime import datetime, date
|
||||
from functools import wraps
|
||||
import ipaddress
|
||||
from flask import request
|
||||
|
||||
from .exceptions import InputValidationError, ValidationException
|
||||
|
||||
|
||||
class ValidationRules:
|
||||
EMAIL_PATTERN = re.compile(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
)
|
||||
|
||||
PHONE_PATTERN = re.compile(
|
||||
r'^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,10}$'
|
||||
)
|
||||
|
||||
LICENSE_KEY_PATTERN = re.compile(
|
||||
r'^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||||
)
|
||||
|
||||
SAFE_STRING_PATTERN = re.compile(
|
||||
r'^[a-zA-Z0-9\s\-\_\.\,\!\?\@\#\$\%\&\*\(\)\[\]\{\}\:\;\'\"\+\=\/\\]+$'
|
||||
)
|
||||
|
||||
USERNAME_PATTERN = re.compile(
|
||||
r'^[a-zA-Z0-9_\-\.]{3,50}$'
|
||||
)
|
||||
|
||||
PASSWORD_MIN_LENGTH = 8
|
||||
PASSWORD_REQUIRE_UPPER = True
|
||||
PASSWORD_REQUIRE_LOWER = True
|
||||
PASSWORD_REQUIRE_DIGIT = True
|
||||
PASSWORD_REQUIRE_SPECIAL = True
|
||||
|
||||
|
||||
class Validators:
|
||||
@staticmethod
|
||||
def required(value: Any, field_name: str = "field") -> Any:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="This field is required",
|
||||
value=value
|
||||
)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def email(value: str, field_name: str = "email") -> str:
|
||||
value = Validators.required(value, field_name).strip()
|
||||
|
||||
if not ValidationRules.EMAIL_PATTERN.match(value):
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="Invalid email format",
|
||||
value=value,
|
||||
expected_type="email"
|
||||
)
|
||||
|
||||
return value.lower()
|
||||
|
||||
@staticmethod
|
||||
def phone(value: str, field_name: str = "phone") -> str:
|
||||
value = Validators.required(value, field_name).strip()
|
||||
|
||||
cleaned = re.sub(r'[\s\-\(\)]', '', value)
|
||||
|
||||
if not ValidationRules.PHONE_PATTERN.match(value):
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="Invalid phone number format",
|
||||
value=value,
|
||||
expected_type="phone"
|
||||
)
|
||||
|
||||
return cleaned
|
||||
|
||||
@staticmethod
|
||||
def license_key(value: str, field_name: str = "license_key") -> str:
|
||||
value = Validators.required(value, field_name).strip().upper()
|
||||
|
||||
if not ValidationRules.LICENSE_KEY_PATTERN.match(value):
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="Invalid license key format (expected: XXXX-XXXX-XXXX-XXXX)",
|
||||
value=value,
|
||||
expected_type="license_key"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def integer(
|
||||
value: Union[str, int],
|
||||
field_name: str = "field",
|
||||
min_value: Optional[int] = None,
|
||||
max_value: Optional[int] = None
|
||||
) -> int:
|
||||
try:
|
||||
int_value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="Must be a valid integer",
|
||||
value=value,
|
||||
expected_type="integer"
|
||||
)
|
||||
|
||||
if min_value is not None and int_value < min_value:
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Must be at least {min_value}",
|
||||
value=int_value
|
||||
)
|
||||
|
||||
if max_value is not None and int_value > max_value:
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Must be at most {max_value}",
|
||||
value=int_value
|
||||
)
|
||||
|
||||
return int_value
|
||||
|
||||
@staticmethod
|
||||
def float_number(
|
||||
value: Union[str, float],
|
||||
field_name: str = "field",
|
||||
min_value: Optional[float] = None,
|
||||
max_value: Optional[float] = None
|
||||
) -> float:
|
||||
try:
|
||||
float_value = float(value)
|
||||
except (ValueError, TypeError):
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="Must be a valid number",
|
||||
value=value,
|
||||
expected_type="float"
|
||||
)
|
||||
|
||||
if min_value is not None and float_value < min_value:
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Must be at least {min_value}",
|
||||
value=float_value
|
||||
)
|
||||
|
||||
if max_value is not None and float_value > max_value:
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Must be at most {max_value}",
|
||||
value=float_value
|
||||
)
|
||||
|
||||
return float_value
|
||||
|
||||
@staticmethod
|
||||
def boolean(value: Union[str, bool], field_name: str = "field") -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
value_lower = value.lower()
|
||||
if value_lower in ['true', '1', 'yes', 'on']:
|
||||
return True
|
||||
elif value_lower in ['false', '0', 'no', 'off']:
|
||||
return False
|
||||
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="Must be a valid boolean",
|
||||
value=value,
|
||||
expected_type="boolean"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def string(
|
||||
value: str,
|
||||
field_name: str = "field",
|
||||
min_length: Optional[int] = None,
|
||||
max_length: Optional[int] = None,
|
||||
pattern: Optional[re.Pattern] = None,
|
||||
safe_only: bool = False
|
||||
) -> str:
|
||||
value = Validators.required(value, field_name).strip()
|
||||
|
||||
if min_length is not None and len(value) < min_length:
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Must be at least {min_length} characters",
|
||||
value=value
|
||||
)
|
||||
|
||||
if max_length is not None and len(value) > max_length:
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Must be at most {max_length} characters",
|
||||
value=value
|
||||
)
|
||||
|
||||
if safe_only and not ValidationRules.SAFE_STRING_PATTERN.match(value):
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="Contains invalid characters",
|
||||
value=value
|
||||
)
|
||||
|
||||
if pattern and not pattern.match(value):
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="Does not match required format",
|
||||
value=value
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def username(value: str, field_name: str = "username") -> str:
|
||||
value = Validators.required(value, field_name).strip()
|
||||
|
||||
if not ValidationRules.USERNAME_PATTERN.match(value):
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="Username must be 3-50 characters and contain only letters, numbers, _, -, or .",
|
||||
value=value,
|
||||
expected_type="username"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def password(value: str, field_name: str = "password") -> str:
|
||||
value = Validators.required(value, field_name)
|
||||
|
||||
errors = []
|
||||
|
||||
if len(value) < ValidationRules.PASSWORD_MIN_LENGTH:
|
||||
errors.append(f"at least {ValidationRules.PASSWORD_MIN_LENGTH} characters")
|
||||
|
||||
if ValidationRules.PASSWORD_REQUIRE_UPPER and not re.search(r'[A-Z]', value):
|
||||
errors.append("at least one uppercase letter")
|
||||
|
||||
if ValidationRules.PASSWORD_REQUIRE_LOWER and not re.search(r'[a-z]', value):
|
||||
errors.append("at least one lowercase letter")
|
||||
|
||||
if ValidationRules.PASSWORD_REQUIRE_DIGIT and not re.search(r'\d', value):
|
||||
errors.append("at least one digit")
|
||||
|
||||
if ValidationRules.PASSWORD_REQUIRE_SPECIAL and not re.search(r'[!@#$%^&*(),.?":{}|<>]', value):
|
||||
errors.append("at least one special character")
|
||||
|
||||
if errors:
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Password must contain {', '.join(errors)}",
|
||||
value="[hidden]"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def date_string(
|
||||
value: str,
|
||||
field_name: str = "date",
|
||||
format: str = "%Y-%m-%d",
|
||||
min_date: Optional[date] = None,
|
||||
max_date: Optional[date] = None
|
||||
) -> date:
|
||||
value = Validators.required(value, field_name).strip()
|
||||
|
||||
try:
|
||||
date_value = datetime.strptime(value, format).date()
|
||||
except ValueError:
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Invalid date format (expected: {format})",
|
||||
value=value,
|
||||
expected_type="date"
|
||||
)
|
||||
|
||||
if min_date and date_value < min_date:
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Date must be after {min_date}",
|
||||
value=value
|
||||
)
|
||||
|
||||
if max_date and date_value > max_date:
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Date must be before {max_date}",
|
||||
value=value
|
||||
)
|
||||
|
||||
return date_value
|
||||
|
||||
@staticmethod
|
||||
def ip_address(
|
||||
value: str,
|
||||
field_name: str = "ip_address",
|
||||
version: Optional[int] = None
|
||||
) -> str:
|
||||
value = Validators.required(value, field_name).strip()
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(value)
|
||||
if version and ip.version != version:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
version_str = f"IPv{version}" if version else "IP"
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Invalid {version_str} address",
|
||||
value=value,
|
||||
expected_type="ip_address"
|
||||
)
|
||||
|
||||
return str(ip)
|
||||
|
||||
@staticmethod
|
||||
def url(
|
||||
value: str,
|
||||
field_name: str = "url",
|
||||
require_https: bool = False
|
||||
) -> str:
|
||||
value = Validators.required(value, field_name).strip()
|
||||
|
||||
url_pattern = re.compile(
|
||||
r'^https?://'
|
||||
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|'
|
||||
r'localhost|'
|
||||
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
|
||||
r'(?::\d+)?'
|
||||
r'(?:/?|[/?]\S+)$', re.IGNORECASE
|
||||
)
|
||||
|
||||
if not url_pattern.match(value):
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="Invalid URL format",
|
||||
value=value,
|
||||
expected_type="url"
|
||||
)
|
||||
|
||||
if require_https and not value.startswith('https://'):
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message="URL must use HTTPS",
|
||||
value=value
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def enum(
|
||||
value: Any,
|
||||
field_name: str,
|
||||
allowed_values: List[Any]
|
||||
) -> Any:
|
||||
if value not in allowed_values:
|
||||
raise InputValidationError(
|
||||
field=field_name,
|
||||
message=f"Must be one of: {', '.join(map(str, allowed_values))}",
|
||||
value=value
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def validate(rules: Dict[str, Dict[str, Any]]) -> Callable:
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
validated_data = {}
|
||||
|
||||
for field_name, field_rules in rules.items():
|
||||
value = data.get(field_name)
|
||||
|
||||
if 'required' in field_rules and field_rules['required']:
|
||||
value = Validators.required(value, field_name)
|
||||
elif value is None or value == '':
|
||||
if 'default' in field_rules:
|
||||
validated_data[field_name] = field_rules['default']
|
||||
continue
|
||||
|
||||
validator_name = field_rules.get('type', 'string')
|
||||
validator_func = getattr(Validators, validator_name, None)
|
||||
|
||||
if not validator_func:
|
||||
raise ValueError(f"Unknown validator type: {validator_name}")
|
||||
|
||||
validator_params = {
|
||||
k: v for k, v in field_rules.items()
|
||||
if k not in ['type', 'required', 'default']
|
||||
}
|
||||
validator_params['field_name'] = field_name
|
||||
|
||||
validated_data[field_name] = validator_func(value, **validator_params)
|
||||
|
||||
request.validated_data = validated_data
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def sanitize_html(value: str) -> str:
|
||||
dangerous_tags = re.compile(
|
||||
r'<(script|iframe|object|embed|form|input|button|textarea|select|link|meta|style).*?>.*?</\1>',
|
||||
re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
dangerous_attrs = re.compile(
|
||||
r'\s*(on\w+|style|javascript:)[\s]*=[\s]*["\']?[^"\'>\s]+',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
value = dangerous_tags.sub('', value)
|
||||
value = dangerous_attrs.sub('', value)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_sql_identifier(value: str) -> str:
|
||||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', value):
|
||||
raise ValidationException(
|
||||
message="Invalid SQL identifier",
|
||||
details={'value': value},
|
||||
user_message="Ungültiger Bezeichner"
|
||||
)
|
||||
|
||||
return value
|
||||
84
v2_adminpanel/db.py
Normale Datei
84
v2_adminpanel/db.py
Normale Datei
@@ -0,0 +1,84 @@
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json, RealDictCursor
|
||||
from contextlib import contextmanager
|
||||
from config import DATABASE_CONFIG
|
||||
|
||||
|
||||
def get_connection():
|
||||
"""Create and return a new database connection"""
|
||||
conn = psycopg2.connect(**DATABASE_CONFIG)
|
||||
conn.set_client_encoding('UTF8')
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_connection():
|
||||
"""Context manager for database connections"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_cursor(conn=None):
|
||||
"""Context manager for database cursors"""
|
||||
if conn is None:
|
||||
with get_db_connection() as connection:
|
||||
cur = connection.cursor()
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
cur.close()
|
||||
else:
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_dict_cursor(conn=None):
|
||||
"""Context manager for dictionary cursors"""
|
||||
if conn is None:
|
||||
with get_db_connection() as connection:
|
||||
cur = connection.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
cur.close()
|
||||
else:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def execute_query(query, params=None, fetch_one=False, fetch_all=False, as_dict=False):
|
||||
"""Execute a query and optionally fetch results"""
|
||||
with get_db_connection() as conn:
|
||||
cursor_func = get_dict_cursor if as_dict else get_db_cursor
|
||||
with cursor_func(conn) as cur:
|
||||
cur.execute(query, params)
|
||||
|
||||
if fetch_one:
|
||||
return cur.fetchone()
|
||||
elif fetch_all:
|
||||
return cur.fetchall()
|
||||
else:
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def execute_many(query, params_list):
|
||||
"""Execute a query multiple times with different parameters"""
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.executemany(query, params_list)
|
||||
return cur.rowcount
|
||||
704
v2_adminpanel/init.sql
Normale Datei
704
v2_adminpanel/init.sql
Normale Datei
@@ -0,0 +1,704 @@
|
||||
-- UTF-8 Encoding für deutsche Sonderzeichen sicherstellen
|
||||
SET client_encoding = 'UTF8';
|
||||
|
||||
-- Zeitzone auf Europe/Berlin setzen
|
||||
SET timezone = 'Europe/Berlin';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
is_fake BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_email UNIQUE (email)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
license_key TEXT UNIQUE NOT NULL,
|
||||
customer_id INTEGER REFERENCES customers(id),
|
||||
license_type TEXT NOT NULL,
|
||||
valid_from DATE NOT NULL,
|
||||
valid_until DATE NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_fake BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
license_id INTEGER REFERENCES licenses(id),
|
||||
license_key VARCHAR(60), -- Denormalized for performance
|
||||
session_id TEXT UNIQUE NOT NULL,
|
||||
username VARCHAR(50),
|
||||
computer_name VARCHAR(100),
|
||||
hardware_id VARCHAR(100),
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
app_version VARCHAR(20),
|
||||
login_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, -- Alias for started_at
|
||||
last_activity TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, -- Alias for last_heartbeat
|
||||
logout_time TIMESTAMP WITH TIME ZONE, -- Alias for ended_at
|
||||
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TIMESTAMP WITH TIME ZONE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
active BOOLEAN DEFAULT TRUE -- Alias for is_active
|
||||
);
|
||||
|
||||
-- Audit-Log-Tabelle für Änderungsprotokolle
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER,
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
additional_info TEXT
|
||||
);
|
||||
|
||||
-- Index für bessere Performance bei Abfragen
|
||||
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC);
|
||||
CREATE INDEX idx_audit_log_username ON audit_log(username);
|
||||
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id);
|
||||
|
||||
-- Backup-Historie-Tabelle
|
||||
CREATE TABLE IF NOT EXISTS backup_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
filepath TEXT NOT NULL,
|
||||
filesize BIGINT,
|
||||
backup_type TEXT NOT NULL, -- 'manual' oder 'scheduled'
|
||||
status TEXT NOT NULL, -- 'success', 'failed', 'in_progress'
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT NOT NULL,
|
||||
tables_count INTEGER,
|
||||
records_count INTEGER,
|
||||
duration_seconds NUMERIC,
|
||||
is_encrypted BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Index für bessere Performance
|
||||
CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC);
|
||||
CREATE INDEX idx_backup_history_status ON backup_history(status);
|
||||
|
||||
-- Login-Attempts-Tabelle für Rate-Limiting
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
ip_address VARCHAR(45) PRIMARY KEY,
|
||||
attempt_count INTEGER DEFAULT 0,
|
||||
first_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
blocked_until TIMESTAMP WITH TIME ZONE NULL,
|
||||
last_username_tried TEXT,
|
||||
last_error_message TEXT
|
||||
);
|
||||
|
||||
-- Index für schnelle Abfragen
|
||||
CREATE INDEX idx_login_attempts_blocked_until ON login_attempts(blocked_until);
|
||||
CREATE INDEX idx_login_attempts_last_attempt ON login_attempts(last_attempt DESC);
|
||||
|
||||
-- Migration: Füge created_at zu licenses hinzu, falls noch nicht vorhanden
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'created_at') THEN
|
||||
ALTER TABLE licenses ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- Setze created_at für bestehende Einträge auf das valid_from Datum
|
||||
UPDATE licenses SET created_at = valid_from WHERE created_at IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ===================== RESOURCE POOL SYSTEM =====================
|
||||
|
||||
-- Haupttabelle für den Resource Pool
|
||||
CREATE TABLE IF NOT EXISTS resource_pools (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resource_type VARCHAR(20) NOT NULL CHECK (resource_type IN ('domain', 'ipv4', 'phone')),
|
||||
resource_value VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('available', 'allocated', 'quarantine')),
|
||||
allocated_to_license INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
|
||||
status_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
status_changed_by VARCHAR(50),
|
||||
quarantine_reason VARCHAR(100) CHECK (quarantine_reason IN ('abuse', 'defect', 'maintenance', 'blacklisted', 'expired', 'review', NULL)),
|
||||
quarantine_until TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
notes TEXT,
|
||||
is_fake BOOLEAN DEFAULT FALSE,
|
||||
UNIQUE(resource_type, resource_value)
|
||||
);
|
||||
|
||||
-- Resource History für vollständige Nachverfolgbarkeit
|
||||
CREATE TABLE IF NOT EXISTS resource_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL CHECK (action IN ('allocated', 'deallocated', 'quarantined', 'released', 'created', 'deleted')),
|
||||
action_by VARCHAR(50) NOT NULL,
|
||||
action_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
details JSONB,
|
||||
ip_address TEXT
|
||||
);
|
||||
|
||||
-- Resource Metrics für Performance-Tracking und ROI
|
||||
CREATE TABLE IF NOT EXISTS resource_metrics (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||||
metric_date DATE NOT NULL,
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
performance_score DECIMAL(5,2) DEFAULT 0.00,
|
||||
cost DECIMAL(10,2) DEFAULT 0.00,
|
||||
revenue DECIMAL(10,2) DEFAULT 0.00,
|
||||
issues_count INTEGER DEFAULT 0,
|
||||
availability_percent DECIMAL(5,2) DEFAULT 100.00,
|
||||
UNIQUE(resource_id, metric_date)
|
||||
);
|
||||
|
||||
-- Zuordnungstabelle zwischen Lizenzen und Ressourcen
|
||||
CREATE TABLE IF NOT EXISTS license_resources (
|
||||
id SERIAL PRIMARY KEY,
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||||
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
assigned_by VARCHAR(50),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
UNIQUE(license_id, resource_id)
|
||||
);
|
||||
|
||||
-- Erweiterung der licenses Tabelle um Resource-Counts
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'domain_count') THEN
|
||||
ALTER TABLE licenses
|
||||
ADD COLUMN domain_count INTEGER DEFAULT 1 CHECK (domain_count >= 0 AND domain_count <= 10),
|
||||
ADD COLUMN ipv4_count INTEGER DEFAULT 1 CHECK (ipv4_count >= 0 AND ipv4_count <= 10),
|
||||
ADD COLUMN phone_count INTEGER DEFAULT 1 CHECK (phone_count >= 0 AND phone_count <= 10);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Erweiterung der licenses Tabelle um device_limit
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'device_limit') THEN
|
||||
ALTER TABLE licenses
|
||||
ADD COLUMN device_limit INTEGER DEFAULT 3 CHECK (device_limit >= 1 AND device_limit <= 10);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Tabelle für Geräte-Registrierungen
|
||||
CREATE TABLE IF NOT EXISTS device_registrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
hardware_id TEXT NOT NULL,
|
||||
device_name TEXT,
|
||||
operating_system TEXT,
|
||||
first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
deactivated_at TIMESTAMP WITH TIME ZONE,
|
||||
deactivated_by TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
UNIQUE(license_id, hardware_id)
|
||||
);
|
||||
|
||||
-- Indizes für device_registrations
|
||||
CREATE INDEX IF NOT EXISTS idx_device_license ON device_registrations(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_hardware ON device_registrations(hardware_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_active ON device_registrations(license_id, is_active) WHERE is_active = TRUE;
|
||||
|
||||
-- Indizes für Performance
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_status ON resource_pools(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_type_status ON resource_pools(resource_type, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_allocated ON resource_pools(allocated_to_license) WHERE allocated_to_license IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_quarantine ON resource_pools(quarantine_until) WHERE status = 'quarantine';
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_history_date ON resource_history(action_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE;
|
||||
|
||||
-- Users table for authentication with password and 2FA support
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
totp_secret VARCHAR(32),
|
||||
totp_enabled BOOLEAN DEFAULT FALSE,
|
||||
backup_codes TEXT, -- JSON array of hashed backup codes
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
password_reset_token VARCHAR(64),
|
||||
password_reset_expires TIMESTAMP WITH TIME ZONE,
|
||||
failed_2fa_attempts INTEGER DEFAULT 0,
|
||||
last_failed_2fa TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Index for faster login lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL;
|
||||
|
||||
-- Migration: Add is_fake column to licenses if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'is_fake') THEN
|
||||
ALTER TABLE licenses ADD COLUMN is_fake BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Mark all existing licenses as fake data
|
||||
UPDATE licenses SET is_fake = TRUE;
|
||||
|
||||
-- Add index for better performance when filtering fake data
|
||||
CREATE INDEX idx_licenses_is_fake ON licenses(is_fake);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add is_fake column to customers if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'customers' AND column_name = 'is_fake') THEN
|
||||
ALTER TABLE customers ADD COLUMN is_fake BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Mark all existing customers as fake data
|
||||
UPDATE customers SET is_fake = TRUE;
|
||||
|
||||
-- Add index for better performance
|
||||
CREATE INDEX idx_customers_is_fake ON customers(is_fake);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add is_fake column to resource_pools if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'resource_pools' AND column_name = 'is_fake') THEN
|
||||
ALTER TABLE resource_pools ADD COLUMN is_fake BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Mark all existing resources as fake data
|
||||
UPDATE resource_pools SET is_fake = TRUE;
|
||||
|
||||
-- Add index for better performance
|
||||
CREATE INDEX idx_resource_pools_is_fake ON resource_pools(is_fake);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add missing columns to sessions table
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Add license_key column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sessions' AND column_name = 'license_key') THEN
|
||||
ALTER TABLE sessions ADD COLUMN license_key VARCHAR(60);
|
||||
END IF;
|
||||
|
||||
-- Add username column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sessions' AND column_name = 'username') THEN
|
||||
ALTER TABLE sessions ADD COLUMN username VARCHAR(50);
|
||||
END IF;
|
||||
|
||||
-- Add computer_name column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sessions' AND column_name = 'computer_name') THEN
|
||||
ALTER TABLE sessions ADD COLUMN computer_name VARCHAR(100);
|
||||
END IF;
|
||||
|
||||
-- Add hardware_id column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sessions' AND column_name = 'hardware_id') THEN
|
||||
ALTER TABLE sessions ADD COLUMN hardware_id VARCHAR(100);
|
||||
END IF;
|
||||
|
||||
-- Add app_version column
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sessions' AND column_name = 'app_version') THEN
|
||||
ALTER TABLE sessions ADD COLUMN app_version VARCHAR(20);
|
||||
END IF;
|
||||
|
||||
-- Add login_time as alias for started_at
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sessions' AND column_name = 'login_time') THEN
|
||||
ALTER TABLE sessions ADD COLUMN login_time TIMESTAMP WITH TIME ZONE;
|
||||
UPDATE sessions SET login_time = started_at;
|
||||
END IF;
|
||||
|
||||
-- Add last_activity as alias for last_heartbeat
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sessions' AND column_name = 'last_activity') THEN
|
||||
ALTER TABLE sessions ADD COLUMN last_activity TIMESTAMP WITH TIME ZONE;
|
||||
UPDATE sessions SET last_activity = last_heartbeat;
|
||||
END IF;
|
||||
|
||||
-- Add logout_time as alias for ended_at
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sessions' AND column_name = 'logout_time') THEN
|
||||
ALTER TABLE sessions ADD COLUMN logout_time TIMESTAMP WITH TIME ZONE;
|
||||
UPDATE sessions SET logout_time = ended_at;
|
||||
END IF;
|
||||
|
||||
-- Add active as alias for is_active
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'sessions' AND column_name = 'active') THEN
|
||||
ALTER TABLE sessions ADD COLUMN active BOOLEAN DEFAULT TRUE;
|
||||
UPDATE sessions SET active = is_active;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ===================== LICENSE SERVER TABLES =====================
|
||||
-- Following best practices: snake_case for DB fields, clear naming conventions
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- License tokens for offline validation
|
||||
CREATE TABLE IF NOT EXISTS license_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
token VARCHAR(512) NOT NULL UNIQUE,
|
||||
hardware_id VARCHAR(255) NOT NULL,
|
||||
valid_until TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_validated TIMESTAMP,
|
||||
validation_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_token ON license_tokens(token);
|
||||
CREATE INDEX idx_hardware ON license_tokens(hardware_id);
|
||||
CREATE INDEX idx_valid_until ON license_tokens(valid_until);
|
||||
|
||||
-- Heartbeat tracking with partitioning support
|
||||
CREATE TABLE IF NOT EXISTS license_heartbeats (
|
||||
id BIGSERIAL,
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
hardware_id VARCHAR(255) NOT NULL,
|
||||
ip_address INET,
|
||||
user_agent VARCHAR(500),
|
||||
app_version VARCHAR(50),
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
session_data JSONB,
|
||||
PRIMARY KEY (id, timestamp)
|
||||
) PARTITION BY RANGE (timestamp);
|
||||
|
||||
-- Create partitions for the current and next month
|
||||
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_01 PARTITION OF license_heartbeats
|
||||
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_02 PARTITION OF license_heartbeats
|
||||
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
|
||||
|
||||
-- Add June 2025 partition for current month
|
||||
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_06 PARTITION OF license_heartbeats
|
||||
FOR VALUES FROM ('2025-06-01') TO ('2025-07-01');
|
||||
|
||||
CREATE INDEX idx_heartbeat_license_time ON license_heartbeats(license_id, timestamp DESC);
|
||||
CREATE INDEX idx_heartbeat_hardware_time ON license_heartbeats(hardware_id, timestamp DESC);
|
||||
|
||||
-- Activation events tracking
|
||||
CREATE TABLE IF NOT EXISTS activation_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(50) NOT NULL CHECK (event_type IN ('activation', 'deactivation', 'reactivation', 'transfer')),
|
||||
hardware_id VARCHAR(255),
|
||||
previous_hardware_id VARCHAR(255),
|
||||
ip_address INET,
|
||||
user_agent VARCHAR(500),
|
||||
success BOOLEAN DEFAULT true,
|
||||
error_message TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_license_events ON activation_events(license_id, created_at DESC);
|
||||
CREATE INDEX idx_event_type ON activation_events(event_type, created_at DESC);
|
||||
|
||||
-- API rate limiting
|
||||
CREATE TABLE IF NOT EXISTS api_rate_limits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
api_key VARCHAR(255) NOT NULL UNIQUE,
|
||||
requests_per_minute INTEGER DEFAULT 60,
|
||||
requests_per_hour INTEGER DEFAULT 1000,
|
||||
requests_per_day INTEGER DEFAULT 10000,
|
||||
burst_size INTEGER DEFAULT 100,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Anomaly detection
|
||||
CREATE TABLE IF NOT EXISTS anomaly_detections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_id INTEGER REFERENCES licenses(id),
|
||||
anomaly_type VARCHAR(100) NOT NULL CHECK (anomaly_type IN ('multiple_ips', 'rapid_hardware_change', 'suspicious_pattern', 'concurrent_use', 'geo_anomaly')),
|
||||
severity VARCHAR(20) NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
|
||||
details JSONB NOT NULL,
|
||||
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved BOOLEAN DEFAULT false,
|
||||
resolved_at TIMESTAMP,
|
||||
resolved_by VARCHAR(255),
|
||||
action_taken TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_unresolved ON anomaly_detections(resolved, severity, detected_at DESC);
|
||||
CREATE INDEX idx_license_anomalies ON anomaly_detections(license_id, detected_at DESC);
|
||||
|
||||
-- API clients for authentication
|
||||
CREATE TABLE IF NOT EXISTS api_clients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_name VARCHAR(255) NOT NULL,
|
||||
api_key VARCHAR(255) NOT NULL UNIQUE,
|
||||
secret_key VARCHAR(255) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
allowed_endpoints TEXT[],
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Feature flags for gradual rollout
|
||||
CREATE TABLE IF NOT EXISTS feature_flags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
feature_name VARCHAR(100) NOT NULL UNIQUE,
|
||||
is_enabled BOOLEAN DEFAULT false,
|
||||
rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage >= 0 AND rollout_percentage <= 100),
|
||||
whitelist_license_ids INTEGER[],
|
||||
blacklist_license_ids INTEGER[],
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert default feature flags
|
||||
INSERT INTO feature_flags (feature_name, is_enabled, rollout_percentage) VALUES
|
||||
('anomaly_detection', true, 100),
|
||||
('offline_tokens', true, 100),
|
||||
('advanced_analytics', false, 0),
|
||||
('geo_restriction', false, 0)
|
||||
ON CONFLICT (feature_name) DO NOTHING;
|
||||
|
||||
-- Session management for concurrent use tracking
|
||||
CREATE TABLE IF NOT EXISTS active_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
hardware_id VARCHAR(255) NOT NULL,
|
||||
session_token VARCHAR(512) NOT NULL UNIQUE,
|
||||
ip_address INET,
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_session_license ON active_sessions(license_id);
|
||||
CREATE INDEX idx_session_expires ON active_sessions(expires_at);
|
||||
|
||||
-- Update trigger for updated_at columns
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_api_rate_limits_updated_at BEFORE UPDATE ON api_rate_limits
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_api_clients_updated_at BEFORE UPDATE ON api_clients
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_feature_flags_updated_at BEFORE UPDATE ON feature_flags
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Function to automatically create monthly partitions for heartbeats
|
||||
CREATE OR REPLACE FUNCTION create_monthly_partition()
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
start_date date;
|
||||
end_date date;
|
||||
partition_name text;
|
||||
BEGIN
|
||||
start_date := date_trunc('month', CURRENT_DATE + interval '1 month');
|
||||
end_date := start_date + interval '1 month';
|
||||
partition_name := 'license_heartbeats_' || to_char(start_date, 'YYYY_MM');
|
||||
|
||||
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)',
|
||||
partition_name, start_date, end_date);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Migration: Add max_devices column to licenses if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'max_devices') THEN
|
||||
ALTER TABLE licenses ADD COLUMN max_devices INTEGER DEFAULT 3 CHECK (max_devices >= 1);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add expires_at column to licenses if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'expires_at') THEN
|
||||
ALTER TABLE licenses ADD COLUMN expires_at TIMESTAMP;
|
||||
-- Set expires_at based on valid_until for existing licenses
|
||||
UPDATE licenses SET expires_at = valid_until::timestamp WHERE expires_at IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add features column to licenses if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'features') THEN
|
||||
ALTER TABLE licenses ADD COLUMN features TEXT[] DEFAULT '{}';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add updated_at column to licenses if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'updated_at') THEN
|
||||
ALTER TABLE licenses ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||
CREATE TRIGGER update_licenses_updated_at BEFORE UPDATE ON licenses
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
|
||||
-- Migration: Add device_type column to device_registrations table
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'device_registrations' AND column_name = 'device_type') THEN
|
||||
ALTER TABLE device_registrations ADD COLUMN device_type VARCHAR(50) DEFAULT 'unknown';
|
||||
|
||||
-- Update existing records to have a device_type based on operating system
|
||||
UPDATE device_registrations
|
||||
SET device_type = CASE
|
||||
WHEN operating_system ILIKE '%windows%' THEN 'desktop'
|
||||
WHEN operating_system ILIKE '%mac%' THEN 'desktop'
|
||||
WHEN operating_system ILIKE '%linux%' THEN 'desktop'
|
||||
WHEN operating_system ILIKE '%android%' THEN 'mobile'
|
||||
WHEN operating_system ILIKE '%ios%' THEN 'mobile'
|
||||
ELSE 'unknown'
|
||||
END
|
||||
WHERE device_type IS NULL OR device_type = 'unknown';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Client configuration table for Account Forger
|
||||
CREATE TABLE IF NOT EXISTS client_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
client_name VARCHAR(100) NOT NULL DEFAULT 'Account Forger',
|
||||
api_key VARCHAR(255) NOT NULL,
|
||||
heartbeat_interval INTEGER DEFAULT 30, -- seconds
|
||||
session_timeout INTEGER DEFAULT 60, -- seconds (2x heartbeat)
|
||||
current_version VARCHAR(20) NOT NULL,
|
||||
minimum_version VARCHAR(20) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- License sessions for single-session enforcement
|
||||
CREATE TABLE IF NOT EXISTS license_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
hardware_id VARCHAR(255) NOT NULL,
|
||||
ip_address INET,
|
||||
client_version VARCHAR(20),
|
||||
session_token VARCHAR(255) UNIQUE NOT NULL,
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_heartbeat TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(license_id) -- Only one active session per license
|
||||
);
|
||||
|
||||
-- Session history for debugging
|
||||
CREATE TABLE IF NOT EXISTS session_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
hardware_id VARCHAR(255) NOT NULL,
|
||||
ip_address INET,
|
||||
client_version VARCHAR(20),
|
||||
started_at TIMESTAMP,
|
||||
ended_at TIMESTAMP,
|
||||
end_reason VARCHAR(50) -- 'normal', 'timeout', 'forced', 'replaced'
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_license_sessions_license_id ON license_sessions(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_license_sessions_last_heartbeat ON license_sessions(last_heartbeat);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_history_license_id ON session_history(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_history_ended_at ON session_history(ended_at);
|
||||
|
||||
-- Insert default client configuration if not exists
|
||||
INSERT INTO client_configs (client_name, api_key, current_version, minimum_version)
|
||||
VALUES ('Account Forger', 'AF-' || gen_random_uuid()::text, '1.0.0', '1.0.0')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ===================== SYSTEM API KEY TABLE =====================
|
||||
-- Single API key for system-wide authentication
|
||||
CREATE TABLE IF NOT EXISTS system_api_key (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), -- Ensures single row
|
||||
api_key VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
regenerated_at TIMESTAMP WITH TIME ZONE,
|
||||
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
created_by VARCHAR(50),
|
||||
regenerated_by VARCHAR(50)
|
||||
);
|
||||
|
||||
-- Function to generate API key with AF-YYYY- prefix
|
||||
CREATE OR REPLACE FUNCTION generate_api_key() RETURNS VARCHAR AS $$
|
||||
DECLARE
|
||||
year_part VARCHAR(4);
|
||||
random_part VARCHAR(32);
|
||||
BEGIN
|
||||
year_part := to_char(CURRENT_DATE, 'YYYY');
|
||||
random_part := upper(substring(md5(random()::text || clock_timestamp()::text) from 1 for 32));
|
||||
RETURN 'AF-' || year_part || '-' || random_part;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Initialize with a default API key if none exists
|
||||
INSERT INTO system_api_key (api_key, created_by)
|
||||
SELECT generate_api_key(), 'system'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM system_api_key);
|
||||
|
||||
-- Audit trigger for API key changes
|
||||
CREATE OR REPLACE FUNCTION audit_api_key_changes() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE' AND OLD.api_key != NEW.api_key THEN
|
||||
INSERT INTO audit_log (
|
||||
timestamp,
|
||||
username,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
old_values,
|
||||
new_values,
|
||||
additional_info
|
||||
) VALUES (
|
||||
CURRENT_TIMESTAMP,
|
||||
COALESCE(NEW.regenerated_by, 'system'),
|
||||
'api_key_regenerated',
|
||||
'system_api_key',
|
||||
NEW.id,
|
||||
jsonb_build_object('api_key', LEFT(OLD.api_key, 8) || '...'),
|
||||
jsonb_build_object('api_key', LEFT(NEW.api_key, 8) || '...'),
|
||||
'API Key regenerated'
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER audit_system_api_key_changes
|
||||
AFTER UPDATE ON system_api_key
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_api_key_changes();
|
||||
6
v2_adminpanel/leads/__init__.py
Normale Datei
6
v2_adminpanel/leads/__init__.py
Normale Datei
@@ -0,0 +1,6 @@
|
||||
# Lead Management Module
|
||||
from flask import Blueprint
|
||||
|
||||
leads_bp = Blueprint('leads', __name__, template_folder='templates')
|
||||
|
||||
from . import routes
|
||||
48
v2_adminpanel/leads/models.py
Normale Datei
48
v2_adminpanel/leads/models.py
Normale Datei
@@ -0,0 +1,48 @@
|
||||
# Lead Management Data Models
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
@dataclass
|
||||
class Institution:
|
||||
id: UUID
|
||||
name: str
|
||||
metadata: Dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: str
|
||||
contact_count: Optional[int] = 0
|
||||
|
||||
@dataclass
|
||||
class Contact:
|
||||
id: UUID
|
||||
institution_id: UUID
|
||||
first_name: str
|
||||
last_name: str
|
||||
position: Optional[str]
|
||||
extra_fields: Dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
institution_name: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class ContactDetail:
|
||||
id: UUID
|
||||
contact_id: UUID
|
||||
detail_type: str # 'phone', 'email'
|
||||
detail_value: str
|
||||
detail_label: Optional[str] # 'Mobil', 'Geschäftlich', etc.
|
||||
is_primary: bool
|
||||
created_at: datetime
|
||||
|
||||
@dataclass
|
||||
class Note:
|
||||
id: UUID
|
||||
contact_id: UUID
|
||||
note_text: str
|
||||
version: int
|
||||
is_current: bool
|
||||
created_at: datetime
|
||||
created_by: str
|
||||
parent_note_id: Optional[UUID] = None
|
||||
359
v2_adminpanel/leads/repositories.py
Normale Datei
359
v2_adminpanel/leads/repositories.py
Normale Datei
@@ -0,0 +1,359 @@
|
||||
# Database Repository for Lead Management
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from uuid import UUID
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
class LeadRepository:
|
||||
def __init__(self, get_db_connection):
|
||||
self.get_db_connection = get_db_connection
|
||||
|
||||
# Institution Methods
|
||||
def get_institutions_with_counts(self) -> List[Dict[str, Any]]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
i.id,
|
||||
i.name,
|
||||
i.metadata,
|
||||
i.created_at,
|
||||
i.updated_at,
|
||||
i.created_by,
|
||||
COUNT(c.id) as contact_count
|
||||
FROM lead_institutions i
|
||||
LEFT JOIN lead_contacts c ON c.institution_id = i.id
|
||||
GROUP BY i.id
|
||||
ORDER BY i.name
|
||||
"""
|
||||
|
||||
cur.execute(query)
|
||||
results = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
return results
|
||||
|
||||
def create_institution(self, name: str, created_by: str) -> Dict[str, Any]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = """
|
||||
INSERT INTO lead_institutions (name, created_by)
|
||||
VALUES (%s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
cur.execute(query, (name, created_by))
|
||||
result = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
return result
|
||||
|
||||
def get_institution_by_id(self, institution_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = """
|
||||
SELECT * FROM lead_institutions WHERE id = %s
|
||||
"""
|
||||
|
||||
cur.execute(query, (str(institution_id),))
|
||||
result = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
return result
|
||||
|
||||
def update_institution(self, institution_id: UUID, name: str) -> Dict[str, Any]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = """
|
||||
UPDATE lead_institutions
|
||||
SET name = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
cur.execute(query, (name, str(institution_id)))
|
||||
result = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
return result
|
||||
|
||||
# Contact Methods
|
||||
def get_contacts_by_institution(self, institution_id: UUID) -> List[Dict[str, Any]]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
c.*,
|
||||
i.name as institution_name
|
||||
FROM lead_contacts c
|
||||
JOIN lead_institutions i ON i.id = c.institution_id
|
||||
WHERE c.institution_id = %s
|
||||
ORDER BY c.last_name, c.first_name
|
||||
"""
|
||||
|
||||
cur.execute(query, (str(institution_id),))
|
||||
results = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
return results
|
||||
|
||||
def create_contact(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = """
|
||||
INSERT INTO lead_contacts
|
||||
(institution_id, first_name, last_name, position, extra_fields)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
cur.execute(query, (
|
||||
str(data['institution_id']),
|
||||
data['first_name'],
|
||||
data['last_name'],
|
||||
data.get('position'),
|
||||
psycopg2.extras.Json(data.get('extra_fields', {}))
|
||||
))
|
||||
result = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
return result
|
||||
|
||||
def get_contact_with_details(self, contact_id: UUID) -> Dict[str, Any]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# Get contact base info
|
||||
query = """
|
||||
SELECT
|
||||
c.*,
|
||||
i.name as institution_name
|
||||
FROM lead_contacts c
|
||||
JOIN lead_institutions i ON i.id = c.institution_id
|
||||
WHERE c.id = %s
|
||||
"""
|
||||
|
||||
cur.execute(query, (str(contact_id),))
|
||||
contact = cur.fetchone()
|
||||
|
||||
if contact:
|
||||
# Get contact details (phones, emails)
|
||||
details_query = """
|
||||
SELECT * FROM lead_contact_details
|
||||
WHERE contact_id = %s
|
||||
ORDER BY detail_type, is_primary DESC, created_at
|
||||
"""
|
||||
cur.execute(details_query, (str(contact_id),))
|
||||
contact['details'] = cur.fetchall()
|
||||
|
||||
# Get notes
|
||||
notes_query = """
|
||||
SELECT * FROM lead_notes
|
||||
WHERE contact_id = %s AND is_current = true
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
cur.execute(notes_query, (str(contact_id),))
|
||||
contact['notes'] = cur.fetchall()
|
||||
|
||||
cur.close()
|
||||
|
||||
return contact
|
||||
|
||||
def update_contact(self, contact_id: UUID, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = """
|
||||
UPDATE lead_contacts
|
||||
SET first_name = %s, last_name = %s, position = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
cur.execute(query, (
|
||||
data['first_name'],
|
||||
data['last_name'],
|
||||
data.get('position'),
|
||||
str(contact_id)
|
||||
))
|
||||
result = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
return result
|
||||
|
||||
# Contact Details Methods
|
||||
def add_contact_detail(self, contact_id: UUID, detail_type: str,
|
||||
detail_value: str, detail_label: str = None) -> Dict[str, Any]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = """
|
||||
INSERT INTO lead_contact_details
|
||||
(contact_id, detail_type, detail_value, detail_label)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
cur.execute(query, (
|
||||
str(contact_id),
|
||||
detail_type,
|
||||
detail_value,
|
||||
detail_label
|
||||
))
|
||||
result = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
return result
|
||||
|
||||
def get_contact_detail_by_id(self, detail_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = "SELECT * FROM lead_contact_details WHERE id = %s"
|
||||
cur.execute(query, (str(detail_id),))
|
||||
result = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
return result
|
||||
|
||||
def update_contact_detail(self, detail_id: UUID, detail_value: str,
|
||||
detail_label: str = None) -> Dict[str, Any]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = """
|
||||
UPDATE lead_contact_details
|
||||
SET detail_value = %s, detail_label = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
cur.execute(query, (detail_value, detail_label, str(detail_id)))
|
||||
result = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
return result
|
||||
|
||||
def delete_contact_detail(self, detail_id: UUID) -> bool:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
query = "DELETE FROM lead_contact_details WHERE id = %s"
|
||||
cur.execute(query, (str(detail_id),))
|
||||
|
||||
deleted = cur.rowcount > 0
|
||||
cur.close()
|
||||
|
||||
return deleted
|
||||
|
||||
# Notes Methods
|
||||
def create_note(self, contact_id: UUID, note_text: str, created_by: str) -> Dict[str, Any]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = """
|
||||
INSERT INTO lead_notes
|
||||
(contact_id, note_text, created_by)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
cur.execute(query, (
|
||||
str(contact_id),
|
||||
note_text,
|
||||
created_by
|
||||
))
|
||||
result = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
return result
|
||||
|
||||
def update_note(self, note_id: UUID, note_text: str, updated_by: str) -> Dict[str, Any]:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# First, mark current version as not current
|
||||
update_old = """
|
||||
UPDATE lead_notes
|
||||
SET is_current = false
|
||||
WHERE id = %s
|
||||
"""
|
||||
cur.execute(update_old, (str(note_id),))
|
||||
|
||||
# Create new version
|
||||
create_new = """
|
||||
INSERT INTO lead_notes
|
||||
(contact_id, note_text, created_by, parent_note_id, version)
|
||||
SELECT contact_id, %s, %s, %s, version + 1
|
||||
FROM lead_notes
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
"""
|
||||
|
||||
cur.execute(create_new, (
|
||||
note_text,
|
||||
updated_by,
|
||||
str(note_id),
|
||||
str(note_id)
|
||||
))
|
||||
result = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
return result
|
||||
|
||||
def delete_note(self, note_id: UUID) -> bool:
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
# Soft delete by marking as not current
|
||||
query = """
|
||||
UPDATE lead_notes
|
||||
SET is_current = false
|
||||
WHERE id = %s
|
||||
"""
|
||||
cur.execute(query, (str(note_id),))
|
||||
|
||||
deleted = cur.rowcount > 0
|
||||
cur.close()
|
||||
|
||||
return deleted
|
||||
|
||||
def get_all_contacts_with_institutions(self) -> List[Dict[str, Any]]:
|
||||
"""Get all contacts with their institution information"""
|
||||
with self.get_db_connection() as conn:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
c.id,
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
c.position,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
c.institution_id,
|
||||
i.name as institution_name,
|
||||
(SELECT COUNT(*) FROM lead_contact_details
|
||||
WHERE contact_id = c.id AND detail_type = 'phone') as phone_count,
|
||||
(SELECT COUNT(*) FROM lead_contact_details
|
||||
WHERE contact_id = c.id AND detail_type = 'email') as email_count,
|
||||
(SELECT COUNT(*) FROM lead_notes
|
||||
WHERE contact_id = c.id AND is_current = true) as note_count
|
||||
FROM lead_contacts c
|
||||
JOIN lead_institutions i ON i.id = c.institution_id
|
||||
ORDER BY c.last_name, c.first_name
|
||||
"""
|
||||
|
||||
cur.execute(query)
|
||||
results = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
return results
|
||||
397
v2_adminpanel/leads/routes.py
Normale Datei
397
v2_adminpanel/leads/routes.py
Normale Datei
@@ -0,0 +1,397 @@
|
||||
# Routes for Lead Management
|
||||
from flask import render_template, request, jsonify, redirect, url_for, flash
|
||||
from auth.decorators import login_required
|
||||
from flask import session as flask_session
|
||||
from . import leads_bp
|
||||
from .services import LeadService
|
||||
from .repositories import LeadRepository
|
||||
from db import get_db_connection
|
||||
from uuid import UUID
|
||||
import traceback
|
||||
|
||||
# Service will be initialized per request
|
||||
lead_repository = None
|
||||
lead_service = None
|
||||
|
||||
def get_lead_service():
|
||||
"""Get or create lead service instance"""
|
||||
global lead_repository, lead_service
|
||||
if lead_service is None:
|
||||
lead_repository = LeadRepository(get_db_connection) # Pass the function, not call it
|
||||
lead_service = LeadService(lead_repository)
|
||||
return lead_service
|
||||
|
||||
# HTML Routes
|
||||
@leads_bp.route('/management')
|
||||
@login_required
|
||||
def lead_management():
|
||||
"""Lead Management Dashboard"""
|
||||
try:
|
||||
# Get institutions with contact counts
|
||||
institutions = get_lead_service().list_institutions()
|
||||
|
||||
# Get all contacts with institution names
|
||||
all_contacts = get_lead_service().list_all_contacts()
|
||||
|
||||
# Calculate totals
|
||||
total_institutions = len(institutions)
|
||||
total_contacts = len(all_contacts)
|
||||
|
||||
return render_template('leads/lead_management.html',
|
||||
total_institutions=total_institutions,
|
||||
total_contacts=total_contacts,
|
||||
institutions=institutions,
|
||||
all_contacts=all_contacts)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Error in lead_management: {str(e)}")
|
||||
print(traceback.format_exc())
|
||||
flash(f'Fehler beim Laden des Dashboards: {str(e)}', 'error')
|
||||
current_user = flask_session.get('username', 'System')
|
||||
return render_template('leads/lead_management.html',
|
||||
total_institutions=0,
|
||||
total_contacts=0,
|
||||
institutions=[],
|
||||
all_contacts=[])
|
||||
|
||||
@leads_bp.route('/')
|
||||
@login_required
|
||||
def institutions():
|
||||
"""List all institutions"""
|
||||
try:
|
||||
institutions = get_lead_service().list_institutions()
|
||||
return render_template('leads/institutions.html', institutions=institutions)
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Laden der Institutionen: {str(e)}', 'error')
|
||||
return render_template('leads/institutions.html', institutions=[])
|
||||
|
||||
@leads_bp.route('/institution/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_institution():
|
||||
"""Add new institution from form"""
|
||||
try:
|
||||
name = request.form.get('name')
|
||||
if not name:
|
||||
flash('Name ist erforderlich', 'error')
|
||||
return redirect(url_for('leads.lead_management'))
|
||||
|
||||
# Add institution
|
||||
get_lead_service().create_institution(name, flask_session.get('username', 'System'))
|
||||
flash(f'Institution "{name}" wurde erfolgreich hinzugefügt', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Hinzufügen der Institution: {str(e)}', 'error')
|
||||
|
||||
return redirect(url_for('leads.lead_management'))
|
||||
|
||||
@leads_bp.route('/contact/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_contact():
|
||||
"""Add new contact from form"""
|
||||
try:
|
||||
data = {
|
||||
'institution_id': request.form.get('institution_id'),
|
||||
'first_name': request.form.get('first_name'),
|
||||
'last_name': request.form.get('last_name'),
|
||||
'position': request.form.get('position')
|
||||
}
|
||||
|
||||
# Validate required fields
|
||||
if not data['institution_id'] or not data['first_name'] or not data['last_name']:
|
||||
flash('Institution, Vorname und Nachname sind erforderlich', 'error')
|
||||
return redirect(url_for('leads.lead_management'))
|
||||
|
||||
# Create contact
|
||||
contact = get_lead_service().create_contact(data, flask_session.get('username', 'System'))
|
||||
|
||||
# Add email if provided
|
||||
email = request.form.get('email')
|
||||
if email:
|
||||
get_lead_service().add_email(contact['id'], email, 'Primär', flask_session.get('username', 'System'))
|
||||
|
||||
# Add phone if provided
|
||||
phone = request.form.get('phone')
|
||||
if phone:
|
||||
get_lead_service().add_phone(contact['id'], phone, 'Primär', flask_session.get('username', 'System'))
|
||||
|
||||
flash(f'Kontakt "{data["first_name"]} {data["last_name"]}" wurde erfolgreich hinzugefügt', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Hinzufügen des Kontakts: {str(e)}', 'error')
|
||||
|
||||
return redirect(url_for('leads.lead_management'))
|
||||
|
||||
@leads_bp.route('/institution/<uuid:institution_id>')
|
||||
@login_required
|
||||
def institution_detail(institution_id):
|
||||
"""Show institution with all contacts"""
|
||||
try:
|
||||
# Get institution through repository
|
||||
service = get_lead_service()
|
||||
institution = service.repo.get_institution_by_id(institution_id)
|
||||
if not institution:
|
||||
flash('Institution nicht gefunden', 'error')
|
||||
return redirect(url_for('leads.institutions'))
|
||||
|
||||
contacts = get_lead_service().list_contacts_by_institution(institution_id)
|
||||
return render_template('leads/institution_detail.html',
|
||||
institution=institution,
|
||||
contacts=contacts)
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Laden der Institution: {str(e)}', 'error')
|
||||
return redirect(url_for('leads.institutions'))
|
||||
|
||||
@leads_bp.route('/contact/<uuid:contact_id>')
|
||||
@login_required
|
||||
def contact_detail(contact_id):
|
||||
"""Show contact details with notes"""
|
||||
try:
|
||||
contact = get_lead_service().get_contact_details(contact_id)
|
||||
return render_template('leads/contact_detail.html', contact=contact)
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Laden des Kontakts: {str(e)}', 'error')
|
||||
return redirect(url_for('leads.institutions'))
|
||||
|
||||
@leads_bp.route('/contacts')
|
||||
@login_required
|
||||
def all_contacts():
|
||||
"""Show all contacts across all institutions"""
|
||||
try:
|
||||
contacts = get_lead_service().list_all_contacts()
|
||||
return render_template('leads/all_contacts.html', contacts=contacts)
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Laden der Kontakte: {str(e)}', 'error')
|
||||
return render_template('leads/all_contacts.html', contacts=[])
|
||||
|
||||
# API Routes
|
||||
@leads_bp.route('/api/institutions', methods=['POST'])
|
||||
@login_required
|
||||
def create_institution():
|
||||
"""Create new institution"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
institution = get_lead_service().create_institution(
|
||||
data['name'],
|
||||
flask_session.get('username')
|
||||
)
|
||||
return jsonify({'success': True, 'institution': institution})
|
||||
except ValueError as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@leads_bp.route('/api/institutions/<uuid:institution_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_institution(institution_id):
|
||||
"""Update institution"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
institution = get_lead_service().update_institution(
|
||||
institution_id,
|
||||
data['name'],
|
||||
flask_session.get('username')
|
||||
)
|
||||
return jsonify({'success': True, 'institution': institution})
|
||||
except ValueError as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@leads_bp.route('/api/contacts', methods=['POST'])
|
||||
@login_required
|
||||
def create_contact():
|
||||
"""Create new contact"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
contact = get_lead_service().create_contact(data, flask_session.get('username'))
|
||||
return jsonify({'success': True, 'contact': contact})
|
||||
except ValueError as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@leads_bp.route('/api/contacts/<uuid:contact_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_contact(contact_id):
|
||||
"""Update contact"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
contact = get_lead_service().update_contact(
|
||||
contact_id,
|
||||
data,
|
||||
flask_session.get('username')
|
||||
)
|
||||
return jsonify({'success': True, 'contact': contact})
|
||||
except ValueError as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@leads_bp.route('/api/contacts/<uuid:contact_id>/phones', methods=['POST'])
|
||||
@login_required
|
||||
def add_phone(contact_id):
|
||||
"""Add phone to contact"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
detail = get_lead_service().add_phone(
|
||||
contact_id,
|
||||
data['phone_number'],
|
||||
data.get('phone_type'),
|
||||
flask_session.get('username')
|
||||
)
|
||||
return jsonify({'success': True, 'detail': detail})
|
||||
except ValueError as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@leads_bp.route('/api/contacts/<uuid:contact_id>/emails', methods=['POST'])
|
||||
@login_required
|
||||
def add_email(contact_id):
|
||||
"""Add email to contact"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
detail = get_lead_service().add_email(
|
||||
contact_id,
|
||||
data['email'],
|
||||
data.get('email_type'),
|
||||
flask_session.get('username')
|
||||
)
|
||||
return jsonify({'success': True, 'detail': detail})
|
||||
except ValueError as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@leads_bp.route('/api/details/<uuid:detail_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_detail(detail_id):
|
||||
"""Update contact detail (phone/email)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
detail = get_lead_service().update_contact_detail(
|
||||
detail_id,
|
||||
data['detail_value'],
|
||||
data.get('detail_label'),
|
||||
flask_session.get('username')
|
||||
)
|
||||
return jsonify({'success': True, 'detail': detail})
|
||||
except ValueError as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@leads_bp.route('/api/details/<uuid:detail_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_detail(detail_id):
|
||||
"""Delete contact detail"""
|
||||
try:
|
||||
success = get_lead_service().delete_contact_detail(
|
||||
detail_id,
|
||||
flask_session.get('username')
|
||||
)
|
||||
return jsonify({'success': success})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@leads_bp.route('/api/contacts/<uuid:contact_id>/notes', methods=['POST'])
|
||||
@login_required
|
||||
def add_note(contact_id):
|
||||
"""Add note to contact"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
note = get_lead_service().add_note(
|
||||
contact_id,
|
||||
data['note_text'],
|
||||
flask_session.get('username')
|
||||
)
|
||||
return jsonify({'success': True, 'note': note})
|
||||
except ValueError as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@leads_bp.route('/api/notes/<uuid:note_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_note(note_id):
|
||||
"""Update note"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
note = get_lead_service().update_note(
|
||||
note_id,
|
||||
data['note_text'],
|
||||
flask_session.get('username')
|
||||
)
|
||||
return jsonify({'success': True, 'note': note})
|
||||
except ValueError as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@leads_bp.route('/api/notes/<uuid:note_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_note(note_id):
|
||||
"""Delete note"""
|
||||
try:
|
||||
success = get_lead_service().delete_note(
|
||||
note_id,
|
||||
flask_session.get('username')
|
||||
)
|
||||
return jsonify({'success': success})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# Export Routes
|
||||
@leads_bp.route('/export')
|
||||
@login_required
|
||||
def export_leads():
|
||||
"""Export leads data as Excel/CSV"""
|
||||
from utils.export import create_excel_export, create_csv_export
|
||||
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Query institutions with contact counts
|
||||
cur.execute("""
|
||||
SELECT
|
||||
i.id,
|
||||
i.name,
|
||||
i.type,
|
||||
i.website,
|
||||
i.address,
|
||||
i.created_at,
|
||||
i.created_by,
|
||||
COUNT(DISTINCT c.id) as contact_count,
|
||||
COUNT(DISTINCT cd.id) as contact_detail_count,
|
||||
COUNT(DISTINCT n.id) as note_count
|
||||
FROM lead_institutions i
|
||||
LEFT JOIN lead_contacts c ON i.id = c.institution_id
|
||||
LEFT JOIN lead_contact_details cd ON c.id = cd.contact_id
|
||||
LEFT JOIN lead_notes n ON i.id = n.institution_id
|
||||
GROUP BY i.id, i.name, i.type, i.website, i.address, i.created_at, i.created_by
|
||||
ORDER BY i.name
|
||||
""")
|
||||
|
||||
# Prepare data for export
|
||||
data = []
|
||||
columns = ['ID', 'Institution', 'Typ', 'Website', 'Adresse',
|
||||
'Erstellt am', 'Erstellt von', 'Anzahl Kontakte',
|
||||
'Anzahl Kontaktdetails', 'Anzahl Notizen']
|
||||
|
||||
for row in cur.fetchall():
|
||||
data.append(list(row))
|
||||
|
||||
# Check format parameter
|
||||
format_type = request.args.get('format', 'excel').lower()
|
||||
|
||||
if format_type == 'csv':
|
||||
return create_csv_export(data, columns, 'leads')
|
||||
else:
|
||||
return create_excel_export(data, columns, 'leads')
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Export: {str(e)}', 'error')
|
||||
return redirect(url_for('leads.institutions'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
171
v2_adminpanel/leads/services.py
Normale Datei
171
v2_adminpanel/leads/services.py
Normale Datei
@@ -0,0 +1,171 @@
|
||||
# Business Logic Service for Lead Management
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from .repositories import LeadRepository
|
||||
|
||||
class LeadService:
|
||||
def __init__(self, repository: LeadRepository):
|
||||
self.repo = repository
|
||||
|
||||
# Institution Services
|
||||
def list_institutions(self) -> List[Dict[str, Any]]:
|
||||
"""Get all institutions with contact counts"""
|
||||
return self.repo.get_institutions_with_counts()
|
||||
|
||||
def create_institution(self, name: str, user: str) -> Dict[str, Any]:
|
||||
"""Create a new institution"""
|
||||
# Validation
|
||||
if not name or len(name.strip()) == 0:
|
||||
raise ValueError("Institution name cannot be empty")
|
||||
|
||||
# Create institution
|
||||
institution = self.repo.create_institution(name.strip(), user)
|
||||
|
||||
# Note: Audit logging removed as it requires different implementation
|
||||
# Can be added later with proper audit system integration
|
||||
|
||||
return institution
|
||||
|
||||
def update_institution(self, institution_id: UUID, name: str, user: str) -> Dict[str, Any]:
|
||||
"""Update institution name"""
|
||||
# Validation
|
||||
if not name or len(name.strip()) == 0:
|
||||
raise ValueError("Institution name cannot be empty")
|
||||
|
||||
# Get current institution
|
||||
current = self.repo.get_institution_by_id(institution_id)
|
||||
if not current:
|
||||
raise ValueError("Institution not found")
|
||||
|
||||
# Update
|
||||
institution = self.repo.update_institution(institution_id, name.strip())
|
||||
|
||||
return institution
|
||||
|
||||
# Contact Services
|
||||
def list_contacts_by_institution(self, institution_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""Get all contacts for an institution"""
|
||||
return self.repo.get_contacts_by_institution(institution_id)
|
||||
|
||||
def create_contact(self, data: Dict[str, Any], user: str) -> Dict[str, Any]:
|
||||
"""Create a new contact"""
|
||||
# Validation
|
||||
if not data.get('first_name') or not data.get('last_name'):
|
||||
raise ValueError("First and last name are required")
|
||||
|
||||
if not data.get('institution_id'):
|
||||
raise ValueError("Institution ID is required")
|
||||
|
||||
# Create contact
|
||||
contact = self.repo.create_contact(data)
|
||||
|
||||
return contact
|
||||
|
||||
def get_contact_details(self, contact_id: UUID) -> Dict[str, Any]:
|
||||
"""Get full contact information including details and notes"""
|
||||
contact = self.repo.get_contact_with_details(contact_id)
|
||||
if not contact:
|
||||
raise ValueError("Contact not found")
|
||||
|
||||
# Group details by type
|
||||
contact['phones'] = [d for d in contact.get('details', []) if d['detail_type'] == 'phone']
|
||||
contact['emails'] = [d for d in contact.get('details', []) if d['detail_type'] == 'email']
|
||||
|
||||
return contact
|
||||
|
||||
def update_contact(self, contact_id: UUID, data: Dict[str, Any], user: str) -> Dict[str, Any]:
|
||||
"""Update contact information"""
|
||||
# Validation
|
||||
if not data.get('first_name') or not data.get('last_name'):
|
||||
raise ValueError("First and last name are required")
|
||||
|
||||
# Update contact
|
||||
contact = self.repo.update_contact(contact_id, data)
|
||||
|
||||
return contact
|
||||
|
||||
# Contact Details Services
|
||||
def add_phone(self, contact_id: UUID, phone_number: str,
|
||||
phone_type: str = None, user: str = None) -> Dict[str, Any]:
|
||||
"""Add phone number to contact"""
|
||||
if not phone_number:
|
||||
raise ValueError("Phone number is required")
|
||||
|
||||
detail = self.repo.add_contact_detail(
|
||||
contact_id, 'phone', phone_number, phone_type
|
||||
)
|
||||
|
||||
return detail
|
||||
|
||||
def add_email(self, contact_id: UUID, email: str,
|
||||
email_type: str = None, user: str = None) -> Dict[str, Any]:
|
||||
"""Add email to contact"""
|
||||
if not email:
|
||||
raise ValueError("Email is required")
|
||||
|
||||
# Basic email validation
|
||||
if '@' not in email:
|
||||
raise ValueError("Invalid email format")
|
||||
|
||||
detail = self.repo.add_contact_detail(
|
||||
contact_id, 'email', email, email_type
|
||||
)
|
||||
|
||||
return detail
|
||||
|
||||
def update_contact_detail(self, detail_id: UUID, detail_value: str,
|
||||
detail_label: str = None, user: str = None) -> Dict[str, Any]:
|
||||
"""Update a contact detail (phone/email)"""
|
||||
if not detail_value or len(detail_value.strip()) == 0:
|
||||
raise ValueError("Detail value cannot be empty")
|
||||
|
||||
# Get current detail to check type
|
||||
current_detail = self.repo.get_contact_detail_by_id(detail_id)
|
||||
if not current_detail:
|
||||
raise ValueError("Contact detail not found")
|
||||
|
||||
# Validation based on type
|
||||
if current_detail['detail_type'] == 'email' and '@' not in detail_value:
|
||||
raise ValueError("Invalid email format")
|
||||
|
||||
detail = self.repo.update_contact_detail(
|
||||
detail_id, detail_value.strip(), detail_label
|
||||
)
|
||||
|
||||
return detail
|
||||
|
||||
def delete_contact_detail(self, detail_id: UUID, user: str) -> bool:
|
||||
"""Delete a contact detail (phone/email)"""
|
||||
success = self.repo.delete_contact_detail(detail_id)
|
||||
|
||||
return success
|
||||
|
||||
# Note Services
|
||||
def add_note(self, contact_id: UUID, note_text: str, user: str) -> Dict[str, Any]:
|
||||
"""Add a note to contact"""
|
||||
if not note_text or len(note_text.strip()) == 0:
|
||||
raise ValueError("Note text cannot be empty")
|
||||
|
||||
note = self.repo.create_note(contact_id, note_text.strip(), user)
|
||||
|
||||
return note
|
||||
|
||||
def update_note(self, note_id: UUID, note_text: str, user: str) -> Dict[str, Any]:
|
||||
"""Update a note (creates new version)"""
|
||||
if not note_text or len(note_text.strip()) == 0:
|
||||
raise ValueError("Note text cannot be empty")
|
||||
|
||||
note = self.repo.update_note(note_id, note_text.strip(), user)
|
||||
|
||||
return note
|
||||
|
||||
def delete_note(self, note_id: UUID, user: str) -> bool:
|
||||
"""Delete a note (soft delete)"""
|
||||
success = self.repo.delete_note(note_id)
|
||||
|
||||
return success
|
||||
|
||||
def list_all_contacts(self) -> List[Dict[str, Any]]:
|
||||
"""Get all contacts across all institutions with summary info"""
|
||||
return self.repo.get_all_contacts_with_institutions()
|
||||
239
v2_adminpanel/leads/templates/leads/all_contacts.html
Normale Datei
239
v2_adminpanel/leads/templates/leads/all_contacts.html
Normale Datei
@@ -0,0 +1,239 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Alle Kontakte{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h1 class="h2 mb-0">
|
||||
<i class="bi bi-people"></i> Alle Kontakte
|
||||
</h1>
|
||||
<p class="text-muted mb-0">Übersicht aller Kontakte aus allen Institutionen</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<a href="{{ url_for('leads.institutions') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-building"></i> Zu Institutionen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Bar -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="searchInput"
|
||||
placeholder="Nach Name, Institution oder Position suchen..."
|
||||
onkeyup="filterContacts()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" id="institutionFilter" onchange="filterContacts()">
|
||||
<option value="">Alle Institutionen</option>
|
||||
{% set institutions = contacts | map(attribute='institution_name') | unique | sort %}
|
||||
{% for institution in institutions %}
|
||||
<option value="{{ institution }}">{{ institution }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="sortContacts('name')">
|
||||
<i class="bi bi-sort-alpha-down"></i> Name
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="sortContacts('institution')">
|
||||
<i class="bi bi-building"></i> Institution
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="sortContacts('updated')">
|
||||
<i class="bi bi-clock"></i> Aktualisiert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts Table -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="contactsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Institution</th>
|
||||
<th>Position</th>
|
||||
<th>Kontaktdaten</th>
|
||||
<th>Notizen</th>
|
||||
<th>Zuletzt aktualisiert</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for contact in contacts %}
|
||||
<tr data-name="{{ contact.last_name }} {{ contact.first_name }}"
|
||||
data-institution="{{ contact.institution_name }}"
|
||||
data-updated="{{ contact.updated_at or contact.created_at }}">
|
||||
<td>
|
||||
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}"
|
||||
class="text-decoration-none">
|
||||
<strong>{{ contact.last_name }}, {{ contact.first_name }}</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('leads.institution_detail', institution_id=contact.institution_id) }}"
|
||||
class="text-decoration-none text-muted">
|
||||
{{ contact.institution_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ contact.position or '-' }}</td>
|
||||
<td>
|
||||
{% if contact.phone_count > 0 %}
|
||||
<span class="badge bg-info me-1" title="Telefonnummern">
|
||||
<i class="bi bi-telephone"></i> {{ contact.phone_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if contact.email_count > 0 %}
|
||||
<span class="badge bg-primary" title="E-Mail-Adressen">
|
||||
<i class="bi bi-envelope"></i> {{ contact.email_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if contact.phone_count == 0 and contact.email_count == 0 %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if contact.note_count > 0 %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="bi bi-sticky"></i> {{ contact.note_count }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ (contact.updated_at or contact.created_at).strftime('%d.%m.%Y %H:%M') }}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not contacts %}
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted">Noch keine Kontakte vorhanden.</p>
|
||||
<a href="{{ url_for('leads.institutions') }}" class="btn btn-primary">
|
||||
<i class="bi bi-building"></i> Zu Institutionen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Count -->
|
||||
{% if contacts %}
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<p class="text-muted">
|
||||
<span id="visibleCount">{{ contacts|length }}</span> von {{ contacts|length }} Kontakten angezeigt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Current sort order
|
||||
let currentSort = { field: 'name', ascending: true };
|
||||
|
||||
// Filter contacts
|
||||
function filterContacts() {
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
const institutionFilter = document.getElementById('institutionFilter').value.toLowerCase();
|
||||
const rows = document.querySelectorAll('#contactsTable tbody tr');
|
||||
let visibleCount = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
const text = row.textContent.toLowerCase();
|
||||
const institution = row.getAttribute('data-institution').toLowerCase();
|
||||
|
||||
const matchesSearch = searchTerm === '' || text.includes(searchTerm);
|
||||
const matchesInstitution = institutionFilter === '' || institution === institutionFilter;
|
||||
|
||||
if (matchesSearch && matchesInstitution) {
|
||||
row.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Update visible count
|
||||
const countElement = document.getElementById('visibleCount');
|
||||
if (countElement) {
|
||||
countElement.textContent = visibleCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort contacts
|
||||
function sortContacts(field) {
|
||||
const tbody = document.querySelector('#contactsTable tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
|
||||
// Toggle sort order if same field
|
||||
if (currentSort.field === field) {
|
||||
currentSort.ascending = !currentSort.ascending;
|
||||
} else {
|
||||
currentSort.field = field;
|
||||
currentSort.ascending = true;
|
||||
}
|
||||
|
||||
// Sort rows
|
||||
rows.sort((a, b) => {
|
||||
let aValue, bValue;
|
||||
|
||||
switch(field) {
|
||||
case 'name':
|
||||
aValue = a.getAttribute('data-name');
|
||||
bValue = b.getAttribute('data-name');
|
||||
break;
|
||||
case 'institution':
|
||||
aValue = a.getAttribute('data-institution');
|
||||
bValue = b.getAttribute('data-institution');
|
||||
break;
|
||||
case 'updated':
|
||||
aValue = new Date(a.getAttribute('data-updated'));
|
||||
bValue = new Date(b.getAttribute('data-updated'));
|
||||
break;
|
||||
}
|
||||
|
||||
if (field === 'updated') {
|
||||
return currentSort.ascending ? aValue - bValue : bValue - aValue;
|
||||
} else {
|
||||
const comparison = aValue.localeCompare(bValue);
|
||||
return currentSort.ascending ? comparison : -comparison;
|
||||
}
|
||||
});
|
||||
|
||||
// Re-append sorted rows
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
|
||||
// Update button states
|
||||
document.querySelectorAll('.btn-group button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set initial sort
|
||||
sortContacts('name');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
622
v2_adminpanel/leads/templates/leads/contact_detail.html
Normale Datei
622
v2_adminpanel/leads/templates/leads/contact_detail.html
Normale Datei
@@ -0,0 +1,622 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ contact.first_name }} {{ contact.last_name }} - Kontakt-Details{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h1 class="h2 mb-0">
|
||||
<i class="bi bi-person"></i> {{ contact.first_name }} {{ contact.last_name }}
|
||||
</h1>
|
||||
<p class="mb-0">
|
||||
<span class="text-muted">{{ contact.position or 'Keine Position' }}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<a href="{{ url_for('leads.institution_detail', institution_id=contact.institution_id) }}"
|
||||
class="text-decoration-none">
|
||||
{{ contact.institution_name }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<button class="btn btn-outline-primary" onclick="editContact()">
|
||||
<i class="bi bi-pencil"></i> Bearbeiten
|
||||
</button>
|
||||
<a href="{{ url_for('leads.institution_detail', institution_id=contact.institution_id) }}"
|
||||
class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Zurück
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Contact Details -->
|
||||
<div class="col-md-6">
|
||||
<!-- Phone Numbers -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-telephone"></i> Telefonnummern</h5>
|
||||
<button class="btn btn-sm btn-primary" onclick="showAddPhoneModal()">
|
||||
<i class="bi bi-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if contact.phones %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for phone in contact.phones %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>{{ phone.detail_value }}</strong>
|
||||
{% if phone.detail_label %}
|
||||
<span class="badge bg-secondary">{{ phone.detail_label }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
onclick="editDetail('{{ phone.id }}', '{{ phone.detail_value }}', '{{ phone.detail_label or '' }}', 'phone')">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
onclick="deleteDetail('{{ phone.id }}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine Telefonnummern hinterlegt.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Addresses -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-envelope"></i> E-Mail-Adressen</h5>
|
||||
<button class="btn btn-sm btn-primary" onclick="showAddEmailModal()">
|
||||
<i class="bi bi-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if contact.emails %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for email in contact.emails %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<a href="mailto:{{ email.detail_value }}">{{ email.detail_value }}</a>
|
||||
{% if email.detail_label %}
|
||||
<span class="badge bg-secondary">{{ email.detail_label }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
onclick="editDetail('{{ email.id }}', '{{ email.detail_value }}', '{{ email.detail_label or '' }}', 'email')">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
onclick="deleteDetail('{{ email.id }}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine E-Mail-Adressen hinterlegt.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-journal-text"></i> Notizen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- New Note Form -->
|
||||
<div class="mb-3">
|
||||
<textarea class="form-control" id="newNoteText" rows="3"
|
||||
placeholder="Neue Notiz hinzufügen..."></textarea>
|
||||
<button class="btn btn-primary btn-sm mt-2" onclick="addNote()">
|
||||
<i class="bi bi-plus"></i> Notiz speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Notes List -->
|
||||
<div id="notesList">
|
||||
{% for note in contact.notes %}
|
||||
<div class="card mb-2" id="note-{{ note.id }}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-clock"></i>
|
||||
{{ note.created_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
{% if note.created_by %} • {{ note.created_by }}{% endif %}
|
||||
{% if note.version > 1 %}
|
||||
<span class="badge bg-info">v{{ note.version }}</span>
|
||||
{% endif %}
|
||||
</small>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-link p-0 mx-1"
|
||||
onclick="editNote('{{ note.id }}')">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-link text-danger p-0 mx-1"
|
||||
onclick="deleteNote('{{ note.id }}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note-content" id="note-content-{{ note.id }}">
|
||||
{{ note.note_text|nl2br|safe }}
|
||||
</div>
|
||||
<div class="note-edit d-none" id="note-edit-{{ note.id }}">
|
||||
<textarea class="form-control mb-2" id="note-edit-text-{{ note.id }}">{{ note.note_text }}</textarea>
|
||||
<button class="btn btn-sm btn-primary" onclick="saveNote('{{ note.id }}')">
|
||||
Speichern
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="cancelEdit('{{ note.id }}')">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if not contact.notes %}
|
||||
<p class="text-muted">Noch keine Notizen vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<!-- Edit Contact Modal -->
|
||||
<div class="modal fade" id="editContactModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Kontakt bearbeiten</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editContactForm">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="editFirstName" class="form-label">Vorname</label>
|
||||
<input type="text" class="form-control" id="editFirstName"
|
||||
value="{{ contact.first_name }}" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="editLastName" class="form-label">Nachname</label>
|
||||
<input type="text" class="form-control" id="editLastName"
|
||||
value="{{ contact.last_name }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editPosition" class="form-label">Position</label>
|
||||
<input type="text" class="form-control" id="editPosition"
|
||||
value="{{ contact.position or '' }}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" onclick="updateContact()">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Phone Modal -->
|
||||
<div class="modal fade" id="addPhoneModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Telefonnummer hinzufügen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="addPhoneForm">
|
||||
<div class="mb-3">
|
||||
<label for="phoneNumber" class="form-label">Telefonnummer</label>
|
||||
<input type="tel" class="form-control" id="phoneNumber" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="phoneType" class="form-label">Typ</label>
|
||||
<select class="form-select" id="phoneType">
|
||||
<option value="">Bitte wählen...</option>
|
||||
<option value="Mobil">Mobil</option>
|
||||
<option value="Geschäftlich">Geschäftlich</option>
|
||||
<option value="Privat">Privat</option>
|
||||
<option value="Fax">Fax</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" onclick="savePhone()">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Email Modal -->
|
||||
<div class="modal fade" id="addEmailModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">E-Mail-Adresse hinzufügen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="addEmailForm">
|
||||
<div class="mb-3">
|
||||
<label for="emailAddress" class="form-label">E-Mail-Adresse</label>
|
||||
<input type="email" class="form-control" id="emailAddress" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="emailType" class="form-label">Typ</label>
|
||||
<select class="form-select" id="emailType">
|
||||
<option value="">Bitte wählen...</option>
|
||||
<option value="Geschäftlich">Geschäftlich</option>
|
||||
<option value="Privat">Privat</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEmail()">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Detail Modal -->
|
||||
<div class="modal fade" id="editDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editDetailTitle">Detail bearbeiten</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="editDetailForm">
|
||||
<input type="hidden" id="editDetailId">
|
||||
<input type="hidden" id="editDetailType">
|
||||
<div class="mb-3">
|
||||
<label for="editDetailValue" class="form-label" id="editDetailValueLabel">Wert</label>
|
||||
<input type="text" class="form-control" id="editDetailValue" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editDetailLabel" class="form-label">Typ</label>
|
||||
<select class="form-select" id="editDetailLabel">
|
||||
<option value="">Bitte wählen...</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" onclick="updateDetail()">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const contactId = '{{ contact.id }}';
|
||||
|
||||
// Contact functions
|
||||
function editContact() {
|
||||
new bootstrap.Modal(document.getElementById('editContactModal')).show();
|
||||
}
|
||||
|
||||
async function updateContact() {
|
||||
const firstName = document.getElementById('editFirstName').value.trim();
|
||||
const lastName = document.getElementById('editLastName').value.trim();
|
||||
const position = document.getElementById('editPosition').value.trim();
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
alert('Bitte geben Sie Vor- und Nachname ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/leads/api/contacts/${contactId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
position: position
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Speichern: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Phone functions
|
||||
function showAddPhoneModal() {
|
||||
document.getElementById('addPhoneForm').reset();
|
||||
new bootstrap.Modal(document.getElementById('addPhoneModal')).show();
|
||||
}
|
||||
|
||||
async function savePhone() {
|
||||
const phoneNumber = document.getElementById('phoneNumber').value.trim();
|
||||
const phoneType = document.getElementById('phoneType').value;
|
||||
|
||||
if (!phoneNumber) {
|
||||
alert('Bitte geben Sie eine Telefonnummer ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/leads/api/contacts/${contactId}/phones`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone_number: phoneNumber,
|
||||
phone_type: phoneType
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Speichern: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Email functions
|
||||
function showAddEmailModal() {
|
||||
document.getElementById('addEmailForm').reset();
|
||||
new bootstrap.Modal(document.getElementById('addEmailModal')).show();
|
||||
}
|
||||
|
||||
async function saveEmail() {
|
||||
const email = document.getElementById('emailAddress').value.trim();
|
||||
const emailType = document.getElementById('emailType').value;
|
||||
|
||||
if (!email) {
|
||||
alert('Bitte geben Sie eine E-Mail-Adresse ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/leads/api/contacts/${contactId}/emails`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
email_type: emailType
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Speichern: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit detail
|
||||
function editDetail(detailId, detailValue, detailLabel, detailType) {
|
||||
document.getElementById('editDetailId').value = detailId;
|
||||
document.getElementById('editDetailType').value = detailType;
|
||||
document.getElementById('editDetailValue').value = detailValue;
|
||||
|
||||
// Set appropriate input type and options based on detail type
|
||||
const valueInput = document.getElementById('editDetailValue');
|
||||
const labelSelect = document.getElementById('editDetailLabel');
|
||||
const valueLabel = document.getElementById('editDetailValueLabel');
|
||||
|
||||
labelSelect.innerHTML = '<option value="">Bitte wählen...</option>';
|
||||
|
||||
if (detailType === 'phone') {
|
||||
valueInput.type = 'tel';
|
||||
valueLabel.textContent = 'Telefonnummer';
|
||||
document.getElementById('editDetailTitle').textContent = 'Telefonnummer bearbeiten';
|
||||
|
||||
// Add phone type options
|
||||
['Mobil', 'Geschäftlich', 'Privat', 'Fax'].forEach(type => {
|
||||
const option = document.createElement('option');
|
||||
option.value = type;
|
||||
option.textContent = type;
|
||||
if (type === detailLabel) option.selected = true;
|
||||
labelSelect.appendChild(option);
|
||||
});
|
||||
} else if (detailType === 'email') {
|
||||
valueInput.type = 'email';
|
||||
valueLabel.textContent = 'E-Mail-Adresse';
|
||||
document.getElementById('editDetailTitle').textContent = 'E-Mail-Adresse bearbeiten';
|
||||
|
||||
// Add email type options
|
||||
['Geschäftlich', 'Privat'].forEach(type => {
|
||||
const option = document.createElement('option');
|
||||
option.value = type;
|
||||
option.textContent = type;
|
||||
if (type === detailLabel) option.selected = true;
|
||||
labelSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('editDetailModal')).show();
|
||||
}
|
||||
|
||||
// Update detail
|
||||
async function updateDetail() {
|
||||
const detailId = document.getElementById('editDetailId').value;
|
||||
const detailValue = document.getElementById('editDetailValue').value.trim();
|
||||
const detailLabel = document.getElementById('editDetailLabel').value;
|
||||
|
||||
if (!detailValue) {
|
||||
alert('Bitte geben Sie einen Wert ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/leads/api/details/${detailId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
detail_value: detailValue,
|
||||
detail_label: detailLabel
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Speichern: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete detail
|
||||
async function deleteDetail(detailId) {
|
||||
if (!confirm('Möchten Sie diesen Eintrag wirklich löschen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/leads/api/details/${detailId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler beim Löschen');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Note functions
|
||||
async function addNote() {
|
||||
const noteText = document.getElementById('newNoteText').value.trim();
|
||||
|
||||
if (!noteText) {
|
||||
alert('Bitte geben Sie eine Notiz ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/leads/api/contacts/${contactId}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note_text: noteText })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Speichern: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function editNote(noteId) {
|
||||
// Text aus dem angezeigten Content holen
|
||||
const contentDiv = document.getElementById(`note-content-${noteId}`);
|
||||
const textarea = document.getElementById(`note-edit-text-${noteId}`);
|
||||
|
||||
// Der Text ist bereits im Textarea durch das Template
|
||||
document.getElementById(`note-content-${noteId}`).classList.add('d-none');
|
||||
document.getElementById(`note-edit-${noteId}`).classList.remove('d-none');
|
||||
|
||||
// Fokus auf Textarea setzen
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function cancelEdit(noteId) {
|
||||
document.getElementById(`note-content-${noteId}`).classList.remove('d-none');
|
||||
document.getElementById(`note-edit-${noteId}`).classList.add('d-none');
|
||||
}
|
||||
|
||||
async function saveNote(noteId) {
|
||||
const noteText = document.getElementById(`note-edit-text-${noteId}`).value.trim();
|
||||
|
||||
if (!noteText) {
|
||||
alert('Notiz darf nicht leer sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/leads/api/notes/${noteId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note_text: noteText })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Speichern: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote(noteId) {
|
||||
if (!confirm('Möchten Sie diese Notiz wirklich löschen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/leads/api/notes/${noteId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler beim Löschen');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler: ' + error.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.note-content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
159
v2_adminpanel/leads/templates/leads/institution_detail.html
Normale Datei
159
v2_adminpanel/leads/templates/leads/institution_detail.html
Normale Datei
@@ -0,0 +1,159 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ institution.name }} - Lead-Details{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h1 class="h2 mb-0">
|
||||
<i class="bi bi-building"></i> {{ institution.name }}
|
||||
</h1>
|
||||
<small class="text-muted">
|
||||
Erstellt am {{ institution.created_at.strftime('%d.%m.%Y') }}
|
||||
{% if institution.created_by %}von {{ institution.created_by }}{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
||||
<i class="bi bi-person-plus"></i> Neuer Kontakt
|
||||
</button>
|
||||
<a href="{{ url_for('leads.institutions') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Zurück
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts Table -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-people"></i> Kontakte
|
||||
<span class="badge bg-secondary">{{ contacts|length }}</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Position</th>
|
||||
<th>Erstellt am</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for contact in contacts %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}"
|
||||
class="text-decoration-none">
|
||||
<strong>{{ contact.first_name }} {{ contact.last_name }}</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ contact.position or '-' }}</td>
|
||||
<td>{{ contact.created_at.strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not contacts %}
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted">Noch keine Kontakte für diese Institution.</p>
|
||||
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
||||
<i class="bi bi-person-plus"></i> Ersten Kontakt anlegen
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Contact Modal -->
|
||||
<div class="modal fade" id="contactModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Neuer Kontakt</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="contactForm">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="firstName" class="form-label">Vorname</label>
|
||||
<input type="text" class="form-control" id="firstName" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="lastName" class="form-label">Nachname</label>
|
||||
<input type="text" class="form-control" id="lastName" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="position" class="form-label">Position</label>
|
||||
<input type="text" class="form-control" id="position"
|
||||
placeholder="z.B. Geschäftsführer, Vertriebsleiter">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveContact()">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show create contact modal
|
||||
function showCreateContactModal() {
|
||||
document.getElementById('contactForm').reset();
|
||||
new bootstrap.Modal(document.getElementById('contactModal')).show();
|
||||
}
|
||||
|
||||
// Save contact
|
||||
async function saveContact() {
|
||||
const firstName = document.getElementById('firstName').value.trim();
|
||||
const lastName = document.getElementById('lastName').value.trim();
|
||||
const position = document.getElementById('position').value.trim();
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
alert('Bitte geben Sie Vor- und Nachname ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/leads/api/contacts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
institution_id: '{{ institution.id }}',
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
position: position
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Speichern: ' + error.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
189
v2_adminpanel/leads/templates/leads/institutions.html
Normale Datei
189
v2_adminpanel/leads/templates/leads/institutions.html
Normale Datei
@@ -0,0 +1,189 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Lead-Verwaltung - Institutionen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h1 class="h2 mb-0">
|
||||
<i class="bi bi-building"></i> Lead-Institutionen
|
||||
</h1>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<button class="btn btn-primary" onclick="showCreateInstitutionModal()">
|
||||
<i class="bi bi-plus-circle"></i> Neue Institution
|
||||
</button>
|
||||
<a href="{{ url_for('leads.all_contacts') }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-people"></i> Alle Kontakte
|
||||
</a>
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Zurück zu Kunden
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" id="searchInput"
|
||||
placeholder="Institution suchen..." onkeyup="filterInstitutions()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<a href="{{ url_for('leads.export_leads', format='excel') }}" class="btn btn-outline-success">
|
||||
<i class="bi bi-file-excel"></i> Excel Export
|
||||
</a>
|
||||
<a href="{{ url_for('leads.export_leads', format='csv') }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-file-text"></i> CSV Export
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Institutions Table -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="institutionsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Institution</th>
|
||||
<th>Anzahl Kontakte</th>
|
||||
<th>Erstellt am</th>
|
||||
<th>Erstellt von</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for institution in institutions %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('leads.institution_detail', institution_id=institution.id) }}"
|
||||
class="text-decoration-none">
|
||||
<strong>{{ institution.name }}</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ institution.contact_count }}</span>
|
||||
</td>
|
||||
<td>{{ institution.created_at.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ institution.created_by or '-' }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('leads.institution_detail', institution_id=institution.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> Details
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="editInstitution('{{ institution.id }}', '{{ institution.name }}')">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not institutions %}
|
||||
<div class="text-center py-4">
|
||||
<p class="text-muted">Noch keine Institutionen vorhanden.</p>
|
||||
<button class="btn btn-primary" onclick="showCreateInstitutionModal()">
|
||||
<i class="bi bi-plus-circle"></i> Erste Institution anlegen
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Institution Modal -->
|
||||
<div class="modal fade" id="institutionModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="institutionModalTitle">Neue Institution</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="institutionForm">
|
||||
<input type="hidden" id="institutionId">
|
||||
<div class="mb-3">
|
||||
<label for="institutionName" class="form-label">Name der Institution</label>
|
||||
<input type="text" class="form-control" id="institutionName" required>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveInstitution()">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Filter institutions
|
||||
function filterInstitutions() {
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
const rows = document.querySelectorAll('#institutionsTable tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const text = row.textContent.toLowerCase();
|
||||
row.style.display = text.includes(searchTerm) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Show create institution modal
|
||||
function showCreateInstitutionModal() {
|
||||
document.getElementById('institutionModalTitle').textContent = 'Neue Institution';
|
||||
document.getElementById('institutionId').value = '';
|
||||
document.getElementById('institutionName').value = '';
|
||||
new bootstrap.Modal(document.getElementById('institutionModal')).show();
|
||||
}
|
||||
|
||||
// Edit institution
|
||||
function editInstitution(id, name) {
|
||||
document.getElementById('institutionModalTitle').textContent = 'Institution bearbeiten';
|
||||
document.getElementById('institutionId').value = id;
|
||||
document.getElementById('institutionName').value = name;
|
||||
new bootstrap.Modal(document.getElementById('institutionModal')).show();
|
||||
}
|
||||
|
||||
// Save institution
|
||||
async function saveInstitution() {
|
||||
const id = document.getElementById('institutionId').value;
|
||||
const name = document.getElementById('institutionName').value.trim();
|
||||
|
||||
if (!name) {
|
||||
alert('Bitte geben Sie einen Namen ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = id
|
||||
? `/leads/api/institutions/${id}`
|
||||
: '/leads/api/institutions';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: name })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Speichern: ' + error.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
367
v2_adminpanel/leads/templates/leads/lead_management.html
Normale Datei
367
v2_adminpanel/leads/templates/leads/lead_management.html
Normale Datei
@@ -0,0 +1,367 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Lead Management{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.nav-tabs .nav-link {
|
||||
color: #495057;
|
||||
border: 1px solid transparent;
|
||||
border-top-left-radius: 0.25rem;
|
||||
border-top-right-radius: 0.25rem;
|
||||
}
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #495057;
|
||||
background-color: #fff;
|
||||
border-color: #dee2e6 #dee2e6 #fff;
|
||||
}
|
||||
.tab-content {
|
||||
border: 1px solid #dee2e6;
|
||||
border-top: none;
|
||||
padding: 1.5rem;
|
||||
background-color: #fff;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="mb-4">
|
||||
<h2>📊 Lead Management</h2>
|
||||
</div>
|
||||
|
||||
<!-- Overview Stats -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0">{{ total_institutions }}</h3>
|
||||
<small class="text-muted">Institutionen</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0">{{ total_contacts }}</h3>
|
||||
<small class="text-muted">Kontakte</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Schnellaktionen</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addInstitutionModal">
|
||||
<i class="bi bi-building-add"></i> Institution hinzufügen
|
||||
</button>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addContactModal">
|
||||
<i class="bi bi-person-plus"></i> Kontakt hinzufügen
|
||||
</button>
|
||||
<a href="{{ url_for('leads.export_leads') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-download"></i> Exportieren
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabbed View -->
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs" id="leadTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="institutions-tab" data-bs-toggle="tab" data-bs-target="#institutions" type="button" role="tab">
|
||||
<i class="bi bi-building"></i> Institutionen
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="contacts-tab" data-bs-toggle="tab" data-bs-target="#contacts" type="button" role="tab">
|
||||
<i class="bi bi-people"></i> Kontakte
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content" id="leadTabContent">
|
||||
<!-- Institutions Tab -->
|
||||
<div class="tab-pane fade show active" id="institutions" role="tabpanel">
|
||||
<!-- Filter for Institutions -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="institutionSearch" placeholder="Institution suchen...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" id="institutionSort">
|
||||
<option value="name">Alphabetisch</option>
|
||||
<option value="date">Datum hinzugefügt</option>
|
||||
<option value="contacts">Anzahl Kontakte</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-outline-primary w-100" id="filterNoContacts">
|
||||
<i class="bi bi-funnel"></i> Ohne Kontakte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Institutions List -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Anzahl Kontakte</th>
|
||||
<th>Erstellt am</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for institution in institutions %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('leads.institution_detail', institution_id=institution.id) }}">
|
||||
{{ institution.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ institution.contact_count }}</td>
|
||||
<td>{{ institution.created_at.strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('leads.institution_detail', institution_id=institution.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not institutions %}
|
||||
<p class="text-muted text-center py-3">Keine Institutionen vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts Tab -->
|
||||
<div class="tab-pane fade" id="contacts" role="tabpanel">
|
||||
<!-- Filter for Contacts -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control" id="contactSearch" placeholder="Kontakt suchen...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select" id="institutionFilter">
|
||||
<option value="">Alle Institutionen</option>
|
||||
{% for institution in institutions %}
|
||||
<option value="{{ institution.id }}">{{ institution.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-outline-primary w-100" id="filterNoEmail">
|
||||
<i class="bi bi-envelope-slash"></i> Ohne E-Mail
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-outline-primary w-100" id="filterNoPhone">
|
||||
<i class="bi bi-telephone-x"></i> Ohne Telefon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts List -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Position</th>
|
||||
<th>Institution</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Telefon</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for contact in all_contacts %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}">
|
||||
{{ contact.first_name }} {{ contact.last_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ contact.position or '-' }}</td>
|
||||
<td>{{ contact.institution_name }}</td>
|
||||
<td>
|
||||
{% if contact.emails %}
|
||||
<small>{{ contact.emails[0] }}</small>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if contact.phones %}
|
||||
<small>{{ contact.phones[0] }}</small>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('leads.contact_detail', contact_id=contact.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not all_contacts %}
|
||||
<p class="text-muted text-center py-3">Keine Kontakte vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Institution Modal -->
|
||||
<div class="modal fade" id="addInstitutionModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('leads.add_institution') }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Neue Institution hinzufügen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Name der Institution</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Contact Modal -->
|
||||
<div class="modal fade" id="addContactModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="POST" action="{{ url_for('leads.add_contact') }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Neuen Kontakt hinzufügen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="institution_id" class="form-label">Institution</label>
|
||||
<select class="form-select" id="institution_id" name="institution_id" required>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{% for institution in institutions %}
|
||||
<option value="{{ institution.id }}">{{ institution.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="first_name" class="form-label">Vorname</label>
|
||||
<input type="text" class="form-control" id="first_name" name="first_name" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="last_name" class="form-label">Nachname</label>
|
||||
<input type="text" class="form-control" id="last_name" name="last_name" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="position" class="form-label">Position</label>
|
||||
<input type="text" class="form-control" id="position" name="position">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="phone" class="form-label">Telefon</label>
|
||||
<input type="tel" class="form-control" id="phone" name="phone">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Filter functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Institution search
|
||||
const institutionSearch = document.getElementById('institutionSearch');
|
||||
if (institutionSearch) {
|
||||
institutionSearch.addEventListener('input', function() {
|
||||
filterTable('institutions', this.value.toLowerCase(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Contact search
|
||||
const contactSearch = document.getElementById('contactSearch');
|
||||
if (contactSearch) {
|
||||
contactSearch.addEventListener('input', function() {
|
||||
filterTable('contacts', this.value.toLowerCase(), [0, 1, 2]);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter table function
|
||||
function filterTable(tabId, searchTerm, columnIndices) {
|
||||
const table = document.querySelector(`#${tabId} table tbody`);
|
||||
const rows = table.getElementsByTagName('tr');
|
||||
|
||||
Array.from(rows).forEach(row => {
|
||||
let match = false;
|
||||
const indices = Array.isArray(columnIndices) ? columnIndices : [columnIndices];
|
||||
|
||||
indices.forEach(index => {
|
||||
const cell = row.getElementsByTagName('td')[index];
|
||||
if (cell && cell.textContent.toLowerCase().includes(searchTerm)) {
|
||||
match = true;
|
||||
}
|
||||
});
|
||||
|
||||
row.style.display = match || searchTerm === '' ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Institution filter
|
||||
const institutionFilter = document.getElementById('institutionFilter');
|
||||
if (institutionFilter) {
|
||||
institutionFilter.addEventListener('change', function() {
|
||||
const selectedInstitution = this.value;
|
||||
const table = document.querySelector('#contacts table tbody');
|
||||
const rows = table.getElementsByTagName('tr');
|
||||
|
||||
Array.from(rows).forEach(row => {
|
||||
const institutionCell = row.getElementsByTagName('td')[2];
|
||||
if (selectedInstitution === '' || institutionCell.textContent === this.options[this.selectedIndex].text) {
|
||||
row.style.display = '';
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
1
v2_adminpanel/middleware/__init__.py
Normale Datei
1
v2_adminpanel/middleware/__init__.py
Normale Datei
@@ -0,0 +1 @@
|
||||
from .error_middleware import ErrorHandlingMiddleware
|
||||
54
v2_adminpanel/middleware/error_middleware.py
Normale Datei
54
v2_adminpanel/middleware/error_middleware.py
Normale Datei
@@ -0,0 +1,54 @@
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from flask import request, g
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from core.exceptions import BaseApplicationException
|
||||
from core.monitoring import track_error
|
||||
from core.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ErrorHandlingMiddleware:
|
||||
def __init__(self, app=None):
|
||||
self.app = app
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
app.before_request(self._before_request)
|
||||
app.teardown_appcontext(self._teardown_request)
|
||||
|
||||
def _before_request(self):
|
||||
g.request_id = request.headers.get('X-Request-ID', str(uuid.uuid4()))
|
||||
g.start_time = time.time()
|
||||
g.errors = []
|
||||
|
||||
def _teardown_request(self, exception=None):
|
||||
if exception:
|
||||
self._handle_exception(exception)
|
||||
|
||||
if hasattr(g, 'errors') and g.errors:
|
||||
for error in g.errors:
|
||||
if isinstance(error, BaseApplicationException):
|
||||
track_error(error)
|
||||
|
||||
def _handle_exception(self, exception):
|
||||
if isinstance(exception, BaseApplicationException):
|
||||
track_error(exception)
|
||||
elif isinstance(exception, HTTPException):
|
||||
pass
|
||||
else:
|
||||
logger.error(
|
||||
f"Unhandled exception: {type(exception).__name__}",
|
||||
exc_info=True,
|
||||
extra={
|
||||
'request_id': getattr(g, 'request_id', 'unknown'),
|
||||
'endpoint': request.endpoint,
|
||||
'method': request.method,
|
||||
'path': request.path
|
||||
}
|
||||
)
|
||||
20
v2_adminpanel/migrations/add_device_type.sql
Normale Datei
20
v2_adminpanel/migrations/add_device_type.sql
Normale Datei
@@ -0,0 +1,20 @@
|
||||
-- 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 $$;
|
||||
72
v2_adminpanel/migrations/add_fake_constraint.sql
Normale Datei
72
v2_adminpanel/migrations/add_fake_constraint.sql
Normale Datei
@@ -0,0 +1,72 @@
|
||||
-- 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 $$;
|
||||
58
v2_adminpanel/migrations/add_june_2025_partition.sql
Normale Datei
58
v2_adminpanel/migrations/add_june_2025_partition.sql
Normale Datei
@@ -0,0 +1,58 @@
|
||||
-- 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;
|
||||
17
v2_adminpanel/migrations/cleanup_orphaned_api_tables.sql
Normale Datei
17
v2_adminpanel/migrations/cleanup_orphaned_api_tables.sql
Normale Datei
@@ -0,0 +1,17 @@
|
||||
-- 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');
|
||||
107
v2_adminpanel/migrations/create_lead_tables.sql
Normale Datei
107
v2_adminpanel/migrations/create_lead_tables.sql
Normale Datei
@@ -0,0 +1,107 @@
|
||||
-- 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';
|
||||
79
v2_adminpanel/migrations/create_license_heartbeats_table.sql
Normale Datei
79
v2_adminpanel/migrations/create_license_heartbeats_table.sql
Normale Datei
@@ -0,0 +1,79 @@
|
||||
-- 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';
|
||||
9
v2_adminpanel/migrations/remove_duplicate_api_key.sql
Normale Datei
9
v2_adminpanel/migrations/remove_duplicate_api_key.sql
Normale Datei
@@ -0,0 +1,9 @@
|
||||
-- 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';
|
||||
48
v2_adminpanel/migrations/rename_test_to_fake.sql
Normale Datei
48
v2_adminpanel/migrations/rename_test_to_fake.sql
Normale Datei
@@ -0,0 +1,48 @@
|
||||
-- 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';
|
||||
178
v2_adminpanel/models.py
Normale Datei
178
v2_adminpanel/models.py
Normale Datei
@@ -0,0 +1,178 @@
|
||||
# Temporary models file - will be expanded in Phase 3
|
||||
from db import execute_query, get_db_connection, get_db_cursor
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_user_by_username(username):
|
||||
"""Get user from database by username"""
|
||||
result = execute_query(
|
||||
"""
|
||||
SELECT id, username, password_hash, email, totp_secret, totp_enabled,
|
||||
backup_codes, last_password_change, failed_2fa_attempts
|
||||
FROM users WHERE username = %s
|
||||
""",
|
||||
(username,),
|
||||
fetch_one=True
|
||||
)
|
||||
|
||||
if result:
|
||||
return {
|
||||
'id': result[0],
|
||||
'username': result[1],
|
||||
'password_hash': result[2],
|
||||
'email': result[3],
|
||||
'totp_secret': result[4],
|
||||
'totp_enabled': result[5],
|
||||
'backup_codes': result[6],
|
||||
'last_password_change': result[7],
|
||||
'failed_2fa_attempts': result[8]
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def get_licenses(show_fake=False):
|
||||
"""Get all licenses from database"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
if show_fake:
|
||||
cur.execute("""
|
||||
SELECT l.*, c.name as customer_name
|
||||
FROM licenses l
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
ORDER BY l.created_at DESC
|
||||
""")
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT l.*, c.name as customer_name
|
||||
FROM licenses l
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE l.is_fake = false
|
||||
ORDER BY l.created_at DESC
|
||||
""")
|
||||
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
licenses = []
|
||||
for row in cur.fetchall():
|
||||
license_dict = dict(zip(columns, row))
|
||||
licenses.append(license_dict)
|
||||
return licenses
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching licenses: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def get_license_by_id(license_id):
|
||||
"""Get a specific license by ID"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("""
|
||||
SELECT l.*, c.name as customer_name
|
||||
FROM licenses l
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE l.id = %s
|
||||
""", (license_id,))
|
||||
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
return dict(zip(columns, row))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching license {license_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def get_customers(show_fake=False, search=None):
|
||||
"""Get all customers from database"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
query = """
|
||||
SELECT c.*,
|
||||
COUNT(DISTINCT l.id) as license_count,
|
||||
COUNT(DISTINCT CASE WHEN l.is_active THEN l.id END) as active_licenses
|
||||
FROM customers c
|
||||
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||
"""
|
||||
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if not show_fake:
|
||||
where_clauses.append("c.is_fake = false")
|
||||
|
||||
if search:
|
||||
where_clauses.append("(LOWER(c.name) LIKE LOWER(%s) OR LOWER(c.email) LIKE LOWER(%s))")
|
||||
search_pattern = f'%{search}%'
|
||||
params.extend([search_pattern, search_pattern])
|
||||
|
||||
if where_clauses:
|
||||
query += " WHERE " + " AND ".join(where_clauses)
|
||||
|
||||
query += " GROUP BY c.id ORDER BY c.name"
|
||||
|
||||
cur.execute(query, params)
|
||||
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
customers = []
|
||||
for row in cur.fetchall():
|
||||
customer_dict = dict(zip(columns, row))
|
||||
customers.append(customer_dict)
|
||||
return customers
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching customers: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def get_customer_by_id(customer_id):
|
||||
"""Get a specific customer by ID"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("""
|
||||
SELECT c.*,
|
||||
COUNT(DISTINCT l.id) as license_count,
|
||||
COUNT(DISTINCT CASE WHEN l.is_active THEN l.id END) as active_licenses
|
||||
FROM customers c
|
||||
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||
WHERE c.id = %s
|
||||
GROUP BY c.id
|
||||
""", (customer_id,))
|
||||
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
return dict(zip(columns, row))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching customer {customer_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def get_active_sessions():
|
||||
"""Get all is_active sessions"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("""
|
||||
SELECT s.*, l.license_key, c.name as customer_name
|
||||
FROM sessions s
|
||||
JOIN licenses l ON s.license_id = l.id
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE s.is_active = true
|
||||
ORDER BY s.started_at DESC
|
||||
""")
|
||||
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
sessions = []
|
||||
for row in cur.fetchall():
|
||||
session_dict = dict(zip(columns, row))
|
||||
sessions.append(session_dict)
|
||||
return sessions
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching is_active sessions: {str(e)}")
|
||||
return []
|
||||
17
v2_adminpanel/requirements.txt
Normale Datei
17
v2_adminpanel/requirements.txt
Normale Datei
@@ -0,0 +1,17 @@
|
||||
flask
|
||||
flask-session
|
||||
psycopg2-binary
|
||||
python-dotenv
|
||||
pyopenssl
|
||||
pandas
|
||||
openpyxl
|
||||
cryptography
|
||||
apscheduler
|
||||
requests
|
||||
python-dateutil
|
||||
bcrypt
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
PyJWT
|
||||
prometheus-flask-exporter
|
||||
prometheus-client
|
||||
2
v2_adminpanel/routes/__init__.py
Normale Datei
2
v2_adminpanel/routes/__init__.py
Normale Datei
@@ -0,0 +1,2 @@
|
||||
# Routes module initialization
|
||||
# This module contains all Flask blueprints organized by functionality
|
||||
BIN
v2_adminpanel/routes/__pycache__/__init__.cpython-312.pyc
Normale Datei
BIN
v2_adminpanel/routes/__pycache__/__init__.cpython-312.pyc
Normale Datei
Binäre Datei nicht angezeigt.
BIN
v2_adminpanel/routes/__pycache__/admin_routes.cpython-312.pyc
Normale Datei
BIN
v2_adminpanel/routes/__pycache__/admin_routes.cpython-312.pyc
Normale Datei
Binäre Datei nicht angezeigt.
BIN
v2_adminpanel/routes/__pycache__/api_routes.cpython-312.pyc
Normale Datei
BIN
v2_adminpanel/routes/__pycache__/api_routes.cpython-312.pyc
Normale Datei
Binäre Datei nicht angezeigt.
BIN
v2_adminpanel/routes/__pycache__/auth_routes.cpython-312.pyc
Normale Datei
BIN
v2_adminpanel/routes/__pycache__/auth_routes.cpython-312.pyc
Normale Datei
Binäre Datei nicht angezeigt.
BIN
v2_adminpanel/routes/__pycache__/batch_routes.cpython-312.pyc
Normale Datei
BIN
v2_adminpanel/routes/__pycache__/batch_routes.cpython-312.pyc
Normale Datei
Binäre Datei nicht angezeigt.
BIN
v2_adminpanel/routes/__pycache__/customer_routes.cpython-312.pyc
Normale Datei
BIN
v2_adminpanel/routes/__pycache__/customer_routes.cpython-312.pyc
Normale Datei
Binäre Datei nicht angezeigt.
BIN
v2_adminpanel/routes/__pycache__/export_routes.cpython-312.pyc
Normale Datei
BIN
v2_adminpanel/routes/__pycache__/export_routes.cpython-312.pyc
Normale Datei
Binäre Datei nicht angezeigt.
BIN
v2_adminpanel/routes/__pycache__/license_routes.cpython-312.pyc
Normale Datei
BIN
v2_adminpanel/routes/__pycache__/license_routes.cpython-312.pyc
Normale Datei
Binäre Datei nicht angezeigt.
BIN
v2_adminpanel/routes/__pycache__/resource_routes.cpython-312.pyc
Normale Datei
BIN
v2_adminpanel/routes/__pycache__/resource_routes.cpython-312.pyc
Normale Datei
Binäre Datei nicht angezeigt.
BIN
v2_adminpanel/routes/__pycache__/session_routes.cpython-312.pyc
Normale Datei
BIN
v2_adminpanel/routes/__pycache__/session_routes.cpython-312.pyc
Normale Datei
Binäre Datei nicht angezeigt.
1441
v2_adminpanel/routes/admin_routes.py
Normale Datei
1441
v2_adminpanel/routes/admin_routes.py
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
1021
v2_adminpanel/routes/api_routes.py
Normale Datei
1021
v2_adminpanel/routes/api_routes.py
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
377
v2_adminpanel/routes/auth_routes.py
Normale Datei
377
v2_adminpanel/routes/auth_routes.py
Normale Datei
@@ -0,0 +1,377 @@
|
||||
import time
|
||||
import json
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from auth.password import hash_password, verify_password
|
||||
from auth.two_factor import (
|
||||
generate_totp_secret, generate_qr_code, verify_totp,
|
||||
generate_backup_codes, hash_backup_code, verify_backup_code
|
||||
)
|
||||
from auth.rate_limiting import (
|
||||
check_ip_blocked, record_failed_attempt,
|
||||
reset_login_attempts, get_login_attempts
|
||||
)
|
||||
from utils.network import get_client_ip
|
||||
from utils.audit import log_audit
|
||||
from models import get_user_by_username
|
||||
from db import get_db_connection, get_db_cursor
|
||||
from utils.recaptcha import verify_recaptcha
|
||||
|
||||
# Create Blueprint
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
# Timing-Attack Schutz - Start Zeit merken
|
||||
start_time = time.time()
|
||||
|
||||
# IP-Adresse ermitteln
|
||||
ip_address = get_client_ip()
|
||||
|
||||
# Prüfen ob IP gesperrt ist
|
||||
is_blocked, blocked_until = check_ip_blocked(ip_address)
|
||||
if is_blocked:
|
||||
time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600
|
||||
error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten."
|
||||
return render_template("login.html", error=error_msg, error_type="blocked")
|
||||
|
||||
# Anzahl bisheriger Versuche
|
||||
attempt_count = get_login_attempts(ip_address)
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
password = request.form.get("password")
|
||||
captcha_response = request.form.get("g-recaptcha-response")
|
||||
|
||||
# CAPTCHA-Prüfung nur wenn Keys konfiguriert sind
|
||||
recaptcha_site_key = config.RECAPTCHA_SITE_KEY
|
||||
if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key:
|
||||
if not captcha_response:
|
||||
# Timing-Attack Schutz
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed < 1.0:
|
||||
time.sleep(1.0 - elapsed)
|
||||
return render_template("login.html",
|
||||
error="CAPTCHA ERFORDERLICH!",
|
||||
show_captcha=True,
|
||||
error_type="captcha",
|
||||
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||||
recaptcha_site_key=recaptcha_site_key)
|
||||
|
||||
# CAPTCHA validieren
|
||||
if not verify_recaptcha(captcha_response):
|
||||
# Timing-Attack Schutz
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed < 1.0:
|
||||
time.sleep(1.0 - elapsed)
|
||||
return render_template("login.html",
|
||||
error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.",
|
||||
show_captcha=True,
|
||||
error_type="captcha",
|
||||
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||||
recaptcha_site_key=recaptcha_site_key)
|
||||
|
||||
# Check user in database first, fallback to env vars
|
||||
user = get_user_by_username(username)
|
||||
login_success = False
|
||||
needs_2fa = False
|
||||
|
||||
if user:
|
||||
# Database user authentication
|
||||
if verify_password(password, user['password_hash']):
|
||||
login_success = True
|
||||
needs_2fa = user['totp_enabled']
|
||||
else:
|
||||
# Fallback to environment variables for backward compatibility
|
||||
if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]:
|
||||
login_success = True
|
||||
|
||||
# Timing-Attack Schutz - Mindestens 1 Sekunde warten
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed < 1.0:
|
||||
time.sleep(1.0 - elapsed)
|
||||
|
||||
if login_success:
|
||||
# Erfolgreicher Login
|
||||
if needs_2fa:
|
||||
# Store temporary session for 2FA verification
|
||||
session['temp_username'] = username
|
||||
session['temp_user_id'] = user['id']
|
||||
session['awaiting_2fa'] = True
|
||||
return redirect(url_for('auth.verify_2fa'))
|
||||
else:
|
||||
# Complete login without 2FA
|
||||
session.permanent = True # Aktiviert das Timeout
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
session['user_id'] = user['id'] if user else None
|
||||
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||||
reset_login_attempts(ip_address)
|
||||
log_audit('LOGIN_SUCCESS', 'user',
|
||||
additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}")
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
else:
|
||||
# Fehlgeschlagener Login
|
||||
error_message = record_failed_attempt(ip_address, username)
|
||||
new_attempt_count = get_login_attempts(ip_address)
|
||||
|
||||
# Prüfen ob jetzt gesperrt
|
||||
is_now_blocked, _ = check_ip_blocked(ip_address)
|
||||
if is_now_blocked:
|
||||
log_audit('LOGIN_BLOCKED', 'security',
|
||||
additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt")
|
||||
|
||||
return render_template("login.html",
|
||||
error=error_message,
|
||||
show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY),
|
||||
error_type="failed",
|
||||
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count),
|
||||
recaptcha_site_key=config.RECAPTCHA_SITE_KEY)
|
||||
|
||||
# GET Request
|
||||
return render_template("login.html",
|
||||
show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY),
|
||||
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||||
recaptcha_site_key=config.RECAPTCHA_SITE_KEY)
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
def logout():
|
||||
username = session.get('username', 'unknown')
|
||||
log_audit('LOGOUT', 'user', additional_info=f"Abmeldung")
|
||||
session.pop('logged_in', None)
|
||||
session.pop('username', None)
|
||||
session.pop('user_id', None)
|
||||
session.pop('temp_username', None)
|
||||
session.pop('temp_user_id', None)
|
||||
session.pop('awaiting_2fa', None)
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@auth_bp.route("/verify-2fa", methods=["GET", "POST"])
|
||||
def verify_2fa():
|
||||
if not session.get('awaiting_2fa'):
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if request.method == "POST":
|
||||
token = request.form.get('token', '').replace(' ', '')
|
||||
username = session.get('temp_username')
|
||||
user_id = session.get('temp_user_id')
|
||||
|
||||
if not username or not user_id:
|
||||
flash('Session expired. Please login again.', 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# Check if it's a backup code
|
||||
if len(token) == 8 and token.isupper():
|
||||
# Try backup code
|
||||
backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else []
|
||||
if verify_backup_code(token, backup_codes):
|
||||
# Remove used backup code
|
||||
code_hash = hash_backup_code(token)
|
||||
backup_codes.remove(code_hash)
|
||||
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s",
|
||||
(json.dumps(backup_codes), user_id))
|
||||
|
||||
# Complete login
|
||||
session.permanent = True
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
session['user_id'] = user_id
|
||||
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||||
session.pop('temp_username', None)
|
||||
session.pop('temp_user_id', None)
|
||||
session.pop('awaiting_2fa', None)
|
||||
|
||||
flash('Login successful using backup code. Please generate new backup codes.', 'warning')
|
||||
log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code")
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
else:
|
||||
# Try TOTP token
|
||||
if verify_totp(user['totp_secret'], token):
|
||||
# Complete login
|
||||
session.permanent = True
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
session['user_id'] = user_id
|
||||
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||||
session.pop('temp_username', None)
|
||||
session.pop('temp_user_id', None)
|
||||
session.pop('awaiting_2fa', None)
|
||||
|
||||
log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful")
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Failed verification
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s",
|
||||
(datetime.now(), user_id))
|
||||
|
||||
flash('Invalid authentication code. Please try again.', 'error')
|
||||
log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt")
|
||||
|
||||
return render_template('verify_2fa.html')
|
||||
|
||||
|
||||
@auth_bp.route("/profile")
|
||||
@login_required
|
||||
def profile():
|
||||
user = get_user_by_username(session['username'])
|
||||
if not user:
|
||||
# For environment-based users, redirect with message
|
||||
flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
return render_template('profile.html', user=user)
|
||||
|
||||
|
||||
@auth_bp.route("/profile/change-password", methods=["POST"])
|
||||
@login_required
|
||||
def change_password():
|
||||
current_password = request.form.get('current_password')
|
||||
new_password = request.form.get('new_password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
|
||||
user = get_user_by_username(session['username'])
|
||||
|
||||
# Verify current password
|
||||
if not verify_password(current_password, user['password_hash']):
|
||||
flash('Current password is incorrect.', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Check new password
|
||||
if new_password != confirm_password:
|
||||
flash('New passwords do not match.', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
if len(new_password) < 8:
|
||||
flash('Password must be at least 8 characters long.', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Update password
|
||||
new_hash = hash_password(new_password)
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s",
|
||||
(new_hash, datetime.now(), user['id']))
|
||||
|
||||
log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'],
|
||||
additional_info="Password changed successfully")
|
||||
flash('Password changed successfully.', 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
|
||||
@auth_bp.route("/profile/setup-2fa")
|
||||
@login_required
|
||||
def setup_2fa():
|
||||
user = get_user_by_username(session['username'])
|
||||
|
||||
if user['totp_enabled']:
|
||||
flash('2FA is already enabled for your account.', 'info')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Generate new TOTP secret
|
||||
totp_secret = generate_totp_secret()
|
||||
session['temp_totp_secret'] = totp_secret
|
||||
|
||||
# Generate QR code
|
||||
qr_code = generate_qr_code(user['username'], totp_secret)
|
||||
|
||||
return render_template('setup_2fa.html',
|
||||
totp_secret=totp_secret,
|
||||
qr_code=qr_code)
|
||||
|
||||
|
||||
@auth_bp.route("/profile/enable-2fa", methods=["POST"])
|
||||
@login_required
|
||||
def enable_2fa():
|
||||
token = request.form.get('token', '').replace(' ', '')
|
||||
totp_secret = session.get('temp_totp_secret')
|
||||
|
||||
if not totp_secret:
|
||||
flash('2FA setup session expired. Please try again.', 'error')
|
||||
return redirect(url_for('auth.setup_2fa'))
|
||||
|
||||
# Verify the token
|
||||
if not verify_totp(totp_secret, token):
|
||||
flash('Invalid authentication code. Please try again.', 'error')
|
||||
return redirect(url_for('auth.setup_2fa'))
|
||||
|
||||
# Generate backup codes
|
||||
backup_codes = generate_backup_codes()
|
||||
backup_codes_hashed = [hash_backup_code(code) for code in backup_codes]
|
||||
|
||||
# Enable 2FA for user
|
||||
user = get_user_by_username(session['username'])
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("""
|
||||
UPDATE users
|
||||
SET totp_secret = %s, totp_enabled = true, backup_codes = %s
|
||||
WHERE id = %s
|
||||
""", (totp_secret, json.dumps(backup_codes_hashed), user['id']))
|
||||
|
||||
# Clear temp secret
|
||||
session.pop('temp_totp_secret', None)
|
||||
|
||||
log_audit('2FA_ENABLED', 'user', entity_id=user['id'],
|
||||
additional_info="2FA successfully enabled")
|
||||
|
||||
# Show backup codes
|
||||
return render_template('backup_codes.html', backup_codes=backup_codes)
|
||||
|
||||
|
||||
@auth_bp.route("/profile/disable-2fa", methods=["POST"])
|
||||
@login_required
|
||||
def disable_2fa():
|
||||
password = request.form.get('password')
|
||||
|
||||
user = get_user_by_username(session['username'])
|
||||
|
||||
# Verify password
|
||||
if not verify_password(password, user['password_hash']):
|
||||
flash('Incorrect password. 2FA was not disabled.', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Disable 2FA
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("""
|
||||
UPDATE users
|
||||
SET totp_enabled = false, totp_secret = NULL, backup_codes = NULL
|
||||
WHERE id = %s
|
||||
""", (user['id'],))
|
||||
|
||||
log_audit('2FA_DISABLED', 'user', entity_id=user['id'],
|
||||
additional_info="2FA disabled by user")
|
||||
flash('2FA has been disabled for your account.', 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
|
||||
@auth_bp.route("/heartbeat", methods=['POST'])
|
||||
@login_required
|
||||
def heartbeat():
|
||||
"""Endpoint für Session Keep-Alive - aktualisiert last_activity"""
|
||||
# Aktualisiere last_activity nur wenn explizit angefordert
|
||||
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||||
# Force session save
|
||||
session.modified = True
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'last_activity': session['last_activity'],
|
||||
'username': session.get('username')
|
||||
})
|
||||
439
v2_adminpanel/routes/batch_routes.py
Normale Datei
439
v2_adminpanel/routes/batch_routes.py
Normale Datei
@@ -0,0 +1,439 @@
|
||||
import os
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from utils.network import get_client_ip
|
||||
from utils.export import create_batch_export
|
||||
from db import get_connection, get_db_connection, get_db_cursor
|
||||
from models import get_customers
|
||||
|
||||
# Create Blueprint
|
||||
batch_bp = Blueprint('batch', __name__)
|
||||
|
||||
|
||||
def generate_license_key():
|
||||
"""Generiert einen zufälligen Lizenzschlüssel"""
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
return '-'.join([''.join(secrets.choice(chars) for _ in range(4)) for _ in range(4)])
|
||||
|
||||
|
||||
@batch_bp.route("/batch", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def batch_create():
|
||||
"""Batch-Erstellung von Lizenzen"""
|
||||
customers = get_customers()
|
||||
|
||||
if request.method == "POST":
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Form data
|
||||
customer_id = int(request.form['customer_id'])
|
||||
license_type = request.form['license_type']
|
||||
count = int(request.form['quantity']) # Korrigiert von 'count' zu 'quantity'
|
||||
valid_from = request.form['valid_from']
|
||||
valid_until = request.form['valid_until']
|
||||
device_limit = int(request.form['device_limit'])
|
||||
|
||||
# Resource allocation parameters
|
||||
domain_count = int(request.form.get('domain_count', 0))
|
||||
ipv4_count = int(request.form.get('ipv4_count', 0))
|
||||
phone_count = int(request.form.get('phone_count', 0))
|
||||
|
||||
# Validierung
|
||||
if count < 1 or count > 100:
|
||||
flash('Anzahl muss zwischen 1 und 100 liegen!', 'error')
|
||||
return redirect(url_for('batch.batch_create'))
|
||||
|
||||
# Hole Kundendaten inkl. is_fake Status
|
||||
cur.execute("SELECT name, email, is_fake FROM customers WHERE id = %s", (customer_id,))
|
||||
customer = cur.fetchone()
|
||||
if not customer:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('batch.batch_create'))
|
||||
|
||||
# Lizenz erbt immer den is_fake Status vom Kunden
|
||||
is_fake = customer[2]
|
||||
|
||||
created_licenses = []
|
||||
|
||||
# Erstelle Lizenzen
|
||||
for i in range(count):
|
||||
license_key = generate_license_key()
|
||||
|
||||
# Prüfe ob Schlüssel bereits existiert
|
||||
while True:
|
||||
cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,))
|
||||
if not cur.fetchone():
|
||||
break
|
||||
license_key = generate_license_key()
|
||||
|
||||
# Erstelle Lizenz
|
||||
cur.execute("""
|
||||
INSERT INTO licenses (
|
||||
license_key, customer_id,
|
||||
license_type, valid_from, valid_until, device_limit,
|
||||
is_fake, created_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
license_key, customer_id,
|
||||
license_type, valid_from, valid_until, device_limit,
|
||||
is_fake, datetime.now()
|
||||
))
|
||||
|
||||
license_id = cur.fetchone()[0]
|
||||
created_licenses.append({
|
||||
'id': license_id,
|
||||
'license_key': license_key
|
||||
})
|
||||
|
||||
# Allocate resources if requested
|
||||
if domain_count > 0 or ipv4_count > 0 or phone_count > 0:
|
||||
# Allocate domains
|
||||
if domain_count > 0:
|
||||
cur.execute("""
|
||||
UPDATE resource_pool
|
||||
SET status = 'allocated',
|
||||
license_id = %s,
|
||||
allocated_at = NOW()
|
||||
WHERE id IN (
|
||||
SELECT id FROM resource_pool
|
||||
WHERE type = 'domain'
|
||||
AND status = 'available'
|
||||
AND is_fake = %s
|
||||
ORDER BY id
|
||||
LIMIT %s
|
||||
)
|
||||
""", (license_id, is_fake, domain_count))
|
||||
|
||||
# Allocate IPv4s
|
||||
if ipv4_count > 0:
|
||||
cur.execute("""
|
||||
UPDATE resource_pool
|
||||
SET status = 'allocated',
|
||||
license_id = %s,
|
||||
allocated_at = NOW()
|
||||
WHERE id IN (
|
||||
SELECT id FROM resource_pool
|
||||
WHERE type = 'ipv4'
|
||||
AND status = 'available'
|
||||
AND is_fake = %s
|
||||
ORDER BY id
|
||||
LIMIT %s
|
||||
)
|
||||
""", (license_id, is_fake, ipv4_count))
|
||||
|
||||
# Allocate phones
|
||||
if phone_count > 0:
|
||||
cur.execute("""
|
||||
UPDATE resource_pool
|
||||
SET status = 'allocated',
|
||||
license_id = %s,
|
||||
allocated_at = NOW()
|
||||
WHERE id IN (
|
||||
SELECT id FROM resource_pool
|
||||
WHERE type = 'phone'
|
||||
AND status = 'available'
|
||||
AND is_fake = %s
|
||||
ORDER BY id
|
||||
LIMIT %s
|
||||
)
|
||||
""", (license_id, is_fake, phone_count))
|
||||
|
||||
# Audit-Log
|
||||
log_audit('CREATE', 'license', license_id,
|
||||
new_values={
|
||||
'license_key': license_key,
|
||||
'customer_name': customer[0],
|
||||
'batch_creation': True
|
||||
})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Speichere erstellte Lizenzen in Session für Export
|
||||
session['batch_created_licenses'] = created_licenses
|
||||
session['batch_customer_name'] = customer[0]
|
||||
session['batch_customer_email'] = customer[1]
|
||||
|
||||
flash(f'{count} Lizenzen erfolgreich erstellt!', 'success')
|
||||
|
||||
# Weiterleitung zum Export
|
||||
return redirect(url_for('batch.batch_export'))
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler bei Batch-Erstellung: {str(e)}")
|
||||
flash('Fehler bei der Batch-Erstellung!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return render_template("batch_form.html", customers=customers)
|
||||
|
||||
|
||||
@batch_bp.route("/batch/export")
|
||||
@login_required
|
||||
def batch_export():
|
||||
"""Exportiert die zuletzt erstellten Batch-Lizenzen"""
|
||||
created_licenses = session.get('batch_created_licenses', [])
|
||||
|
||||
if not created_licenses:
|
||||
flash('Keine Lizenzen zum Exportieren gefunden!', 'error')
|
||||
return redirect(url_for('batch.batch_create'))
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole vollständige Lizenzdaten
|
||||
license_ids = [l['id'] for l in created_licenses]
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
l.license_key, c.name, c.email,
|
||||
l.license_type, l.valid_from, l.valid_until,
|
||||
l.device_limit, l.is_fake, l.created_at
|
||||
FROM licenses l
|
||||
JOIN customers c ON l.customer_id = c.id
|
||||
WHERE l.id = ANY(%s)
|
||||
ORDER BY l.id
|
||||
""", (license_ids,))
|
||||
|
||||
licenses = []
|
||||
for row in cur.fetchall():
|
||||
licenses.append({
|
||||
'license_key': row[0],
|
||||
'customer_name': row[1],
|
||||
'customer_email': row[2],
|
||||
'license_type': row[3],
|
||||
'valid_from': row[4],
|
||||
'valid_until': row[5],
|
||||
'device_limit': row[6],
|
||||
'is_fake': row[7],
|
||||
'created_at': row[8]
|
||||
})
|
||||
|
||||
# Lösche aus Session
|
||||
session.pop('batch_created_licenses', None)
|
||||
session.pop('batch_customer_name', None)
|
||||
session.pop('batch_customer_email', None)
|
||||
|
||||
# Erstelle und sende Excel-Export
|
||||
return create_batch_export(licenses)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
flash('Fehler beim Exportieren der Lizenzen!', 'error')
|
||||
return redirect(url_for('batch.batch_create'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@batch_bp.route("/batch/update", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def batch_update():
|
||||
"""Batch-Update von Lizenzen"""
|
||||
if request.method == "POST":
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Form data
|
||||
license_keys = request.form.get('license_keys', '').strip().split('\n')
|
||||
license_keys = [key.strip() for key in license_keys if key.strip()]
|
||||
|
||||
if not license_keys:
|
||||
flash('Keine Lizenzschlüssel angegeben!', 'error')
|
||||
return redirect(url_for('batch.batch_update'))
|
||||
|
||||
# Update-Parameter
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if 'update_valid_until' in request.form and request.form['valid_until']:
|
||||
updates.append("valid_until = %s")
|
||||
params.append(request.form['valid_until'])
|
||||
|
||||
if 'update_device_limit' in request.form and request.form['device_limit']:
|
||||
updates.append("device_limit = %s")
|
||||
params.append(int(request.form['device_limit']))
|
||||
|
||||
if 'update_active' in request.form:
|
||||
updates.append("is_active = %s")
|
||||
params.append('is_active' in request.form)
|
||||
|
||||
if not updates:
|
||||
flash('Keine Änderungen angegeben!', 'error')
|
||||
return redirect(url_for('batch.batch_update'))
|
||||
|
||||
# Führe Updates aus
|
||||
updated_count = 0
|
||||
not_found = []
|
||||
|
||||
for license_key in license_keys:
|
||||
# Prüfe ob Lizenz existiert
|
||||
cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,))
|
||||
result = cur.fetchone()
|
||||
|
||||
if not result:
|
||||
not_found.append(license_key)
|
||||
continue
|
||||
|
||||
license_id = result[0]
|
||||
|
||||
# Update ausführen
|
||||
update_params = params + [license_id]
|
||||
cur.execute(f"""
|
||||
UPDATE licenses
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = %s
|
||||
""", update_params)
|
||||
|
||||
# Audit-Log
|
||||
log_audit('BATCH_UPDATE', 'license', license_id,
|
||||
additional_info=f"Batch-Update: {', '.join(updates)}")
|
||||
|
||||
updated_count += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Feedback
|
||||
flash(f'{updated_count} Lizenzen erfolgreich aktualisiert!', 'success')
|
||||
|
||||
if not_found:
|
||||
flash(f'{len(not_found)} Lizenzen nicht gefunden: {", ".join(not_found[:5])}{"..." if len(not_found) > 5 else ""}', 'warning')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler bei Batch-Update: {str(e)}")
|
||||
flash('Fehler beim Batch-Update!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return render_template("batch_update.html")
|
||||
|
||||
|
||||
@batch_bp.route("/batch/import", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def batch_import():
|
||||
"""Import von Lizenzen aus CSV/Excel"""
|
||||
if request.method == "POST":
|
||||
if 'file' not in request.files:
|
||||
flash('Keine Datei ausgewählt!', 'error')
|
||||
return redirect(url_for('batch.batch_import'))
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
flash('Keine Datei ausgewählt!', 'error')
|
||||
return redirect(url_for('batch.batch_import'))
|
||||
|
||||
# Verarbeite Datei
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
# Lese Datei
|
||||
if file.filename.endswith('.csv'):
|
||||
df = pd.read_csv(file)
|
||||
elif file.filename.endswith(('.xlsx', '.xls')):
|
||||
df = pd.read_excel(file)
|
||||
else:
|
||||
flash('Ungültiges Dateiformat! Nur CSV und Excel erlaubt.', 'error')
|
||||
return redirect(url_for('batch.batch_import'))
|
||||
|
||||
# Validiere Spalten
|
||||
required_columns = ['customer_email', 'license_type', 'valid_from', 'valid_until', 'device_limit']
|
||||
missing_columns = [col for col in required_columns if col not in df.columns]
|
||||
|
||||
if missing_columns:
|
||||
flash(f'Fehlende Spalten: {", ".join(missing_columns)}', 'error')
|
||||
return redirect(url_for('batch.batch_import'))
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
imported_count = 0
|
||||
errors = []
|
||||
|
||||
for index, row in df.iterrows():
|
||||
try:
|
||||
# Finde oder erstelle Kunde
|
||||
email = row['customer_email']
|
||||
cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,))
|
||||
customer = cur.fetchone()
|
||||
|
||||
if not customer:
|
||||
# Erstelle neuen Kunden
|
||||
name = row.get('customer_name', email.split('@')[0])
|
||||
# Neue Kunden werden immer als Fake erstellt in der Testphase
|
||||
# TODO: Nach Testphase muss hier die Business-Logik angepasst werden
|
||||
is_fake = True
|
||||
cur.execute("""
|
||||
INSERT INTO customers (name, email, is_fake, created_at)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (name, email, is_fake, datetime.now()))
|
||||
customer_id = cur.fetchone()[0]
|
||||
customer_name = name
|
||||
else:
|
||||
customer_id = customer[0]
|
||||
customer_name = customer[1]
|
||||
# Hole is_fake Status vom existierenden Kunden
|
||||
cur.execute("SELECT is_fake FROM customers WHERE id = %s", (customer_id,))
|
||||
is_fake = cur.fetchone()[0]
|
||||
|
||||
# Generiere Lizenzschlüssel
|
||||
license_key = row.get('license_key', generate_license_key())
|
||||
|
||||
# Erstelle Lizenz - is_fake wird vom Kunden geerbt
|
||||
cur.execute("""
|
||||
INSERT INTO licenses (
|
||||
license_key, customer_id,
|
||||
license_type, valid_from, valid_until, device_limit,
|
||||
is_fake, created_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
license_key, customer_id,
|
||||
row['license_type'], row['valid_from'], row['valid_until'],
|
||||
int(row['device_limit']), is_fake,
|
||||
datetime.now()
|
||||
))
|
||||
|
||||
license_id = cur.fetchone()[0]
|
||||
imported_count += 1
|
||||
|
||||
# Audit-Log
|
||||
log_audit('IMPORT', 'license', license_id,
|
||||
additional_info=f"Importiert aus {file.filename}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Zeile {index + 2}: {str(e)}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Feedback
|
||||
flash(f'{imported_count} Lizenzen erfolgreich importiert!', 'success')
|
||||
|
||||
if errors:
|
||||
flash(f'{len(errors)} Fehler aufgetreten. Erste Fehler: {"; ".join(errors[:3])}', 'warning')
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Import: {str(e)}")
|
||||
flash(f'Fehler beim Import: {str(e)}', 'error')
|
||||
finally:
|
||||
if 'conn' in locals():
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return render_template("batch_import.html")
|
||||
466
v2_adminpanel/routes/customer_routes.py
Normale Datei
466
v2_adminpanel/routes/customer_routes.py
Normale Datei
@@ -0,0 +1,466 @@
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from db import get_connection, get_db_connection, get_db_cursor
|
||||
from models import get_customers, get_customer_by_id
|
||||
|
||||
# Create Blueprint
|
||||
customer_bp = Blueprint('customers', __name__)
|
||||
|
||||
# Test route
|
||||
@customer_bp.route("/test-customers")
|
||||
def test_customers():
|
||||
return "Customer blueprint is working!"
|
||||
|
||||
|
||||
@customer_bp.route("/customers")
|
||||
@login_required
|
||||
def customers():
|
||||
show_fake = request.args.get('show_fake', 'false').lower() == 'true'
|
||||
search = request.args.get('search', '').strip()
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
sort = request.args.get('sort', 'name')
|
||||
order = request.args.get('order', 'asc')
|
||||
|
||||
customers_list = get_customers(show_fake=show_fake, search=search)
|
||||
|
||||
# Sortierung
|
||||
if sort == 'name':
|
||||
customers_list.sort(key=lambda x: x['name'].lower(), reverse=(order == 'desc'))
|
||||
elif sort == 'email':
|
||||
customers_list.sort(key=lambda x: x['email'].lower(), reverse=(order == 'desc'))
|
||||
elif sort == 'created_at':
|
||||
customers_list.sort(key=lambda x: x['created_at'], reverse=(order == 'desc'))
|
||||
|
||||
# Paginierung
|
||||
total_customers = len(customers_list)
|
||||
total_pages = (total_customers + per_page - 1) // per_page
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
paginated_customers = customers_list[start:end]
|
||||
|
||||
return render_template("customers.html",
|
||||
customers=paginated_customers,
|
||||
show_fake=show_fake,
|
||||
search=search,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total_pages=total_pages,
|
||||
total_customers=total_customers,
|
||||
sort=sort,
|
||||
order=order,
|
||||
current_order=order)
|
||||
|
||||
|
||||
@customer_bp.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_customer(customer_id):
|
||||
if request.method == "POST":
|
||||
try:
|
||||
# Get current customer data for comparison
|
||||
current_customer = get_customer_by_id(customer_id)
|
||||
if not current_customer:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('customers.customers_licenses'))
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
# Update customer data
|
||||
new_values = {
|
||||
'name': request.form['name'],
|
||||
'email': request.form['email'],
|
||||
'is_fake': 'is_fake' in request.form
|
||||
}
|
||||
|
||||
cur.execute("""
|
||||
UPDATE customers
|
||||
SET name = %s, email = %s, is_fake = %s
|
||||
WHERE id = %s
|
||||
""", (
|
||||
new_values['name'],
|
||||
new_values['email'],
|
||||
new_values['is_fake'],
|
||||
customer_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Log changes
|
||||
log_audit('UPDATE', 'customer', customer_id,
|
||||
old_values={
|
||||
'name': current_customer['name'],
|
||||
'email': current_customer['email'],
|
||||
'is_fake': current_customer.get('is_fake', False)
|
||||
},
|
||||
new_values=new_values)
|
||||
|
||||
flash('Kunde erfolgreich aktualisiert!', 'success')
|
||||
|
||||
# Redirect mit show_fake Parameter wenn nötig
|
||||
redirect_url = url_for('customers.customers_licenses')
|
||||
if request.form.get('show_fake') == 'true':
|
||||
redirect_url += '?show_fake=true'
|
||||
return redirect(redirect_url)
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Aktualisieren des Kunden: {str(e)}")
|
||||
flash('Fehler beim Aktualisieren des Kunden!', 'error')
|
||||
return redirect(url_for('customers.customers_licenses'))
|
||||
|
||||
# GET request
|
||||
customer_data = get_customer_by_id(customer_id)
|
||||
if not customer_data:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('customers.customers_licenses'))
|
||||
|
||||
return render_template("edit_customer.html", customer=customer_data)
|
||||
|
||||
|
||||
@customer_bp.route("/customer/create", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_customer():
|
||||
if request.method == "POST":
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Insert new customer
|
||||
name = request.form['name']
|
||||
email = request.form['email']
|
||||
is_fake = 'is_fake' in request.form # Checkbox ist nur vorhanden wenn angekreuzt
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO customers (name, email, is_fake, created_at)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (name, email, is_fake, datetime.now()))
|
||||
|
||||
customer_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
|
||||
# Log creation
|
||||
log_audit('CREATE', 'customer', customer_id,
|
||||
new_values={
|
||||
'name': name,
|
||||
'email': email,
|
||||
'is_fake': is_fake
|
||||
})
|
||||
|
||||
if is_fake:
|
||||
flash(f'Fake-Kunde {name} erfolgreich erstellt!', 'success')
|
||||
else:
|
||||
flash(f'Kunde {name} erfolgreich erstellt!', 'success')
|
||||
|
||||
# Redirect mit show_fake=true wenn Fake-Kunde erstellt wurde
|
||||
if is_fake:
|
||||
return redirect(url_for('customers.customers_licenses', show_fake='true'))
|
||||
else:
|
||||
return redirect(url_for('customers.customers_licenses'))
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Erstellen des Kunden: {str(e)}")
|
||||
flash('Fehler beim Erstellen des Kunden!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return render_template("create_customer.html")
|
||||
|
||||
|
||||
@customer_bp.route("/customer/delete/<int:customer_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_customer(customer_id):
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Get customer data before deletion
|
||||
customer_data = get_customer_by_id(customer_id)
|
||||
if not customer_data:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('customers.customers_licenses'))
|
||||
|
||||
# Check if customer has licenses
|
||||
cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,))
|
||||
license_count = cur.fetchone()[0]
|
||||
|
||||
if license_count > 0:
|
||||
flash(f'Kunde kann nicht gelöscht werden - hat noch {license_count} Lizenz(en)!', 'error')
|
||||
return redirect(url_for('customers.customers_licenses'))
|
||||
|
||||
# Delete the customer
|
||||
cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Log deletion
|
||||
log_audit('DELETE', 'customer', customer_id,
|
||||
old_values={
|
||||
'name': customer_data['name'],
|
||||
'email': customer_data['email']
|
||||
})
|
||||
|
||||
flash(f'Kunde {customer_data["name"]} erfolgreich gelöscht!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Löschen des Kunden: {str(e)}")
|
||||
flash('Fehler beim Löschen des Kunden!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('customers.customers_licenses'))
|
||||
|
||||
|
||||
@customer_bp.route("/customers-licenses")
|
||||
@login_required
|
||||
def customers_licenses():
|
||||
"""Zeigt die Übersicht von Kunden und deren Lizenzen"""
|
||||
import logging
|
||||
import psycopg2
|
||||
logging.info("=== CUSTOMERS-LICENSES ROUTE CALLED ===")
|
||||
|
||||
# Get show_fake parameter from URL
|
||||
show_fake = request.args.get('show_fake', 'false').lower() == 'true'
|
||||
logging.info(f"show_fake parameter: {show_fake}")
|
||||
|
||||
try:
|
||||
# Direkte Verbindung ohne Helper-Funktionen
|
||||
conn = psycopg2.connect(
|
||||
host=os.getenv("POSTGRES_HOST", "postgres"),
|
||||
port=os.getenv("POSTGRES_PORT", "5432"),
|
||||
dbname=os.getenv("POSTGRES_DB"),
|
||||
user=os.getenv("POSTGRES_USER"),
|
||||
password=os.getenv("POSTGRES_PASSWORD")
|
||||
)
|
||||
conn.set_client_encoding('UTF8')
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole alle Kunden mit ihren Lizenzen
|
||||
# Wenn show_fake=false, zeige nur Nicht-Test-Kunden
|
||||
query = """
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.email,
|
||||
c.created_at,
|
||||
COUNT(l.id),
|
||||
COUNT(CASE WHEN l.is_active = true THEN 1 END),
|
||||
COUNT(CASE WHEN l.is_fake = true THEN 1 END),
|
||||
MAX(l.created_at),
|
||||
c.is_fake
|
||||
FROM customers c
|
||||
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||
WHERE (%s OR c.is_fake = false)
|
||||
GROUP BY c.id, c.name, c.email, c.created_at, c.is_fake
|
||||
ORDER BY c.name
|
||||
"""
|
||||
|
||||
cur.execute(query, (show_fake,))
|
||||
|
||||
customers = []
|
||||
results = cur.fetchall()
|
||||
logging.info(f"=== QUERY RETURNED {len(results)} ROWS ===")
|
||||
|
||||
for idx, row in enumerate(results):
|
||||
logging.info(f"Row {idx}: Type={type(row)}, Length={len(row) if hasattr(row, '__len__') else 'N/A'}")
|
||||
customers.append({
|
||||
'id': row[0],
|
||||
'name': row[1],
|
||||
'email': row[2],
|
||||
'created_at': row[3],
|
||||
'license_count': row[4],
|
||||
'active_licenses': row[5],
|
||||
'test_licenses': row[6],
|
||||
'last_license_created': row[7],
|
||||
'is_fake': row[8]
|
||||
})
|
||||
|
||||
return render_template("customers_licenses.html",
|
||||
customers=customers,
|
||||
show_fake=show_fake)
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_details = f"Fehler beim Laden der Kunden-Lizenz-Übersicht: {str(e)}\nType: {type(e)}\nTraceback: {traceback.format_exc()}"
|
||||
logging.error(error_details)
|
||||
flash(f'Datenbankfehler: {str(e)}', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
|
||||
@customer_bp.route("/api/customer/<int:customer_id>/licenses")
|
||||
@login_required
|
||||
def api_customer_licenses(customer_id):
|
||||
"""API-Endpunkt für die Lizenzen eines Kunden"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole Kundeninformationen
|
||||
customer = get_customer_by_id(customer_id)
|
||||
if not customer:
|
||||
return jsonify({'error': 'Kunde nicht gefunden'}), 404
|
||||
|
||||
# Hole alle Lizenzen des Kunden - vereinfachte Version ohne komplexe Subqueries
|
||||
cur.execute("""
|
||||
SELECT
|
||||
l.id,
|
||||
l.license_key,
|
||||
l.license_type,
|
||||
l.is_active,
|
||||
l.is_fake,
|
||||
l.valid_from,
|
||||
l.valid_until,
|
||||
l.device_limit,
|
||||
l.created_at,
|
||||
CASE
|
||||
WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||||
WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||||
WHEN l.is_active = false THEN 'inaktiv'
|
||||
ELSE 'aktiv'
|
||||
END as status,
|
||||
COALESCE(l.domain_count, 0) as domain_count,
|
||||
COALESCE(l.ipv4_count, 0) as ipv4_count,
|
||||
COALESCE(l.phone_count, 0) as phone_count
|
||||
FROM licenses l
|
||||
WHERE l.customer_id = %s
|
||||
ORDER BY l.created_at DESC, l.id DESC
|
||||
""", (customer_id,))
|
||||
|
||||
licenses = []
|
||||
for row in cur.fetchall():
|
||||
license_id = row[0]
|
||||
|
||||
# Hole die konkreten zugewiesenen Ressourcen für diese Lizenz
|
||||
conn2 = get_connection()
|
||||
cur2 = conn2.cursor()
|
||||
cur2.execute("""
|
||||
SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at
|
||||
FROM resource_pools rp
|
||||
JOIN license_resources lr ON rp.id = lr.resource_id
|
||||
WHERE lr.license_id = %s AND lr.is_active = true
|
||||
ORDER BY rp.resource_type, rp.resource_value
|
||||
""", (license_id,))
|
||||
|
||||
resources = {
|
||||
'domains': [],
|
||||
'ipv4s': [],
|
||||
'phones': []
|
||||
}
|
||||
|
||||
for res_row in cur2.fetchall():
|
||||
resource_data = {
|
||||
'id': res_row[0],
|
||||
'value': res_row[2],
|
||||
'assigned_at': res_row[3].strftime('%Y-%m-%d %H:%M:%S') if res_row[3] else None
|
||||
}
|
||||
|
||||
if res_row[1] == 'domain':
|
||||
resources['domains'].append(resource_data)
|
||||
elif res_row[1] == 'ipv4':
|
||||
resources['ipv4s'].append(resource_data)
|
||||
elif res_row[1] == 'phone':
|
||||
resources['phones'].append(resource_data)
|
||||
|
||||
cur2.close()
|
||||
conn2.close()
|
||||
|
||||
licenses.append({
|
||||
'id': row[0],
|
||||
'license_key': row[1],
|
||||
'license_type': row[2],
|
||||
'is_active': row[3],
|
||||
'is_fake': row[4],
|
||||
'valid_from': row[5].strftime('%Y-%m-%d') if row[5] else None,
|
||||
'valid_until': row[6].strftime('%Y-%m-%d') if row[6] else None,
|
||||
'device_limit': row[7],
|
||||
'created_at': row[8].strftime('%Y-%m-%d %H:%M:%S') if row[8] else None,
|
||||
'status': row[9],
|
||||
'domain_count': row[10],
|
||||
'ipv4_count': row[11],
|
||||
'phone_count': row[12],
|
||||
'active_sessions': 0, # Platzhalter
|
||||
'registered_devices': 0, # Platzhalter
|
||||
'active_devices': 0, # Platzhalter
|
||||
'actual_domain_count': len(resources['domains']),
|
||||
'actual_ipv4_count': len(resources['ipv4s']),
|
||||
'actual_phone_count': len(resources['phones']),
|
||||
'resources': resources,
|
||||
# License Server Data (Platzhalter bis Implementation)
|
||||
'recent_heartbeats': 0,
|
||||
'last_heartbeat': None,
|
||||
'active_server_devices': 0,
|
||||
'unresolved_anomalies': 0
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True, # Wichtig: Frontend erwartet dieses Feld
|
||||
'customer': {
|
||||
'id': customer['id'],
|
||||
'name': customer['name'],
|
||||
'email': customer['email'],
|
||||
'is_fake': customer.get('is_fake', False) # Include the is_fake field
|
||||
},
|
||||
'licenses': licenses
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_msg = f"Fehler beim Laden der Kundenlizenzen: {str(e)}\nTraceback: {traceback.format_exc()}"
|
||||
logging.error(error_msg)
|
||||
return jsonify({'error': f'Fehler beim Laden der Daten: {str(e)}', 'details': error_msg}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@customer_bp.route("/api/customer/<int:customer_id>/quick-stats")
|
||||
@login_required
|
||||
def api_customer_quick_stats(customer_id):
|
||||
"""Schnelle Statistiken für einen Kunden"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COUNT(l.id) as total_licenses,
|
||||
COUNT(CASE WHEN l.is_active = true THEN 1 END) as active_licenses,
|
||||
COUNT(CASE WHEN l.is_fake = true THEN 1 END) as test_licenses,
|
||||
SUM(l.device_limit) as total_device_limit
|
||||
FROM licenses l
|
||||
WHERE l.customer_id = %s
|
||||
""", (customer_id,))
|
||||
|
||||
row = cur.fetchone()
|
||||
|
||||
return jsonify({
|
||||
'total_licenses': row[0] or 0,
|
||||
'active_licenses': row[1] or 0,
|
||||
'test_licenses': row[2] or 0,
|
||||
'total_device_limit': row[3] or 0
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Kundenstatistiken: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Laden der Daten'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
495
v2_adminpanel/routes/export_routes.py
Normale Datei
495
v2_adminpanel/routes/export_routes.py
Normale Datei
@@ -0,0 +1,495 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, request, send_file
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.export import create_excel_export, create_csv_export, prepare_audit_export_data, format_datetime_for_export
|
||||
from db import get_connection
|
||||
|
||||
# Create Blueprint
|
||||
export_bp = Blueprint('export', __name__, url_prefix='/export')
|
||||
|
||||
|
||||
@export_bp.route("/licenses")
|
||||
@login_required
|
||||
def export_licenses():
|
||||
"""Exportiert Lizenzen als Excel-Datei"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Nur reale Daten exportieren - keine Fake-Daten
|
||||
query = """
|
||||
SELECT
|
||||
l.id,
|
||||
l.license_key,
|
||||
c.name as customer_name,
|
||||
c.email as customer_email,
|
||||
l.license_type,
|
||||
l.valid_from,
|
||||
l.valid_until,
|
||||
l.is_active,
|
||||
l.device_limit,
|
||||
l.created_at,
|
||||
l.is_fake,
|
||||
CASE
|
||||
WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen'
|
||||
WHEN l.is_active = false THEN 'Deaktiviert'
|
||||
ELSE 'Aktiv'
|
||||
END as status,
|
||||
(SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.is_active = true) as active_sessions,
|
||||
(SELECT COUNT(DISTINCT hardware_id) FROM sessions s WHERE s.license_key = l.license_key) as registered_devices
|
||||
FROM licenses l
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE l.is_fake = false
|
||||
ORDER BY l.created_at DESC
|
||||
"""
|
||||
|
||||
cur.execute(query)
|
||||
|
||||
# Daten für Export vorbereiten
|
||||
data = []
|
||||
columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', 'Gültig von',
|
||||
'Gültig bis', 'Aktiv', 'Gerätelimit', 'Erstellt am', 'Fake-Lizenz',
|
||||
'Status', 'Aktive Sessions', 'Registrierte Geräte']
|
||||
|
||||
for row in cur.fetchall():
|
||||
row_data = list(row)
|
||||
# Format datetime fields
|
||||
if row_data[5]: # valid_from
|
||||
row_data[5] = format_datetime_for_export(row_data[5])
|
||||
if row_data[6]: # valid_until
|
||||
row_data[6] = format_datetime_for_export(row_data[6])
|
||||
if row_data[9]: # created_at
|
||||
row_data[9] = format_datetime_for_export(row_data[9])
|
||||
data.append(row_data)
|
||||
|
||||
# Format prüfen
|
||||
format_type = request.args.get('format', 'excel').lower()
|
||||
|
||||
if format_type == 'csv':
|
||||
# CSV-Datei erstellen
|
||||
return create_csv_export(data, columns, 'lizenzen')
|
||||
else:
|
||||
# Excel-Datei erstellen
|
||||
return create_excel_export(data, columns, 'lizenzen')
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
return "Fehler beim Exportieren der Lizenzen", 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@export_bp.route("/audit")
|
||||
@login_required
|
||||
def export_audit():
|
||||
"""Exportiert Audit-Logs als Excel-Datei"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Filter aus Request
|
||||
days = int(request.args.get('days', 30))
|
||||
action_filter = request.args.get('action', '')
|
||||
entity_type_filter = request.args.get('entity_type', '')
|
||||
|
||||
# Query aufbauen
|
||||
query = """
|
||||
SELECT
|
||||
id, timestamp, username, action, entity_type, entity_id,
|
||||
ip_address, user_agent, old_values, new_values, additional_info
|
||||
FROM audit_log
|
||||
WHERE timestamp >= CURRENT_TIMESTAMP - INTERVAL '%s days'
|
||||
"""
|
||||
params = [days]
|
||||
|
||||
if action_filter:
|
||||
query += " AND action = %s"
|
||||
params.append(action_filter)
|
||||
|
||||
if entity_type_filter:
|
||||
query += " AND entity_type = %s"
|
||||
params.append(entity_type_filter)
|
||||
|
||||
query += " ORDER BY timestamp DESC"
|
||||
|
||||
cur.execute(query, params)
|
||||
|
||||
# Daten in Dictionary-Format umwandeln
|
||||
audit_logs = []
|
||||
for row in cur.fetchall():
|
||||
audit_logs.append({
|
||||
'id': row[0],
|
||||
'timestamp': row[1],
|
||||
'username': row[2],
|
||||
'action': row[3],
|
||||
'entity_type': row[4],
|
||||
'entity_id': row[5],
|
||||
'ip_address': row[6],
|
||||
'user_agent': row[7],
|
||||
'old_values': row[8],
|
||||
'new_values': row[9],
|
||||
'additional_info': row[10]
|
||||
})
|
||||
|
||||
# Daten für Export vorbereiten
|
||||
data = prepare_audit_export_data(audit_logs)
|
||||
|
||||
# Excel-Datei erstellen
|
||||
columns = ['ID', 'Zeitstempel', 'Benutzer', 'Aktion', 'Entität', 'Entität ID',
|
||||
'IP-Adresse', 'User Agent', 'Alte Werte', 'Neue Werte', 'Zusatzinfo']
|
||||
|
||||
# Format prüfen
|
||||
format_type = request.args.get('format', 'excel').lower()
|
||||
|
||||
if format_type == 'csv':
|
||||
# CSV-Datei erstellen
|
||||
return create_csv_export(data, columns, 'audit_log')
|
||||
else:
|
||||
# Excel-Datei erstellen
|
||||
return create_excel_export(data, columns, 'audit_log')
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
return "Fehler beim Exportieren der Audit-Logs", 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@export_bp.route("/customers")
|
||||
@login_required
|
||||
def export_customers():
|
||||
"""Exportiert Kunden als Excel-Datei"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# SQL Query - nur reale Kunden exportieren
|
||||
cur.execute("""
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.email,
|
||||
c.phone,
|
||||
c.address,
|
||||
c.created_at,
|
||||
c.is_fake,
|
||||
COUNT(l.id) as license_count,
|
||||
COUNT(CASE WHEN l.is_active = true THEN 1 END) as active_licenses,
|
||||
COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses
|
||||
FROM customers c
|
||||
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||
WHERE c.is_fake = false
|
||||
GROUP BY c.id, c.name, c.email, c.phone, c.address, c.created_at, c.is_fake
|
||||
ORDER BY c.name
|
||||
""")
|
||||
|
||||
# Daten für Export vorbereiten
|
||||
data = []
|
||||
columns = ['ID', 'Name', 'E-Mail', 'Telefon', 'Adresse', 'Erstellt am',
|
||||
'Test-Kunde', 'Anzahl Lizenzen', 'Aktive Lizenzen', 'Abgelaufene Lizenzen']
|
||||
|
||||
for row in cur.fetchall():
|
||||
# Format datetime fields (created_at ist Spalte 5)
|
||||
row_data = list(row)
|
||||
if row_data[5]: # created_at
|
||||
row_data[5] = format_datetime_for_export(row_data[5])
|
||||
data.append(row_data)
|
||||
|
||||
# Format prüfen
|
||||
format_type = request.args.get('format', 'excel').lower()
|
||||
|
||||
if format_type == 'csv':
|
||||
# CSV-Datei erstellen
|
||||
return create_csv_export(data, columns, 'kunden')
|
||||
else:
|
||||
# Excel-Datei erstellen
|
||||
return create_excel_export(data, columns, 'kunden')
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
return "Fehler beim Exportieren der Kunden", 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@export_bp.route("/sessions")
|
||||
@login_required
|
||||
def export_sessions():
|
||||
"""Exportiert Sessions als Excel-Datei"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Filter aus Request
|
||||
days = int(request.args.get('days', 7))
|
||||
active_only = request.args.get('active_only', 'false') == 'true'
|
||||
|
||||
# SQL Query
|
||||
if active_only:
|
||||
query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.license_key,
|
||||
l.customer_name,
|
||||
s.username,
|
||||
s.hardware_id,
|
||||
s.started_at,
|
||||
s.ended_at,
|
||||
s.last_heartbeat,
|
||||
s.is_active,
|
||||
l.license_type,
|
||||
l.is_fake
|
||||
FROM sessions s
|
||||
LEFT JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE s.is_active = true AND l.is_fake = false
|
||||
ORDER BY s.started_at DESC
|
||||
"""
|
||||
cur.execute(query)
|
||||
else:
|
||||
query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.license_key,
|
||||
l.customer_name,
|
||||
s.username,
|
||||
s.hardware_id,
|
||||
s.started_at,
|
||||
s.ended_at,
|
||||
s.last_heartbeat,
|
||||
s.is_active,
|
||||
l.license_type,
|
||||
l.is_fake
|
||||
FROM sessions s
|
||||
LEFT JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE s.started_at >= CURRENT_TIMESTAMP - INTERVAL '%s days' AND l.is_fake = false
|
||||
ORDER BY s.started_at DESC
|
||||
"""
|
||||
cur.execute(query, (days,))
|
||||
|
||||
# Daten für Export vorbereiten
|
||||
data = []
|
||||
columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'Benutzer', 'Geräte-ID',
|
||||
'Login-Zeit', 'Logout-Zeit', 'Letzte Aktivität', 'Aktiv',
|
||||
'Lizenztyp', 'Fake-Lizenz']
|
||||
|
||||
for row in cur.fetchall():
|
||||
row_data = list(row)
|
||||
# Format datetime fields
|
||||
if row_data[5]: # started_at
|
||||
row_data[5] = format_datetime_for_export(row_data[5])
|
||||
if row_data[6]: # ended_at
|
||||
row_data[6] = format_datetime_for_export(row_data[6])
|
||||
if row_data[7]: # last_heartbeat
|
||||
row_data[7] = format_datetime_for_export(row_data[7])
|
||||
data.append(row_data)
|
||||
|
||||
# Format prüfen
|
||||
format_type = request.args.get('format', 'excel').lower()
|
||||
|
||||
if format_type == 'csv':
|
||||
# CSV-Datei erstellen
|
||||
return create_csv_export(data, columns, 'sessions')
|
||||
else:
|
||||
# Excel-Datei erstellen
|
||||
return create_excel_export(data, columns, 'sessions')
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
return "Fehler beim Exportieren der Sessions", 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@export_bp.route("/resources")
|
||||
@login_required
|
||||
def export_resources():
|
||||
"""Exportiert Ressourcen als Excel-Datei"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Filter aus Request
|
||||
resource_type = request.args.get('type', 'all')
|
||||
status_filter = request.args.get('status', 'all')
|
||||
|
||||
# SQL Query aufbauen
|
||||
query = """
|
||||
SELECT
|
||||
rp.id,
|
||||
rp.resource_type,
|
||||
rp.resource_value,
|
||||
rp.status,
|
||||
rp.is_fake,
|
||||
l.license_key,
|
||||
c.name as customer_name,
|
||||
rp.created_at,
|
||||
rp.created_by,
|
||||
rp.status_changed_at,
|
||||
rp.status_changed_by,
|
||||
rp.quarantine_reason
|
||||
FROM resource_pools rp
|
||||
LEFT JOIN licenses l ON rp.allocated_to_license = l.id
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = []
|
||||
|
||||
if resource_type != 'all':
|
||||
query += " AND rp.resource_type = %s"
|
||||
params.append(resource_type)
|
||||
|
||||
if status_filter != 'all':
|
||||
query += " AND rp.status = %s"
|
||||
params.append(status_filter)
|
||||
|
||||
# Immer nur reale Ressourcen exportieren
|
||||
query += " AND rp.is_fake = false"
|
||||
|
||||
query += " ORDER BY rp.resource_type, rp.resource_value"
|
||||
|
||||
cur.execute(query, params)
|
||||
|
||||
# Daten für Export vorbereiten
|
||||
data = []
|
||||
columns = ['ID', 'Typ', 'Wert', 'Status', 'Test-Ressource', 'Lizenzschlüssel',
|
||||
'Kunde', 'Erstellt am', 'Erstellt von', 'Status geändert am',
|
||||
'Status geändert von', 'Quarantäne-Grund']
|
||||
|
||||
for row in cur.fetchall():
|
||||
row_data = list(row)
|
||||
# Format datetime fields
|
||||
if row_data[7]: # created_at
|
||||
row_data[7] = format_datetime_for_export(row_data[7])
|
||||
if row_data[9]: # status_changed_at
|
||||
row_data[9] = format_datetime_for_export(row_data[9])
|
||||
data.append(row_data)
|
||||
|
||||
# Format prüfen
|
||||
format_type = request.args.get('format', 'excel').lower()
|
||||
|
||||
if format_type == 'csv':
|
||||
# CSV-Datei erstellen
|
||||
return create_csv_export(data, columns, 'ressourcen')
|
||||
else:
|
||||
# Excel-Datei erstellen
|
||||
return create_excel_export(data, columns, 'ressourcen')
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
return "Fehler beim Exportieren der Ressourcen", 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@export_bp.route("/monitoring")
|
||||
@login_required
|
||||
def export_monitoring():
|
||||
"""Exportiert Monitoring-Daten als Excel/CSV-Datei"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Zeitraum aus Request
|
||||
hours = int(request.args.get('hours', 24))
|
||||
|
||||
# Monitoring-Daten sammeln
|
||||
data = []
|
||||
columns = ['Zeitstempel', 'Lizenz-ID', 'Lizenzschlüssel', 'Kunde', 'Hardware-ID',
|
||||
'IP-Adresse', 'Ereignis-Typ', 'Schweregrad', 'Beschreibung']
|
||||
|
||||
# Query für Heartbeats und optionale Anomalien
|
||||
query = """
|
||||
WITH monitoring_data AS (
|
||||
-- Lizenz-Heartbeats
|
||||
SELECT
|
||||
lh.timestamp,
|
||||
lh.license_id,
|
||||
l.license_key,
|
||||
c.name as customer_name,
|
||||
lh.hardware_id,
|
||||
lh.ip_address,
|
||||
'Heartbeat' as event_type,
|
||||
'Normal' as severity,
|
||||
'License validation' as description
|
||||
FROM license_heartbeats lh
|
||||
JOIN licenses l ON l.id = lh.license_id
|
||||
JOIN customers c ON c.id = l.customer_id
|
||||
WHERE lh.timestamp > CURRENT_TIMESTAMP - INTERVAL '%s hours'
|
||||
AND l.is_fake = false
|
||||
"""
|
||||
|
||||
# Check if anomaly_detections table exists
|
||||
cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'anomaly_detections'
|
||||
)
|
||||
""")
|
||||
has_anomalies = cur.fetchone()[0]
|
||||
|
||||
if has_anomalies:
|
||||
query += """
|
||||
UNION ALL
|
||||
|
||||
-- Anomalien
|
||||
SELECT
|
||||
ad.detected_at as timestamp,
|
||||
ad.license_id,
|
||||
l.license_key,
|
||||
c.name as customer_name,
|
||||
ad.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()
|
||||
506
v2_adminpanel/routes/license_routes.py
Normale Datei
506
v2_adminpanel/routes/license_routes.py
Normale Datei
@@ -0,0 +1,506 @@
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from utils.network import get_client_ip
|
||||
from utils.license import validate_license_key
|
||||
from db import get_connection, get_db_connection, get_db_cursor
|
||||
from models import get_licenses, get_license_by_id
|
||||
|
||||
# Create Blueprint
|
||||
license_bp = Blueprint('licenses', __name__)
|
||||
|
||||
|
||||
@license_bp.route("/licenses")
|
||||
@login_required
|
||||
def licenses():
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Get filter parameters
|
||||
search = request.args.get('search', '').strip()
|
||||
data_source = request.args.get('data_source', 'real') # real, fake, all
|
||||
license_type = request.args.get('license_type', '') # '', full, test
|
||||
license_status = request.args.get('license_status', '') # '', active, expiring, expired, inactive
|
||||
sort = request.args.get('sort', 'created_at')
|
||||
order = request.args.get('order', 'desc')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 50
|
||||
|
||||
# Get licenses based on data source
|
||||
if data_source == 'fake':
|
||||
licenses_list = get_licenses(show_fake=True)
|
||||
licenses_list = [l for l in licenses_list if l.get('is_fake')]
|
||||
elif data_source == 'all':
|
||||
licenses_list = get_licenses(show_fake=True)
|
||||
else: # real
|
||||
licenses_list = get_licenses(show_fake=False)
|
||||
|
||||
# Type filtering
|
||||
if license_type:
|
||||
if license_type == 'full':
|
||||
licenses_list = [l for l in licenses_list if l.get('license_type') == 'full']
|
||||
elif license_type == 'test':
|
||||
licenses_list = [l for l in licenses_list if l.get('license_type') == 'test']
|
||||
|
||||
# Status filtering
|
||||
if license_status:
|
||||
now = datetime.now().date()
|
||||
filtered_licenses = []
|
||||
|
||||
for license in licenses_list:
|
||||
if license_status == 'active' and license.get('is_active'):
|
||||
# Active means is_active=true, regardless of expiration date
|
||||
filtered_licenses.append(license)
|
||||
elif license_status == 'expired' and license.get('valid_until') and license.get('valid_until') <= now:
|
||||
# Expired means past valid_until date, regardless of is_active
|
||||
filtered_licenses.append(license)
|
||||
elif license_status == 'inactive' and not license.get('is_active'):
|
||||
# Inactive means is_active=false, regardless of date
|
||||
filtered_licenses.append(license)
|
||||
|
||||
licenses_list = filtered_licenses
|
||||
|
||||
# Search filtering
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
licenses_list = [l for l in licenses_list if
|
||||
search_lower in str(l.get('license_key', '')).lower() or
|
||||
search_lower in str(l.get('customer_name', '')).lower() or
|
||||
search_lower in str(l.get('customer_email', '')).lower()]
|
||||
|
||||
# Calculate pagination
|
||||
total = len(licenses_list)
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
licenses_list = licenses_list[start:end]
|
||||
|
||||
return render_template("licenses.html",
|
||||
licenses=licenses_list,
|
||||
search=search,
|
||||
data_source=data_source,
|
||||
license_type=license_type,
|
||||
license_status=license_status,
|
||||
sort=sort,
|
||||
order=order,
|
||||
page=page,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
per_page=per_page,
|
||||
now=datetime.now,
|
||||
timedelta=timedelta)
|
||||
|
||||
|
||||
@license_bp.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_license(license_id):
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
# Get current license data for comparison
|
||||
current_license = get_license_by_id(license_id)
|
||||
if not current_license:
|
||||
flash('Lizenz nicht gefunden!', 'error')
|
||||
return redirect(url_for('licenses.licenses'))
|
||||
|
||||
# Update license data
|
||||
new_values = {
|
||||
'license_key': request.form['license_key'],
|
||||
'license_type': request.form['license_type'],
|
||||
'valid_from': request.form['valid_from'],
|
||||
'valid_until': request.form['valid_until'],
|
||||
'is_active': 'is_active' in request.form,
|
||||
'device_limit': int(request.form.get('device_limit', 3))
|
||||
}
|
||||
|
||||
cur.execute("""
|
||||
UPDATE licenses
|
||||
SET license_key = %s, license_type = %s, valid_from = %s,
|
||||
valid_until = %s, is_active = %s, device_limit = %s
|
||||
WHERE id = %s
|
||||
""", (
|
||||
new_values['license_key'],
|
||||
new_values['license_type'],
|
||||
new_values['valid_from'],
|
||||
new_values['valid_until'],
|
||||
new_values['is_active'],
|
||||
new_values['device_limit'],
|
||||
license_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Log changes
|
||||
log_audit('UPDATE', 'license', license_id,
|
||||
old_values={
|
||||
'license_key': current_license.get('license_key'),
|
||||
'license_type': current_license.get('license_type'),
|
||||
'valid_from': str(current_license.get('valid_from', '')),
|
||||
'valid_until': str(current_license.get('valid_until', '')),
|
||||
'is_active': current_license.get('is_active'),
|
||||
'device_limit': current_license.get('device_limit', 3)
|
||||
},
|
||||
new_values=new_values)
|
||||
|
||||
flash('Lizenz erfolgreich aktualisiert!', 'success')
|
||||
|
||||
# Preserve show_test parameter if present
|
||||
show_test = request.args.get('show_test', 'false')
|
||||
return redirect(url_for('licenses.licenses', show_test=show_test))
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Aktualisieren der Lizenz: {str(e)}")
|
||||
flash('Fehler beim Aktualisieren der Lizenz!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# GET request
|
||||
license_data = get_license_by_id(license_id)
|
||||
if not license_data:
|
||||
flash('Lizenz nicht gefunden!', 'error')
|
||||
return redirect(url_for('licenses.licenses'))
|
||||
|
||||
return render_template("edit_license.html", license=license_data)
|
||||
|
||||
|
||||
@license_bp.route("/license/delete/<int:license_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_license(license_id):
|
||||
# Check for force parameter
|
||||
force_delete = request.form.get('force', 'false').lower() == 'true'
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Get license data before deletion
|
||||
license_data = get_license_by_id(license_id)
|
||||
if not license_data:
|
||||
flash('Lizenz nicht gefunden!', 'error')
|
||||
return redirect(url_for('licenses.licenses'))
|
||||
|
||||
# Safety check: Don't delete active licenses unless forced
|
||||
if license_data.get('is_active') and not force_delete:
|
||||
flash(f'Lizenz {license_data["license_key"]} ist noch aktiv! Bitte deaktivieren Sie die Lizenz zuerst oder nutzen Sie "Erzwungenes Löschen".', 'warning')
|
||||
return redirect(url_for('licenses.licenses'))
|
||||
|
||||
# Check for recent activity (heartbeats in last 24 hours)
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM license_heartbeats
|
||||
WHERE license_id = %s
|
||||
AND timestamp > NOW() - INTERVAL '24 hours'
|
||||
""", (license_id,))
|
||||
recent_heartbeats = cur.fetchone()[0]
|
||||
|
||||
if recent_heartbeats > 0 and not force_delete:
|
||||
flash(f'Lizenz {license_data["license_key"]} hatte in den letzten 24 Stunden {recent_heartbeats} Aktivitäten! '
|
||||
f'Die Lizenz wird möglicherweise noch aktiv genutzt. Bitte prüfen Sie dies vor dem Löschen.', 'danger')
|
||||
return redirect(url_for('licenses.licenses'))
|
||||
except Exception as e:
|
||||
# If heartbeats table doesn't exist, continue
|
||||
logging.warning(f"Could not check heartbeats: {str(e)}")
|
||||
|
||||
# Check for active devices/activations
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM activations
|
||||
WHERE license_id = %s
|
||||
AND is_active = true
|
||||
""", (license_id,))
|
||||
active_devices = cur.fetchone()[0]
|
||||
|
||||
if active_devices > 0 and not force_delete:
|
||||
flash(f'Lizenz {license_data["license_key"]} hat {active_devices} aktive Geräte! '
|
||||
f'Bitte deaktivieren Sie alle Geräte vor dem Löschen.', 'danger')
|
||||
return redirect(url_for('licenses.licenses'))
|
||||
except Exception as e:
|
||||
# If activations table doesn't exist, continue
|
||||
logging.warning(f"Could not check activations: {str(e)}")
|
||||
|
||||
# Delete from sessions first
|
||||
cur.execute("DELETE FROM sessions WHERE license_key = %s", (license_data['license_key'],))
|
||||
|
||||
# Delete from license_heartbeats if exists
|
||||
try:
|
||||
cur.execute("DELETE FROM license_heartbeats WHERE license_id = %s", (license_id,))
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delete from activations if exists
|
||||
try:
|
||||
cur.execute("DELETE FROM activations WHERE license_id = %s", (license_id,))
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delete the license
|
||||
cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Log deletion with force flag
|
||||
log_audit('DELETE', 'license', license_id,
|
||||
old_values={
|
||||
'license_key': license_data['license_key'],
|
||||
'customer_name': license_data['customer_name'],
|
||||
'customer_email': license_data['customer_email'],
|
||||
'was_active': license_data.get('is_active'),
|
||||
'forced': force_delete
|
||||
},
|
||||
additional_info=f"{'Forced deletion' if force_delete else 'Normal deletion'}")
|
||||
|
||||
flash(f'Lizenz {license_data["license_key"]} erfolgreich gelöscht!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Löschen der Lizenz: {str(e)}")
|
||||
flash('Fehler beim Löschen der Lizenz!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Preserve show_test parameter if present
|
||||
show_test = request.args.get('show_test', 'false')
|
||||
return redirect(url_for('licenses.licenses', show_test=show_test))
|
||||
|
||||
|
||||
@license_bp.route("/create", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_license():
|
||||
if request.method == "POST":
|
||||
customer_id = request.form.get("customer_id")
|
||||
license_key = request.form["license_key"].upper() # Immer Großbuchstaben
|
||||
license_type = request.form["license_type"]
|
||||
valid_from = request.form["valid_from"]
|
||||
# is_fake wird später vom Kunden geerbt
|
||||
|
||||
# Berechne valid_until basierend auf Laufzeit
|
||||
duration = int(request.form.get("duration", 1))
|
||||
duration_type = request.form.get("duration_type", "years")
|
||||
|
||||
start_date = datetime.strptime(valid_from, "%Y-%m-%d")
|
||||
|
||||
if duration_type == "days":
|
||||
end_date = start_date + timedelta(days=duration)
|
||||
elif duration_type == "months":
|
||||
end_date = start_date + relativedelta(months=duration)
|
||||
else: # years
|
||||
end_date = start_date + relativedelta(years=duration)
|
||||
|
||||
# Ein Tag abziehen, da der Starttag mitgezählt wird
|
||||
end_date = end_date - timedelta(days=1)
|
||||
valid_until = end_date.strftime("%Y-%m-%d")
|
||||
|
||||
# Validiere License Key Format
|
||||
if not validate_license_key(license_key):
|
||||
flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error')
|
||||
return redirect(url_for('licenses.create_license'))
|
||||
|
||||
# Resource counts
|
||||
domain_count = int(request.form.get("domain_count", 1))
|
||||
ipv4_count = int(request.form.get("ipv4_count", 1))
|
||||
phone_count = int(request.form.get("phone_count", 1))
|
||||
device_limit = int(request.form.get("device_limit", 3))
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Prüfe ob neuer Kunde oder bestehender
|
||||
if customer_id == "new":
|
||||
# Neuer Kunde
|
||||
name = request.form.get("customer_name")
|
||||
email = request.form.get("email")
|
||||
|
||||
if not name:
|
||||
flash('Kundenname ist erforderlich!', 'error')
|
||||
return redirect(url_for('licenses.create_license'))
|
||||
|
||||
# Prüfe ob E-Mail bereits existiert
|
||||
if email:
|
||||
cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||||
return redirect(url_for('licenses.create_license'))
|
||||
|
||||
# Neuer Kunde wird immer als Fake erstellt, da wir in der Testphase sind
|
||||
# TODO: Nach Testphase muss hier die Business-Logik angepasst werden
|
||||
is_fake = True
|
||||
cur.execute("""
|
||||
INSERT INTO customers (name, email, is_fake, created_at)
|
||||
VALUES (%s, %s, %s, NOW())
|
||||
RETURNING id
|
||||
""", (name, email, is_fake))
|
||||
customer_id = cur.fetchone()[0]
|
||||
customer_info = {'name': name, 'email': email, 'is_fake': is_fake}
|
||||
|
||||
# Audit-Log für neuen Kunden
|
||||
log_audit('CREATE', 'customer', customer_id,
|
||||
new_values={'name': name, 'email': email, 'is_fake': is_fake})
|
||||
else:
|
||||
# Bestehender Kunde - hole Infos für Audit-Log
|
||||
cur.execute("SELECT name, email, is_fake FROM customers WHERE id = %s", (customer_id,))
|
||||
customer_data = cur.fetchone()
|
||||
if not customer_data:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('licenses.create_license'))
|
||||
customer_info = {'name': customer_data[0], 'email': customer_data[1]}
|
||||
|
||||
# Lizenz erbt immer den is_fake Status vom Kunden
|
||||
is_fake = customer_data[2]
|
||||
|
||||
# Lizenz hinzufügen
|
||||
cur.execute("""
|
||||
INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active,
|
||||
domain_count, ipv4_count, phone_count, device_limit, is_fake)
|
||||
VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (license_key, customer_id, license_type, valid_from, valid_until,
|
||||
domain_count, ipv4_count, phone_count, device_limit, is_fake))
|
||||
license_id = cur.fetchone()[0]
|
||||
|
||||
# Ressourcen zuweisen
|
||||
try:
|
||||
# Prüfe Verfügbarkeit
|
||||
cur.execute("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_fake = %s) as domains,
|
||||
(SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_fake = %s) as ipv4s,
|
||||
(SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_fake = %s) as phones
|
||||
""", (is_fake, is_fake, is_fake))
|
||||
available = cur.fetchone()
|
||||
|
||||
if available[0] < domain_count:
|
||||
raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})")
|
||||
if available[1] < ipv4_count:
|
||||
raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})")
|
||||
if available[2] < phone_count:
|
||||
raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})")
|
||||
|
||||
# Domains zuweisen
|
||||
if domain_count > 0:
|
||||
cur.execute("""
|
||||
SELECT id FROM resource_pools
|
||||
WHERE resource_type = 'domain' AND status = 'available' AND is_fake = %s
|
||||
LIMIT %s FOR UPDATE
|
||||
""", (is_fake, domain_count))
|
||||
for (resource_id,) in cur.fetchall():
|
||||
cur.execute("""
|
||||
UPDATE resource_pools
|
||||
SET status = 'allocated', allocated_to_license = %s,
|
||||
status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||||
WHERE id = %s
|
||||
""", (license_id, session['username'], resource_id))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (license_id, resource_id, session['username']))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||||
VALUES (%s, %s, 'allocated', %s, %s)
|
||||
""", (resource_id, license_id, session['username'], get_client_ip()))
|
||||
|
||||
# IPv4s zuweisen
|
||||
if ipv4_count > 0:
|
||||
cur.execute("""
|
||||
SELECT id FROM resource_pools
|
||||
WHERE resource_type = 'ipv4' AND status = 'available' AND is_fake = %s
|
||||
LIMIT %s FOR UPDATE
|
||||
""", (is_fake, ipv4_count))
|
||||
for (resource_id,) in cur.fetchall():
|
||||
cur.execute("""
|
||||
UPDATE resource_pools
|
||||
SET status = 'allocated', allocated_to_license = %s,
|
||||
status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||||
WHERE id = %s
|
||||
""", (license_id, session['username'], resource_id))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (license_id, resource_id, session['username']))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||||
VALUES (%s, %s, 'allocated', %s, %s)
|
||||
""", (resource_id, license_id, session['username'], get_client_ip()))
|
||||
|
||||
# Telefonnummern zuweisen
|
||||
if phone_count > 0:
|
||||
cur.execute("""
|
||||
SELECT id FROM resource_pools
|
||||
WHERE resource_type = 'phone' AND status = 'available' AND is_fake = %s
|
||||
LIMIT %s FOR UPDATE
|
||||
""", (is_fake, phone_count))
|
||||
for (resource_id,) in cur.fetchall():
|
||||
cur.execute("""
|
||||
UPDATE resource_pools
|
||||
SET status = 'allocated', allocated_to_license = %s,
|
||||
status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||||
WHERE id = %s
|
||||
""", (license_id, session['username'], resource_id))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (license_id, resource_id, session['username']))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||||
VALUES (%s, %s, 'allocated', %s, %s)
|
||||
""", (resource_id, license_id, session['username'], get_client_ip()))
|
||||
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
flash(str(e), 'error')
|
||||
return redirect(url_for('licenses.create_license'))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit-Log
|
||||
log_audit('CREATE', 'license', license_id,
|
||||
new_values={
|
||||
'license_key': license_key,
|
||||
'customer_name': customer_info['name'],
|
||||
'customer_email': customer_info['email'],
|
||||
'license_type': license_type,
|
||||
'valid_from': valid_from,
|
||||
'valid_until': valid_until,
|
||||
'device_limit': device_limit,
|
||||
'is_fake': is_fake
|
||||
})
|
||||
|
||||
flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}")
|
||||
flash('Fehler beim Erstellen der Lizenz!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Preserve show_test parameter if present
|
||||
redirect_url = url_for('licenses.create_license')
|
||||
if request.args.get('show_test') == 'true':
|
||||
redirect_url += "?show_test=true"
|
||||
return redirect(redirect_url)
|
||||
|
||||
# Unterstützung für vorausgewählten Kunden
|
||||
preselected_customer_id = request.args.get('customer_id', type=int)
|
||||
return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id)
|
||||
428
v2_adminpanel/routes/monitoring_routes.py
Normale Datei
428
v2_adminpanel/routes/monitoring_routes.py
Normale Datei
@@ -0,0 +1,428 @@
|
||||
from flask import Blueprint, render_template, jsonify, request, session, redirect, url_for
|
||||
from functools import wraps
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
import os
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from utils.partition_helper import ensure_partition_exists, check_table_exists
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create a function to get database connection
|
||||
def get_db_connection():
|
||||
return psycopg2.connect(
|
||||
host=os.environ.get('POSTGRES_HOST', 'postgres'),
|
||||
database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'),
|
||||
user=os.environ.get('POSTGRES_USER', 'postgres'),
|
||||
password=os.environ.get('POSTGRES_PASSWORD', 'postgres')
|
||||
)
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
return redirect(url_for('auth.login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
# Create Blueprint
|
||||
monitoring_bp = Blueprint('monitoring', __name__)
|
||||
|
||||
@monitoring_bp.route('/monitoring')
|
||||
@login_required
|
||||
def unified_monitoring():
|
||||
"""Unified monitoring dashboard combining live activity and anomaly detection"""
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
# Initialize default values
|
||||
system_status = 'normal'
|
||||
status_color = 'success'
|
||||
active_alerts = 0
|
||||
live_metrics = {
|
||||
'active_licenses': 0,
|
||||
'total_validations': 0,
|
||||
'unique_devices': 0,
|
||||
'unique_ips': 0,
|
||||
'avg_response_time': 0
|
||||
}
|
||||
trend_data = []
|
||||
activity_stream = []
|
||||
geo_data = []
|
||||
top_licenses = []
|
||||
anomaly_distribution = []
|
||||
performance_data = []
|
||||
|
||||
# Check if tables exist before querying
|
||||
has_heartbeats = check_table_exists(conn, 'license_heartbeats')
|
||||
has_anomalies = check_table_exists(conn, 'anomaly_detections')
|
||||
|
||||
if has_anomalies:
|
||||
# Get active alerts count
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM anomaly_detections
|
||||
WHERE resolved = false
|
||||
AND detected_at > NOW() - INTERVAL '24 hours'
|
||||
""")
|
||||
active_alerts = cur.fetchone()['count'] or 0
|
||||
|
||||
# Determine system status based on alerts
|
||||
if active_alerts == 0:
|
||||
system_status = 'normal'
|
||||
status_color = 'success'
|
||||
elif active_alerts < 5:
|
||||
system_status = 'warning'
|
||||
status_color = 'warning'
|
||||
else:
|
||||
system_status = 'critical'
|
||||
status_color = 'danger'
|
||||
|
||||
if has_heartbeats:
|
||||
# Ensure current month partition exists
|
||||
ensure_partition_exists(conn, 'license_heartbeats', datetime.now())
|
||||
|
||||
# Executive summary metrics
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COUNT(DISTINCT license_id) as active_licenses,
|
||||
COUNT(*) as total_validations,
|
||||
COUNT(DISTINCT hardware_id) as unique_devices,
|
||||
COUNT(DISTINCT ip_address) as unique_ips,
|
||||
0 as avg_response_time
|
||||
FROM license_heartbeats
|
||||
WHERE timestamp > NOW() - INTERVAL '5 minutes'
|
||||
""")
|
||||
result = cur.fetchone()
|
||||
if result:
|
||||
live_metrics = result
|
||||
|
||||
# Get 24h trend data for metrics
|
||||
cur.execute("""
|
||||
SELECT
|
||||
DATE_TRUNC('hour', timestamp) as hour,
|
||||
COUNT(DISTINCT license_id) as licenses,
|
||||
COUNT(*) as validations
|
||||
FROM license_heartbeats
|
||||
WHERE timestamp > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
""")
|
||||
trend_data = cur.fetchall()
|
||||
|
||||
# Activity stream - just validations if no anomalies table
|
||||
if has_anomalies:
|
||||
cur.execute("""
|
||||
WITH combined_events AS (
|
||||
-- Normal validations
|
||||
SELECT
|
||||
lh.timestamp,
|
||||
'validation' as event_type,
|
||||
'normal' as severity,
|
||||
l.license_key,
|
||||
c.name as customer_name,
|
||||
lh.ip_address,
|
||||
lh.hardware_id,
|
||||
NULL as anomaly_type,
|
||||
NULL as description
|
||||
FROM license_heartbeats lh
|
||||
JOIN licenses l ON l.id = lh.license_id
|
||||
JOIN customers c ON c.id = l.customer_id
|
||||
WHERE lh.timestamp > NOW() - INTERVAL '1 hour'
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Anomalies
|
||||
SELECT
|
||||
ad.detected_at as timestamp,
|
||||
'anomaly' as event_type,
|
||||
ad.severity,
|
||||
l.license_key,
|
||||
c.name as customer_name,
|
||||
ad.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")
|
||||
721
v2_adminpanel/routes/resource_routes.py
Normale Datei
721
v2_adminpanel/routes/resource_routes.py
Normale Datei
@@ -0,0 +1,721 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify, send_file
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from utils.network import get_client_ip
|
||||
from db import get_connection, get_db_connection, get_db_cursor
|
||||
|
||||
# Create Blueprint
|
||||
resource_bp = Blueprint('resources', __name__)
|
||||
|
||||
|
||||
@resource_bp.route('/resources')
|
||||
@login_required
|
||||
def resources():
|
||||
"""Zeigt die Ressourcenpool-Übersicht"""
|
||||
import logging
|
||||
logging.info("=== RESOURCES ROUTE CALLED ===")
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Filter aus Query-Parametern
|
||||
resource_type = request.args.get('type', 'all')
|
||||
status_filter = request.args.get('status', 'all')
|
||||
search_query = request.args.get('search', '')
|
||||
show_fake = request.args.get('show_fake', 'false') == 'true'
|
||||
|
||||
logging.info(f"Filters: type={resource_type}, status={status_filter}, search={search_query}, show_fake={show_fake}")
|
||||
|
||||
# Basis-Query
|
||||
query = """
|
||||
SELECT
|
||||
rp.id,
|
||||
rp.resource_type,
|
||||
rp.resource_value,
|
||||
rp.status,
|
||||
rp.is_fake,
|
||||
rp.allocated_to_license,
|
||||
rp.created_at,
|
||||
rp.status_changed_at,
|
||||
rp.status_changed_by,
|
||||
c.name as customer_name,
|
||||
l.license_type
|
||||
FROM resource_pools rp
|
||||
LEFT JOIN licenses l ON rp.allocated_to_license = l.id
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = []
|
||||
|
||||
# Filter anwenden
|
||||
if resource_type != 'all':
|
||||
query += " AND rp.resource_type = %s"
|
||||
params.append(resource_type)
|
||||
|
||||
if status_filter != 'all':
|
||||
query += " AND rp.status = %s"
|
||||
params.append(status_filter)
|
||||
|
||||
if search_query:
|
||||
query += " AND (rp.resource_value ILIKE %s OR c.name ILIKE %s)"
|
||||
params.extend([f'%{search_query}%', f'%{search_query}%'])
|
||||
|
||||
if not show_fake:
|
||||
query += " AND rp.is_fake = false"
|
||||
|
||||
query += " ORDER BY rp.resource_type, rp.resource_value"
|
||||
|
||||
cur.execute(query, params)
|
||||
|
||||
resources_list = []
|
||||
rows = cur.fetchall()
|
||||
logging.info(f"Query returned {len(rows)} rows")
|
||||
|
||||
for row in rows:
|
||||
resources_list.append({
|
||||
'id': row[0],
|
||||
'resource_type': row[1],
|
||||
'resource_value': row[2],
|
||||
'status': row[3],
|
||||
'is_fake': row[4],
|
||||
'allocated_to_license': row[5],
|
||||
'created_at': row[6],
|
||||
'status_changed_at': row[7],
|
||||
'status_changed_by': row[8],
|
||||
'customer_name': row[9],
|
||||
'license_type': row[10]
|
||||
})
|
||||
|
||||
# Statistiken
|
||||
stats_query = """
|
||||
SELECT
|
||||
resource_type,
|
||||
status,
|
||||
is_fake,
|
||||
COUNT(*) as count
|
||||
FROM resource_pools
|
||||
"""
|
||||
|
||||
# Apply test filter to statistics as well
|
||||
if not show_fake:
|
||||
stats_query += " WHERE is_fake = false"
|
||||
|
||||
stats_query += " GROUP BY resource_type, status, is_fake"
|
||||
|
||||
cur.execute(stats_query)
|
||||
|
||||
stats = {}
|
||||
for row in cur.fetchall():
|
||||
res_type = row[0]
|
||||
status = row[1]
|
||||
is_fake = row[2]
|
||||
count = row[3]
|
||||
|
||||
if res_type not in stats:
|
||||
stats[res_type] = {
|
||||
'total': 0,
|
||||
'available': 0,
|
||||
'allocated': 0,
|
||||
'quarantined': 0,
|
||||
'test': 0,
|
||||
'prod': 0,
|
||||
'available_percent': 0
|
||||
}
|
||||
|
||||
stats[res_type]['total'] += count
|
||||
stats[res_type][status] = stats[res_type].get(status, 0) + count
|
||||
if is_fake:
|
||||
stats[res_type]['test'] += count
|
||||
else:
|
||||
stats[res_type]['prod'] += count
|
||||
|
||||
# Calculate percentages
|
||||
for res_type in stats:
|
||||
if stats[res_type]['total'] > 0:
|
||||
stats[res_type]['available_percent'] = int((stats[res_type]['available'] / stats[res_type]['total']) * 100)
|
||||
|
||||
# Pagination parameters (simple defaults for now)
|
||||
try:
|
||||
page = int(request.args.get('page', '1') or '1')
|
||||
except (ValueError, TypeError):
|
||||
page = 1
|
||||
per_page = 50
|
||||
total = len(resources_list)
|
||||
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
|
||||
# Sort parameters
|
||||
sort_by = request.args.get('sort', 'id')
|
||||
sort_order = request.args.get('order', 'asc')
|
||||
|
||||
return render_template('resources.html',
|
||||
resources=resources_list,
|
||||
stats=stats,
|
||||
resource_type=resource_type,
|
||||
status_filter=status_filter,
|
||||
search=search_query, # Changed from search_query to search
|
||||
show_fake=show_fake,
|
||||
total=total,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
recent_activities=[], # Empty for now
|
||||
datetime=datetime) # For template datetime usage
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logging.error(f"Fehler beim Laden der Ressourcen: {str(e)}")
|
||||
logging.error(f"Traceback: {traceback.format_exc()}")
|
||||
flash('Fehler beim Laden der Ressourcen!', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
# Old add_resource function removed - using add_resources instead
|
||||
|
||||
|
||||
@resource_bp.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
|
||||
@login_required
|
||||
def quarantine(resource_id):
|
||||
"""Ressource in Quarantäne versetzen"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
reason = request.form.get('reason', '')
|
||||
|
||||
# Hole aktuelle Ressourcen-Info
|
||||
cur.execute("""
|
||||
SELECT resource_value, status, allocated_to_license
|
||||
FROM resource_pools WHERE id = %s
|
||||
""", (resource_id,))
|
||||
resource = cur.fetchone()
|
||||
|
||||
if not resource:
|
||||
flash('Ressource nicht gefunden!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
|
||||
# Setze Status auf quarantined
|
||||
cur.execute("""
|
||||
UPDATE resource_pools
|
||||
SET status = 'quarantined',
|
||||
allocated_to_license = NULL,
|
||||
status_changed_at = CURRENT_TIMESTAMP,
|
||||
status_changed_by = %s,
|
||||
quarantine_reason = %s
|
||||
WHERE id = %s
|
||||
""", (session['username'], reason, resource_id))
|
||||
|
||||
# Wenn die Ressource zugewiesen war, entferne die Zuweisung
|
||||
if resource[2]: # allocated_to_license
|
||||
cur.execute("""
|
||||
DELETE FROM license_resources
|
||||
WHERE license_id = %s AND resource_id = %s
|
||||
""", (resource[2], resource_id))
|
||||
|
||||
# History-Eintrag
|
||||
cur.execute("""
|
||||
INSERT INTO resource_history (resource_id, license_id, action, action_by, notes, ip_address)
|
||||
VALUES (%s, %s, 'quarantined', %s, %s, %s)
|
||||
""", (resource_id, resource[2], session['username'], reason, get_client_ip()))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit-Log
|
||||
log_audit('QUARANTINE', 'resource', resource_id,
|
||||
old_values={'status': resource[1]},
|
||||
new_values={'status': 'quarantined', 'reason': reason})
|
||||
|
||||
flash(f'Ressource {resource[0]} in Quarantäne versetzt!', 'warning')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Quarantänisieren der Ressource: {str(e)}")
|
||||
flash('Fehler beim Quarantänisieren der Ressource!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('resources.resources'))
|
||||
|
||||
|
||||
@resource_bp.route('/resources/release', methods=['POST'])
|
||||
@login_required
|
||||
def release():
|
||||
"""Ressourcen aus Quarantäne freigeben oder von Lizenz entfernen"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
resource_ids = request.form.getlist('resource_ids[]')
|
||||
action = request.form.get('action', 'release')
|
||||
|
||||
if not resource_ids:
|
||||
flash('Keine Ressourcen ausgewählt!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
|
||||
for resource_id in resource_ids:
|
||||
# Hole aktuelle Ressourcen-Info
|
||||
cur.execute("""
|
||||
SELECT resource_value, status, allocated_to_license
|
||||
FROM resource_pools WHERE id = %s
|
||||
""", (resource_id,))
|
||||
resource = cur.fetchone()
|
||||
|
||||
if resource:
|
||||
# Setze Status auf available
|
||||
cur.execute("""
|
||||
UPDATE resource_pools
|
||||
SET status = 'available',
|
||||
allocated_to_license = NULL,
|
||||
status_changed_at = CURRENT_TIMESTAMP,
|
||||
status_changed_by = %s,
|
||||
quarantine_reason = NULL
|
||||
WHERE id = %s
|
||||
""", (session['username'], resource_id))
|
||||
|
||||
# Entferne Lizenz-Zuweisung wenn vorhanden
|
||||
if resource[2]: # allocated_to_license
|
||||
cur.execute("""
|
||||
DELETE FROM license_resources
|
||||
WHERE license_id = %s AND resource_id = %s
|
||||
""", (resource[2], resource_id))
|
||||
|
||||
# History-Eintrag
|
||||
cur.execute("""
|
||||
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||||
VALUES (%s, %s, 'released', %s, %s)
|
||||
""", (resource_id, resource[2], session['username'], get_client_ip()))
|
||||
|
||||
# Audit-Log
|
||||
log_audit('RELEASE', 'resource', resource_id,
|
||||
old_values={'status': resource[1]},
|
||||
new_values={'status': 'available'})
|
||||
|
||||
conn.commit()
|
||||
flash(f'{len(resource_ids)} Ressource(n) freigegeben!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Freigeben der Ressourcen: {str(e)}")
|
||||
flash('Fehler beim Freigeben der Ressourcen!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('resources.resources'))
|
||||
|
||||
|
||||
@resource_bp.route('/resources/history/<int:resource_id>')
|
||||
@login_required
|
||||
def resource_history(resource_id):
|
||||
"""Zeigt die Historie einer Ressource"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole Ressourcen-Info
|
||||
cur.execute("""
|
||||
SELECT resource_type, resource_value, status, is_fake
|
||||
FROM resource_pools WHERE id = %s
|
||||
""", (resource_id,))
|
||||
resource = cur.fetchone()
|
||||
|
||||
if not resource:
|
||||
flash('Ressource nicht gefunden!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
|
||||
# Hole Historie
|
||||
cur.execute("""
|
||||
SELECT
|
||||
rh.action,
|
||||
rh.action_timestamp,
|
||||
rh.action_by,
|
||||
rh.notes,
|
||||
rh.ip_address,
|
||||
l.license_key,
|
||||
c.name as customer_name
|
||||
FROM resource_history rh
|
||||
LEFT JOIN licenses l ON rh.license_id = l.id
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE rh.resource_id = %s
|
||||
ORDER BY rh.action_timestamp DESC
|
||||
""", (resource_id,))
|
||||
|
||||
history = []
|
||||
for row in cur.fetchall():
|
||||
history.append({
|
||||
'action': row[0],
|
||||
'timestamp': row[1],
|
||||
'by': row[2],
|
||||
'notes': row[3],
|
||||
'ip_address': row[4],
|
||||
'license_key': row[5],
|
||||
'customer_name': row[6]
|
||||
})
|
||||
|
||||
return render_template('resource_history.html',
|
||||
resource={
|
||||
'id': resource_id,
|
||||
'type': resource[0],
|
||||
'value': resource[1],
|
||||
'status': resource[2],
|
||||
'is_fake': resource[3]
|
||||
},
|
||||
history=history)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Ressourcen-Historie: {str(e)}")
|
||||
flash('Fehler beim Laden der Historie!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@resource_bp.route('/resources/metrics')
|
||||
@login_required
|
||||
def resource_metrics():
|
||||
"""Zeigt Metriken und Statistiken zu Ressourcen"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Allgemeine Statistiken
|
||||
cur.execute("""
|
||||
SELECT
|
||||
resource_type,
|
||||
status,
|
||||
is_fake,
|
||||
COUNT(*) as count
|
||||
FROM resource_pools
|
||||
GROUP BY resource_type, status, is_fake
|
||||
ORDER BY resource_type, status
|
||||
""")
|
||||
|
||||
general_stats = {}
|
||||
for row in cur.fetchall():
|
||||
res_type = row[0]
|
||||
if res_type not in general_stats:
|
||||
general_stats[res_type] = {
|
||||
'total': 0,
|
||||
'available': 0,
|
||||
'allocated': 0,
|
||||
'quarantined': 0,
|
||||
'test': 0,
|
||||
'production': 0
|
||||
}
|
||||
|
||||
general_stats[res_type]['total'] += row[3]
|
||||
general_stats[res_type][row[1]] += row[3]
|
||||
if row[2]:
|
||||
general_stats[res_type]['test'] += row[3]
|
||||
else:
|
||||
general_stats[res_type]['production'] += row[3]
|
||||
|
||||
# Zuweisungs-Statistiken
|
||||
cur.execute("""
|
||||
SELECT
|
||||
rp.resource_type,
|
||||
COUNT(DISTINCT l.customer_id) as unique_customers,
|
||||
COUNT(DISTINCT rp.allocated_to_license) as unique_licenses
|
||||
FROM resource_pools rp
|
||||
JOIN licenses l ON rp.allocated_to_license = l.id
|
||||
WHERE rp.status = 'allocated'
|
||||
GROUP BY rp.resource_type
|
||||
""")
|
||||
|
||||
allocation_stats = {}
|
||||
for row in cur.fetchall():
|
||||
allocation_stats[row[0]] = {
|
||||
'unique_customers': row[1],
|
||||
'unique_licenses': row[2]
|
||||
}
|
||||
|
||||
# Historische Daten (letzte 30 Tage)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
DATE(action_timestamp) as date,
|
||||
action,
|
||||
COUNT(*) as count
|
||||
FROM resource_history
|
||||
WHERE action_timestamp >= CURRENT_DATE - INTERVAL '30 days'
|
||||
GROUP BY DATE(action_timestamp), action
|
||||
ORDER BY date, action
|
||||
""")
|
||||
|
||||
historical_data = {}
|
||||
for row in cur.fetchall():
|
||||
date_str = row[0].strftime('%Y-%m-%d')
|
||||
if date_str not in historical_data:
|
||||
historical_data[date_str] = {}
|
||||
historical_data[date_str][row[1]] = row[2]
|
||||
|
||||
# Top-Kunden nach Ressourcennutzung
|
||||
cur.execute("""
|
||||
SELECT
|
||||
c.name,
|
||||
rp.resource_type,
|
||||
COUNT(*) as count
|
||||
FROM resource_pools rp
|
||||
JOIN licenses l ON rp.allocated_to_license = l.id
|
||||
JOIN customers c ON l.customer_id = c.id
|
||||
WHERE rp.status = 'allocated'
|
||||
GROUP BY c.name, rp.resource_type
|
||||
ORDER BY count DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
|
||||
top_customers = []
|
||||
for row in cur.fetchall():
|
||||
top_customers.append({
|
||||
'customer': row[0],
|
||||
'resource_type': row[1],
|
||||
'count': row[2]
|
||||
})
|
||||
|
||||
return render_template('resource_metrics.html',
|
||||
general_stats=general_stats,
|
||||
allocation_stats=allocation_stats,
|
||||
historical_data=historical_data,
|
||||
top_customers=top_customers)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Ressourcen-Metriken: {str(e)}")
|
||||
flash('Fehler beim Laden der Metriken!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@resource_bp.route('/resources/report', methods=['GET'])
|
||||
@login_required
|
||||
def resources_report():
|
||||
"""Generiert einen Ressourcen-Report"""
|
||||
from io import BytesIO
|
||||
import xlsxwriter
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Erstelle Excel-Datei im Speicher
|
||||
output = BytesIO()
|
||||
workbook = xlsxwriter.Workbook(output)
|
||||
|
||||
# Formatierungen
|
||||
header_format = workbook.add_format({
|
||||
'bold': True,
|
||||
'bg_color': '#4CAF50',
|
||||
'font_color': 'white',
|
||||
'border': 1
|
||||
})
|
||||
|
||||
date_format = workbook.add_format({'num_format': 'dd.mm.yyyy hh:mm'})
|
||||
|
||||
# Sheet 1: Übersicht
|
||||
overview_sheet = workbook.add_worksheet('Übersicht')
|
||||
|
||||
# Header
|
||||
headers = ['Ressourcen-Typ', 'Gesamt', 'Verfügbar', 'Zugewiesen', 'Quarantäne', 'Test', 'Produktion']
|
||||
for col, header in enumerate(headers):
|
||||
overview_sheet.write(0, col, header, header_format)
|
||||
|
||||
# Daten
|
||||
cur.execute("""
|
||||
SELECT
|
||||
resource_type,
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN status = 'available' THEN 1 END) as available,
|
||||
COUNT(CASE WHEN status = 'allocated' THEN 1 END) as allocated,
|
||||
COUNT(CASE WHEN status = 'quarantined' THEN 1 END) as quarantined,
|
||||
COUNT(CASE WHEN is_fake = true THEN 1 END) as test,
|
||||
COUNT(CASE WHEN is_fake = false THEN 1 END) as production
|
||||
FROM resource_pools
|
||||
GROUP BY resource_type
|
||||
ORDER BY resource_type
|
||||
""")
|
||||
|
||||
row = 1
|
||||
for data in cur.fetchall():
|
||||
for col, value in enumerate(data):
|
||||
overview_sheet.write(row, col, value)
|
||||
row += 1
|
||||
|
||||
# Sheet 2: Detailliste
|
||||
detail_sheet = workbook.add_worksheet('Detailliste')
|
||||
|
||||
# Header
|
||||
headers = ['Typ', 'Wert', 'Status', 'Test', 'Kunde', 'Lizenz', 'Zugewiesen am', 'Zugewiesen von']
|
||||
for col, header in enumerate(headers):
|
||||
detail_sheet.write(0, col, header, header_format)
|
||||
|
||||
# Daten
|
||||
cur.execute("""
|
||||
SELECT
|
||||
rp.resource_type,
|
||||
rp.resource_value,
|
||||
rp.status,
|
||||
rp.is_fake,
|
||||
c.name as customer_name,
|
||||
l.license_key,
|
||||
rp.status_changed_at,
|
||||
rp.status_changed_by
|
||||
FROM resource_pools rp
|
||||
LEFT JOIN licenses l ON rp.allocated_to_license = l.id
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
ORDER BY rp.resource_type, rp.resource_value
|
||||
""")
|
||||
|
||||
row = 1
|
||||
for data in cur.fetchall():
|
||||
for col, value in enumerate(data):
|
||||
if col == 6 and value: # Datum
|
||||
detail_sheet.write_datetime(row, col, value, date_format)
|
||||
else:
|
||||
detail_sheet.write(row, col, value if value is not None else '')
|
||||
row += 1
|
||||
|
||||
# Spaltenbreiten anpassen
|
||||
overview_sheet.set_column('A:A', 20)
|
||||
overview_sheet.set_column('B:G', 12)
|
||||
|
||||
detail_sheet.set_column('A:A', 15)
|
||||
detail_sheet.set_column('B:B', 30)
|
||||
detail_sheet.set_column('C:D', 12)
|
||||
detail_sheet.set_column('E:F', 25)
|
||||
detail_sheet.set_column('G:H', 20)
|
||||
|
||||
workbook.close()
|
||||
output.seek(0)
|
||||
|
||||
# Sende Datei
|
||||
filename = f"ressourcen_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Generieren des Reports: {str(e)}")
|
||||
flash('Fehler beim Generieren des Reports!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@resource_bp.route('/resources/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_resources():
|
||||
"""Fügt neue Ressourcen zum Pool hinzu"""
|
||||
if request.method == 'POST':
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
resource_type = request.form.get('resource_type')
|
||||
resources_text = request.form.get('resources_text', '')
|
||||
is_fake = request.form.get('is_fake', 'false') == 'true'
|
||||
|
||||
if not resource_type or not resources_text.strip():
|
||||
flash('Bitte Ressourcentyp und Ressourcen angeben!', 'error')
|
||||
return redirect(url_for('resources.add_resources'))
|
||||
|
||||
# Parse resources (one per line)
|
||||
resources = [r.strip() for r in resources_text.strip().split('\n') if r.strip()]
|
||||
|
||||
# Validate resources based on type
|
||||
valid_resources = []
|
||||
invalid_resources = []
|
||||
|
||||
for resource in resources:
|
||||
if resource_type == 'domain':
|
||||
# Basic domain validation
|
||||
import re
|
||||
if re.match(r'^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.[a-zA-Z]{2,}$', resource):
|
||||
valid_resources.append(resource)
|
||||
else:
|
||||
invalid_resources.append(resource)
|
||||
elif resource_type == 'ipv4':
|
||||
# IPv4 validation
|
||||
parts = resource.split('.')
|
||||
if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
|
||||
valid_resources.append(resource)
|
||||
else:
|
||||
invalid_resources.append(resource)
|
||||
elif resource_type == 'phone':
|
||||
# Phone number validation (basic)
|
||||
import re
|
||||
if re.match(r'^\+?[0-9]{7,15}$', resource.replace(' ', '').replace('-', '')):
|
||||
valid_resources.append(resource)
|
||||
else:
|
||||
invalid_resources.append(resource)
|
||||
else:
|
||||
invalid_resources.append(resource)
|
||||
|
||||
# Check for duplicates
|
||||
existing_resources = []
|
||||
if valid_resources:
|
||||
placeholders = ','.join(['%s'] * len(valid_resources))
|
||||
cur.execute(f"""
|
||||
SELECT resource_value
|
||||
FROM resource_pools
|
||||
WHERE resource_type = %s
|
||||
AND resource_value IN ({placeholders})
|
||||
""", [resource_type] + valid_resources)
|
||||
existing_resources = [row[0] for row in cur.fetchall()]
|
||||
|
||||
# Filter out existing resources
|
||||
new_resources = [r for r in valid_resources if r not in existing_resources]
|
||||
|
||||
# Insert new resources
|
||||
added_count = 0
|
||||
for resource in new_resources:
|
||||
cur.execute("""
|
||||
INSERT INTO resource_pools
|
||||
(resource_type, resource_value, status, is_fake, created_by)
|
||||
VALUES (%s, %s, 'available', %s, %s)
|
||||
""", (resource_type, resource, is_fake, session['username']))
|
||||
added_count += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Log audit
|
||||
if added_count > 0:
|
||||
log_audit('BULK_CREATE', 'resource',
|
||||
additional_info=f"Added {added_count} {resource_type} resources")
|
||||
|
||||
# Flash messages
|
||||
if added_count > 0:
|
||||
flash(f'✅ {added_count} neue Ressourcen erfolgreich hinzugefügt!', 'success')
|
||||
if existing_resources:
|
||||
flash(f'⚠️ {len(existing_resources)} Ressourcen existierten bereits und wurden übersprungen.', 'warning')
|
||||
if invalid_resources:
|
||||
flash(f'❌ {len(invalid_resources)} ungültige Ressourcen wurden ignoriert.', 'error')
|
||||
|
||||
return redirect(url_for('resources.resources', show_fake=request.form.get('show_fake', 'false')))
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Hinzufügen von Ressourcen: {str(e)}")
|
||||
flash('Fehler beim Hinzufügen der Ressourcen!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# GET request - show form
|
||||
show_fake = request.args.get('show_fake', 'false') == 'true'
|
||||
return render_template('add_resources.html', show_fake=show_fake)
|
||||
429
v2_adminpanel/routes/session_routes.py
Normale Datei
429
v2_adminpanel/routes/session_routes.py
Normale Datei
@@ -0,0 +1,429 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from utils.network import get_client_ip
|
||||
from db import get_connection, get_db_connection, get_db_cursor
|
||||
from models import get_active_sessions
|
||||
|
||||
# Create Blueprint
|
||||
session_bp = Blueprint('sessions', __name__)
|
||||
|
||||
|
||||
@session_bp.route("/sessions")
|
||||
@login_required
|
||||
def sessions():
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Get is_active sessions with calculated inactive time
|
||||
cur.execute("""
|
||||
SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address,
|
||||
s.user_agent, s.started_at, s.last_heartbeat,
|
||||
EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive
|
||||
FROM sessions s
|
||||
JOIN licenses l ON s.license_id = l.id
|
||||
JOIN customers c ON l.customer_id = c.id
|
||||
WHERE s.is_active = TRUE
|
||||
ORDER BY s.last_heartbeat DESC
|
||||
""")
|
||||
active_sessions = cur.fetchall()
|
||||
|
||||
# Get recent ended sessions
|
||||
cur.execute("""
|
||||
SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address,
|
||||
s.started_at, s.ended_at,
|
||||
EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes
|
||||
FROM sessions s
|
||||
JOIN licenses l ON s.license_id = l.id
|
||||
JOIN customers c ON l.customer_id = c.id
|
||||
WHERE s.is_active = FALSE
|
||||
AND s.ended_at > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY s.ended_at DESC
|
||||
LIMIT 50
|
||||
""")
|
||||
recent_sessions = cur.fetchall()
|
||||
|
||||
return render_template("sessions.html",
|
||||
active_sessions=active_sessions,
|
||||
recent_sessions=recent_sessions)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading sessions: {str(e)}")
|
||||
flash('Fehler beim Laden der Sessions!', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@session_bp.route("/sessions/history")
|
||||
@login_required
|
||||
def session_history():
|
||||
"""Zeigt die Session-Historie"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Query parameters
|
||||
license_key = request.args.get('license_key', '')
|
||||
username = request.args.get('username', '')
|
||||
days = int(request.args.get('days', 7))
|
||||
|
||||
# Base query
|
||||
query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.license_key,
|
||||
s.username,
|
||||
s.hardware_id,
|
||||
s.started_at,
|
||||
s.ended_at,
|
||||
s.last_heartbeat,
|
||||
s.is_active,
|
||||
l.customer_name,
|
||||
l.license_type,
|
||||
l.is_test
|
||||
FROM sessions s
|
||||
LEFT JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = []
|
||||
|
||||
# Apply filters
|
||||
if license_key:
|
||||
query += " AND s.license_key = %s"
|
||||
params.append(license_key)
|
||||
|
||||
if username:
|
||||
query += " AND s.username ILIKE %s"
|
||||
params.append(f'%{username}%')
|
||||
|
||||
# Time filter
|
||||
query += " AND s.started_at >= CURRENT_TIMESTAMP - INTERVAL '%s days'"
|
||||
params.append(days)
|
||||
|
||||
query += " ORDER BY s.started_at DESC LIMIT 1000"
|
||||
|
||||
cur.execute(query, params)
|
||||
|
||||
sessions_list = []
|
||||
for row in cur.fetchall():
|
||||
session_duration = None
|
||||
if row[4] and row[5]: # started_at and ended_at
|
||||
duration = row[5] - row[4]
|
||||
hours = int(duration.total_seconds() // 3600)
|
||||
minutes = int((duration.total_seconds() % 3600) // 60)
|
||||
session_duration = f"{hours}h {minutes}m"
|
||||
elif row[4] and row[7]: # started_at and is_active
|
||||
duration = datetime.now(ZoneInfo("UTC")) - row[4]
|
||||
hours = int(duration.total_seconds() // 3600)
|
||||
minutes = int((duration.total_seconds() % 3600) // 60)
|
||||
session_duration = f"{hours}h {minutes}m (aktiv)"
|
||||
|
||||
sessions_list.append({
|
||||
'id': row[0],
|
||||
'license_key': row[1],
|
||||
'username': row[2],
|
||||
'hardware_id': row[3],
|
||||
'started_at': row[4],
|
||||
'ended_at': row[5],
|
||||
'last_heartbeat': row[6],
|
||||
'is_active': row[7],
|
||||
'customer_name': row[8],
|
||||
'license_type': row[9],
|
||||
'is_test': row[10],
|
||||
'duration': session_duration
|
||||
})
|
||||
|
||||
# Get unique license keys for filter dropdown
|
||||
cur.execute("""
|
||||
SELECT DISTINCT s.license_key, l.customer_name
|
||||
FROM sessions s
|
||||
LEFT JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE s.started_at >= CURRENT_TIMESTAMP - INTERVAL '30 days'
|
||||
ORDER BY l.customer_name, s.license_key
|
||||
""")
|
||||
|
||||
available_licenses = []
|
||||
for row in cur.fetchall():
|
||||
available_licenses.append({
|
||||
'license_key': row[0],
|
||||
'customer_name': row[1] or 'Unbekannt'
|
||||
})
|
||||
|
||||
return render_template("session_history.html",
|
||||
sessions=sessions_list,
|
||||
available_licenses=available_licenses,
|
||||
filters={
|
||||
'license_key': license_key,
|
||||
'username': username,
|
||||
'days': days
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Session-Historie: {str(e)}")
|
||||
flash('Fehler beim Laden der Session-Historie!', 'error')
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@session_bp.route("/session/end/<int:session_id>", methods=["POST"])
|
||||
@login_required
|
||||
def terminate_session(session_id):
|
||||
"""Beendet eine aktive Session"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Get session info
|
||||
cur.execute("""
|
||||
SELECT license_key, username, hardware_id
|
||||
FROM sessions
|
||||
WHERE id = %s AND is_active = true
|
||||
""", (session_id,))
|
||||
|
||||
session_info = cur.fetchone()
|
||||
if not session_info:
|
||||
flash('Session nicht gefunden oder bereits beendet!', 'error')
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
|
||||
# Terminate session
|
||||
cur.execute("""
|
||||
UPDATE sessions
|
||||
SET is_active = false, ended_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""", (session_id,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit log
|
||||
log_audit('SESSION_TERMINATE', 'session', session_id,
|
||||
additional_info=f"Session beendet für {session_info[1]} auf Lizenz {session_info[0]}")
|
||||
|
||||
flash('Session erfolgreich beendet!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Beenden der Session: {str(e)}")
|
||||
flash('Fehler beim Beenden der Session!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
|
||||
|
||||
@session_bp.route("/sessions/terminate-all/<license_key>", methods=["POST"])
|
||||
@login_required
|
||||
def terminate_all_sessions(license_key):
|
||||
"""Beendet alle aktiven Sessions einer Lizenz"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Count is_active sessions
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM sessions
|
||||
WHERE license_key = %s AND is_active = true
|
||||
""", (license_key,))
|
||||
|
||||
active_count = cur.fetchone()[0]
|
||||
|
||||
if active_count == 0:
|
||||
flash('Keine aktiven Sessions gefunden!', 'info')
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
|
||||
# Terminate all sessions
|
||||
cur.execute("""
|
||||
UPDATE sessions
|
||||
SET is_active = false, ended_at = CURRENT_TIMESTAMP
|
||||
WHERE license_key = %s AND is_active = true
|
||||
""", (license_key,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit log
|
||||
log_audit('SESSION_TERMINATE_ALL', 'license', None,
|
||||
additional_info=f"{active_count} Sessions beendet für Lizenz {license_key}")
|
||||
|
||||
flash(f'{active_count} Sessions erfolgreich beendet!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Beenden der Sessions: {str(e)}")
|
||||
flash('Fehler beim Beenden der Sessions!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
|
||||
|
||||
@session_bp.route("/sessions/cleanup", methods=["POST"])
|
||||
@login_required
|
||||
def cleanup_sessions():
|
||||
"""Bereinigt alte inaktive Sessions"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
days = int(request.form.get('days', 30))
|
||||
|
||||
# Delete old inactive sessions
|
||||
cur.execute("""
|
||||
DELETE FROM sessions
|
||||
WHERE is_active = false
|
||||
AND ended_at < CURRENT_TIMESTAMP - INTERVAL '%s days'
|
||||
RETURNING id
|
||||
""", (days,))
|
||||
|
||||
deleted_ids = [row[0] for row in cur.fetchall()]
|
||||
deleted_count = len(deleted_ids)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit log
|
||||
if deleted_count > 0:
|
||||
log_audit('SESSION_CLEANUP', 'system', None,
|
||||
additional_info=f"{deleted_count} Sessions älter als {days} Tage gelöscht")
|
||||
|
||||
flash(f'{deleted_count} alte Sessions bereinigt!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Bereinigen der Sessions: {str(e)}")
|
||||
flash('Fehler beim Bereinigen der Sessions!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('sessions.session_history'))
|
||||
|
||||
|
||||
@session_bp.route("/sessions/statistics")
|
||||
@login_required
|
||||
def session_statistics():
|
||||
"""Zeigt Session-Statistiken"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Aktuelle Statistiken
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COUNT(DISTINCT s.license_key) as active_licenses,
|
||||
COUNT(DISTINCT s.username) as unique_users,
|
||||
COUNT(DISTINCT s.hardware_id) as unique_devices,
|
||||
COUNT(*) as total_active_sessions
|
||||
FROM sessions s
|
||||
WHERE s.is_active = true
|
||||
""")
|
||||
|
||||
current_stats = cur.fetchone()
|
||||
|
||||
# Sessions nach Lizenztyp
|
||||
cur.execute("""
|
||||
SELECT
|
||||
l.license_type,
|
||||
COUNT(*) as session_count
|
||||
FROM sessions s
|
||||
JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE s.is_active = true
|
||||
GROUP BY l.license_type
|
||||
ORDER BY session_count DESC
|
||||
""")
|
||||
|
||||
sessions_by_type = []
|
||||
for row in cur.fetchall():
|
||||
sessions_by_type.append({
|
||||
'license_type': row[0],
|
||||
'count': row[1]
|
||||
})
|
||||
|
||||
# Top 10 Lizenzen nach aktiven Sessions
|
||||
cur.execute("""
|
||||
SELECT
|
||||
s.license_key,
|
||||
l.customer_name,
|
||||
COUNT(*) as session_count,
|
||||
l.device_limit
|
||||
FROM sessions s
|
||||
JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE s.is_active = true
|
||||
GROUP BY s.license_key, l.customer_name, l.device_limit
|
||||
ORDER BY session_count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
|
||||
top_licenses = []
|
||||
for row in cur.fetchall():
|
||||
top_licenses.append({
|
||||
'license_key': row[0],
|
||||
'customer_name': row[1],
|
||||
'session_count': row[2],
|
||||
'device_limit': row[3]
|
||||
})
|
||||
|
||||
# Session-Verlauf (letzte 7 Tage)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
DATE(started_at) as date,
|
||||
COUNT(*) as login_count,
|
||||
COUNT(DISTINCT license_key) as unique_licenses,
|
||||
COUNT(DISTINCT username) as unique_users
|
||||
FROM sessions
|
||||
WHERE started_at >= CURRENT_DATE - INTERVAL '7 days'
|
||||
GROUP BY DATE(started_at)
|
||||
ORDER BY date
|
||||
""")
|
||||
|
||||
session_history = []
|
||||
for row in cur.fetchall():
|
||||
session_history.append({
|
||||
'date': row[0].strftime('%Y-%m-%d'),
|
||||
'login_count': row[1],
|
||||
'unique_licenses': row[2],
|
||||
'unique_users': row[3]
|
||||
})
|
||||
|
||||
# Durchschnittliche Session-Dauer
|
||||
cur.execute("""
|
||||
SELECT
|
||||
AVG(EXTRACT(EPOCH FROM (ended_at - started_at))/3600) as avg_duration_hours
|
||||
FROM sessions
|
||||
WHERE is_active = false
|
||||
AND ended_at IS NOT NULL
|
||||
AND ended_at - started_at < INTERVAL '24 hours'
|
||||
AND started_at >= CURRENT_DATE - INTERVAL '30 days'
|
||||
""")
|
||||
|
||||
avg_duration = cur.fetchone()[0] or 0
|
||||
|
||||
return render_template("session_statistics.html",
|
||||
current_stats={
|
||||
'active_licenses': current_stats[0],
|
||||
'unique_users': current_stats[1],
|
||||
'unique_devices': current_stats[2],
|
||||
'total_sessions': current_stats[3]
|
||||
},
|
||||
sessions_by_type=sessions_by_type,
|
||||
top_licenses=top_licenses,
|
||||
session_history=session_history,
|
||||
avg_duration=round(avg_duration, 1))
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Session-Statistiken: {str(e)}")
|
||||
flash('Fehler beim Laden der Statistiken!', 'error')
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
165
v2_adminpanel/scheduler.py
Normale Datei
165
v2_adminpanel/scheduler.py
Normale Datei
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Scheduler module for handling background tasks
|
||||
"""
|
||||
import logging
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
import config
|
||||
from utils.backup import create_backup
|
||||
from db import get_connection
|
||||
|
||||
|
||||
def scheduled_backup():
|
||||
"""Führt ein geplantes Backup aus"""
|
||||
logging.info("Starte geplantes Backup...")
|
||||
create_backup(backup_type="scheduled", created_by="scheduler")
|
||||
|
||||
|
||||
def cleanup_expired_sessions():
|
||||
"""Clean up expired license sessions"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get client config for timeout value
|
||||
cur.execute("""
|
||||
SELECT session_timeout
|
||||
FROM client_configs
|
||||
WHERE client_name = 'Account Forger'
|
||||
""")
|
||||
result = cur.fetchone()
|
||||
timeout_seconds = result[0] if result else 60
|
||||
|
||||
# Find expired sessions
|
||||
cur.execute("""
|
||||
SELECT id, license_id, hardware_id, ip_address, client_version, started_at
|
||||
FROM license_sessions
|
||||
WHERE last_heartbeat < CURRENT_TIMESTAMP - INTERVAL '%s seconds'
|
||||
""", (timeout_seconds,))
|
||||
|
||||
expired_sessions = cur.fetchall()
|
||||
|
||||
if expired_sessions:
|
||||
logging.info(f"Found {len(expired_sessions)} expired sessions to clean up")
|
||||
|
||||
for session in expired_sessions:
|
||||
# Log to history
|
||||
cur.execute("""
|
||||
INSERT INTO session_history
|
||||
(license_id, hardware_id, ip_address, client_version, started_at, ended_at, end_reason)
|
||||
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP, 'timeout')
|
||||
""", (session[1], session[2], session[3], session[4], session[5]))
|
||||
|
||||
# Delete session
|
||||
cur.execute("DELETE FROM license_sessions WHERE id = %s", (session[0],))
|
||||
|
||||
conn.commit()
|
||||
logging.info(f"Cleaned up {len(expired_sessions)} expired sessions")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error cleaning up sessions: {str(e)}")
|
||||
if 'conn' in locals():
|
||||
conn.rollback()
|
||||
|
||||
|
||||
def deactivate_expired_licenses():
|
||||
"""Deactivate licenses that have expired"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Find active licenses that have expired
|
||||
# Check valid_until < today (at midnight)
|
||||
cur.execute("""
|
||||
SELECT id, license_key, customer_id, valid_until
|
||||
FROM licenses
|
||||
WHERE is_active = true
|
||||
AND valid_until IS NOT NULL
|
||||
AND valid_until < CURRENT_DATE
|
||||
AND is_fake = false
|
||||
""")
|
||||
|
||||
expired_licenses = cur.fetchall()
|
||||
|
||||
if expired_licenses:
|
||||
logging.info(f"Found {len(expired_licenses)} expired licenses to deactivate")
|
||||
|
||||
for license in expired_licenses:
|
||||
license_id, license_key, customer_id, valid_until = license
|
||||
|
||||
# Deactivate the license
|
||||
cur.execute("""
|
||||
UPDATE licenses
|
||||
SET is_active = false,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""", (license_id,))
|
||||
|
||||
# Log to audit trail
|
||||
cur.execute("""
|
||||
INSERT INTO audit_log
|
||||
(timestamp, username, action, entity_type, entity_id,
|
||||
old_values, new_values, additional_info)
|
||||
VALUES (CURRENT_TIMESTAMP, 'system', 'DEACTIVATE', 'license', %s,
|
||||
jsonb_build_object('is_active', true),
|
||||
jsonb_build_object('is_active', false),
|
||||
%s)
|
||||
""", (license_id, f"License automatically deactivated due to expiration. Valid until: {valid_until}"))
|
||||
|
||||
logging.info(f"Deactivated expired license: {license_key} (ID: {license_id})")
|
||||
|
||||
conn.commit()
|
||||
logging.info(f"Successfully deactivated {len(expired_licenses)} expired licenses")
|
||||
else:
|
||||
logging.debug("No expired licenses found to deactivate")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error deactivating expired licenses: {str(e)}")
|
||||
if 'conn' in locals():
|
||||
conn.rollback()
|
||||
|
||||
|
||||
def init_scheduler():
|
||||
"""Initialize and configure the scheduler"""
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
# Configure daily backup job
|
||||
scheduler.add_job(
|
||||
scheduled_backup,
|
||||
'cron',
|
||||
hour=config.SCHEDULER_CONFIG['backup_hour'],
|
||||
minute=config.SCHEDULER_CONFIG['backup_minute'],
|
||||
id='daily_backup',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# Configure session cleanup job - runs every 60 seconds
|
||||
scheduler.add_job(
|
||||
cleanup_expired_sessions,
|
||||
'interval',
|
||||
seconds=60,
|
||||
id='session_cleanup',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# Configure license expiration job - runs daily at midnight
|
||||
scheduler.add_job(
|
||||
deactivate_expired_licenses,
|
||||
'cron',
|
||||
hour=0,
|
||||
minute=0,
|
||||
id='license_expiration_check',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
logging.info(f"Scheduler started. Daily backup scheduled at {config.SCHEDULER_CONFIG['backup_hour']:02d}:{config.SCHEDULER_CONFIG['backup_minute']:02d}")
|
||||
logging.info("Session cleanup job scheduled to run every 60 seconds")
|
||||
logging.info("License expiration check scheduled to run daily at midnight")
|
||||
|
||||
return scheduler
|
||||
20
v2_adminpanel/templates/404.html
Normale Datei
20
v2_adminpanel/templates/404.html
Normale Datei
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Seite nicht gefunden{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-1">404</h1>
|
||||
<h2>Seite nicht gefunden</h2>
|
||||
<p>Die angeforderte Seite konnte nicht gefunden werden.</p>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-primary">Zur Startseite</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
47
v2_adminpanel/templates/500.html
Normale Datei
47
v2_adminpanel/templates/500.html
Normale Datei
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Serverfehler{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-exclamation-octagon text-danger" style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
<h1 class="display-1 text-danger">500</h1>
|
||||
<h2 class="mb-4">Interner Serverfehler</h2>
|
||||
<p class="lead mb-4">
|
||||
Es tut uns leid, aber es ist ein unerwarteter Fehler aufgetreten.
|
||||
Unser Team wurde benachrichtigt und arbeitet an einer Lösung.
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-warning text-start mx-auto" style="max-width: 500px;">
|
||||
<p class="mb-0"><strong>Fehlermeldung:</strong> {{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-house"></i> Zum Dashboard
|
||||
</a>
|
||||
<button onclick="location.reload();" class="btn btn-outline-secondary btn-lg ms-2">
|
||||
<i class="bi bi-arrow-clockwise"></i> Seite neu laden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-muted">
|
||||
<small>
|
||||
Fehler-ID: <code>{{ request_id or 'Nicht verfügbar' }}</code><br>
|
||||
Zeitstempel: {{ timestamp or 'Nicht verfügbar' }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
439
v2_adminpanel/templates/add_resources.html
Normale Datei
439
v2_adminpanel/templates/add_resources.html
Normale Datei
@@ -0,0 +1,439 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Ressourcen hinzufügen{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Card Styling */
|
||||
.main-card {
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Preview Section */
|
||||
.preview-card {
|
||||
background-color: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.preview-card.active {
|
||||
border-color: #28a745;
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
|
||||
/* Format Examples */
|
||||
.example-card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.example-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.example-code {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Resource Type Selector */
|
||||
.resource-type-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.resource-type-option {
|
||||
padding: 1.5rem;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #fff;
|
||||
}
|
||||
.resource-type-option:hover {
|
||||
border-color: #007bff;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.resource-type-option.selected {
|
||||
border-color: #007bff;
|
||||
background-color: #e7f3ff;
|
||||
}
|
||||
.resource-type-option .icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Textarea Styling */
|
||||
.resource-input {
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: #f8f9fa;
|
||||
border: 2px solid #dee2e6;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
.resource-input:focus {
|
||||
background-color: #fff;
|
||||
border-color: #80bdff;
|
||||
}
|
||||
|
||||
/* Stats Display */
|
||||
.stats-display {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">Ressourcen hinzufügen</h1>
|
||||
<p class="text-muted mb-0">Fügen Sie neue Domains, IPs oder Telefonnummern zum Pool hinzu</p>
|
||||
</div>
|
||||
<a href="{{ url_for('resources.resources', show_test=show_test) }}" class="btn btn-secondary">
|
||||
← Zurück zur Übersicht
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('resources.add_resources', show_test=show_test) }}" id="addResourceForm">
|
||||
<!-- Resource Type Selection -->
|
||||
<div class="card main-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">1️⃣ Ressourcentyp wählen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<input type="hidden" name="resource_type" id="resource_type" required>
|
||||
<div class="resource-type-selector">
|
||||
<div class="resource-type-option" data-type="domain">
|
||||
<div class="icon">🌐</div>
|
||||
<h6 class="mb-0">Domain</h6>
|
||||
<small class="text-muted">Webseiten-Adressen</small>
|
||||
</div>
|
||||
<div class="resource-type-option" data-type="ipv4">
|
||||
<div class="icon">🖥️</div>
|
||||
<h6 class="mb-0">IPv4</h6>
|
||||
<small class="text-muted">IP-Adressen</small>
|
||||
</div>
|
||||
<div class="resource-type-option" data-type="phone">
|
||||
<div class="icon">📱</div>
|
||||
<h6 class="mb-0">Telefon</h6>
|
||||
<small class="text-muted">Telefonnummern</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Input -->
|
||||
<div class="card main-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">2️⃣ Ressourcen eingeben</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="resources_text" class="form-label">
|
||||
Ressourcen (eine pro Zeile)
|
||||
</label>
|
||||
<textarea name="resources_text"
|
||||
id="resources_text"
|
||||
class="form-control resource-input"
|
||||
rows="12"
|
||||
required
|
||||
placeholder="Bitte wählen Sie zuerst einen Ressourcentyp aus..."></textarea>
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Geben Sie jede Ressource in eine neue Zeile ein. Duplikate werden automatisch übersprungen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Preview -->
|
||||
<div class="preview-card p-3" id="preview">
|
||||
<h6 class="mb-3">📊 Live-Vorschau</h6>
|
||||
<div class="stats-display">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="validCount">0</div>
|
||||
<div class="stat-label">Gültig</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value text-warning" id="duplicateCount">0</div>
|
||||
<div class="stat-label">Duplikate</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value text-danger" id="invalidCount">0</div>
|
||||
<div class="stat-label">Ungültig</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="errorList" class="mt-3" style="display: none;">
|
||||
<div class="alert alert-danger">
|
||||
<strong>Fehler gefunden:</strong>
|
||||
<ul id="errorMessages" class="mb-0"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Format Examples -->
|
||||
<div class="card main-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">💡 Format-Beispiele</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card example-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<span class="text-primary">🌐</span> Domains
|
||||
</h6>
|
||||
<pre class="example-code">example.com
|
||||
test-domain.net
|
||||
meine-seite.de
|
||||
subdomain.example.org
|
||||
my-website.io</pre>
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<strong>Format:</strong> Ohne http(s)://<br>
|
||||
<strong>Erlaubt:</strong> Buchstaben, Zahlen, Punkt, Bindestrich
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card example-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<span class="text-primary">🖥️</span> IPv4-Adressen
|
||||
</h6>
|
||||
<pre class="example-code">192.168.1.10
|
||||
192.168.1.11
|
||||
10.0.0.1
|
||||
172.16.0.5
|
||||
8.8.8.8</pre>
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<strong>Format:</strong> xxx.xxx.xxx.xxx<br>
|
||||
<strong>Bereich:</strong> 0-255 pro Oktett
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card example-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<span class="text-primary">📱</span> Telefonnummern
|
||||
</h6>
|
||||
<pre class="example-code">+491701234567
|
||||
+493012345678
|
||||
+33123456789
|
||||
+441234567890
|
||||
+12125551234</pre>
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<strong>Format:</strong> Mit Ländervorwahl<br>
|
||||
<strong>Start:</strong> Immer mit +
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Data Option -->
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="is_test" name="is_test" {% if show_test %}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_test">
|
||||
Als Testdaten markieren
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="button" class="btn btn-secondary" onclick="window.location.href='{{ url_for('resources.resources', show_test=show_test) }}'">
|
||||
<i class="fas fa-times"></i> Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success btn-lg" id="submitBtn" disabled>
|
||||
<i class="fas fa-plus-circle"></i> Ressourcen hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const typeOptions = document.querySelectorAll('.resource-type-option');
|
||||
const typeInput = document.getElementById('resource_type');
|
||||
const textArea = document.getElementById('resources_text');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const form = document.getElementById('addResourceForm');
|
||||
|
||||
// Preview elements
|
||||
const validCount = document.getElementById('validCount');
|
||||
const duplicateCount = document.getElementById('duplicateCount');
|
||||
const invalidCount = document.getElementById('invalidCount');
|
||||
const errorList = document.getElementById('errorList');
|
||||
const errorMessages = document.getElementById('errorMessages');
|
||||
const preview = document.getElementById('preview');
|
||||
|
||||
let selectedType = null;
|
||||
|
||||
// Placeholder texts for different types
|
||||
const placeholders = {
|
||||
domain: `example.com
|
||||
test-site.net
|
||||
my-domain.org
|
||||
subdomain.example.com`,
|
||||
ipv4: `192.168.1.1
|
||||
10.0.0.1
|
||||
172.16.0.1
|
||||
8.8.8.8`,
|
||||
phone: `+491234567890
|
||||
+4930123456
|
||||
+33123456789
|
||||
+12125551234`
|
||||
};
|
||||
|
||||
// Resource type selection
|
||||
typeOptions.forEach(option => {
|
||||
option.addEventListener('click', function() {
|
||||
typeOptions.forEach(opt => opt.classList.remove('selected'));
|
||||
this.classList.add('selected');
|
||||
selectedType = this.dataset.type;
|
||||
typeInput.value = selectedType;
|
||||
textArea.placeholder = placeholders[selectedType] || '';
|
||||
textArea.disabled = false;
|
||||
updatePreview();
|
||||
});
|
||||
});
|
||||
|
||||
// Validation functions
|
||||
function validateDomain(domain) {
|
||||
const domainRegex = /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9][a-zA-Z0-9-_]+\.[a-zA-Z]{2,}$/;
|
||||
return domainRegex.test(domain);
|
||||
}
|
||||
|
||||
function validateIPv4(ip) {
|
||||
const ipRegex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
return ipRegex.test(ip);
|
||||
}
|
||||
|
||||
function validatePhone(phone) {
|
||||
const phoneRegex = /^\+[1-9]\d{6,14}$/;
|
||||
return phoneRegex.test(phone);
|
||||
}
|
||||
|
||||
// Update preview function
|
||||
function updatePreview() {
|
||||
if (!selectedType) {
|
||||
preview.classList.remove('active');
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = textArea.value.split('\n').filter(line => line.trim() !== '');
|
||||
const uniqueResources = new Set();
|
||||
const errors = [];
|
||||
let valid = 0;
|
||||
let duplicates = 0;
|
||||
let invalid = 0;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmed = line.trim();
|
||||
if (uniqueResources.has(trimmed)) {
|
||||
duplicates++;
|
||||
return;
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
switch(selectedType) {
|
||||
case 'domain':
|
||||
isValid = validateDomain(trimmed);
|
||||
break;
|
||||
case 'ipv4':
|
||||
isValid = validateIPv4(trimmed);
|
||||
break;
|
||||
case 'phone':
|
||||
isValid = validatePhone(trimmed);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
valid++;
|
||||
uniqueResources.add(trimmed);
|
||||
} else {
|
||||
invalid++;
|
||||
errors.push(`Zeile ${index + 1}: "${trimmed}"`);
|
||||
}
|
||||
});
|
||||
|
||||
// Update counts
|
||||
validCount.textContent = valid;
|
||||
duplicateCount.textContent = duplicates;
|
||||
invalidCount.textContent = invalid;
|
||||
|
||||
// Show/hide error list
|
||||
if (errors.length > 0) {
|
||||
errorList.style.display = 'block';
|
||||
errorMessages.innerHTML = errors.map(err => `<li>${err}</li>`).join('');
|
||||
} else {
|
||||
errorList.style.display = 'none';
|
||||
}
|
||||
|
||||
// Enable/disable submit button
|
||||
submitBtn.disabled = valid === 0 || invalid > 0;
|
||||
|
||||
// Update preview appearance
|
||||
if (lines.length > 0) {
|
||||
preview.classList.add('active');
|
||||
} else {
|
||||
preview.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Live validation
|
||||
textArea.addEventListener('input', updatePreview);
|
||||
|
||||
// Form submission
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (submitBtn.disabled) {
|
||||
e.preventDefault();
|
||||
alert('Bitte beheben Sie alle Fehler bevor Sie fortfahren.');
|
||||
}
|
||||
});
|
||||
|
||||
// Initial state
|
||||
textArea.disabled = true;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
391
v2_adminpanel/templates/audit_log.html
Normale Datei
391
v2_adminpanel/templates/audit_log.html
Normale Datei
@@ -0,0 +1,391 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Log{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('admin.audit_log', sort=field, order='desc' if current_order == 'asc' else 'asc', user=filter_user, action=action_filter, entity=entity_filter, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('admin.audit_log', sort=field, order='asc', user=filter_user, action=action_filter, entity=entity_filter, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.audit-details {
|
||||
font-size: 0.85em;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.json-display {
|
||||
background-color: #f8f9fa;
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.8em;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* CRUD-Operationen */
|
||||
.action-CREATE { color: #28a745; }
|
||||
.action-UPDATE { color: #007bff; }
|
||||
.action-DELETE { color: #dc3545; }
|
||||
.action-IMPORT { color: #6f42c1; }
|
||||
|
||||
/* Authentifizierung */
|
||||
.action-LOGIN { color: #17a2b8; }
|
||||
.action-LOGIN_SUCCESS { color: #28a745; }
|
||||
.action-LOGIN_FAILED { color: #dc3545; }
|
||||
.action-LOGIN_BLOCKED { color: #b91c1c; }
|
||||
.action-LOGOUT { color: #6c757d; }
|
||||
.action-AUTO_LOGOUT { color: #fd7e14; }
|
||||
|
||||
/* 2FA */
|
||||
.action-LOGIN_2FA_SUCCESS { color: #00a86b; }
|
||||
.action-LOGIN_2FA_BACKUP { color: #059862; }
|
||||
.action-LOGIN_2FA_FAILED { color: #e53e3e; }
|
||||
.action-2FA_ENABLED { color: #38a169; }
|
||||
.action-2FA_DISABLED { color: #e53e3e; }
|
||||
|
||||
/* Lizenzverwaltung */
|
||||
.action-GENERATE_KEY { color: #20c997; }
|
||||
.action-CREATE_BATCH { color: #6610f2; }
|
||||
.action-BATCH_UPDATE { color: #17a2b8; }
|
||||
.action-TOGGLE { color: #ffc107; }
|
||||
.action-QUICK_EDIT { color: #00bcd4; }
|
||||
.action-BULK_ACTIVATE { color: #4caf50; }
|
||||
.action-BULK_DEACTIVATE { color: #ff5722; }
|
||||
.action-BULK_DELETE { color: #f44336; }
|
||||
|
||||
/* Geräteverwaltung */
|
||||
.action-DEVICE_REGISTER { color: #2196f3; }
|
||||
.action-DEVICE_DEACTIVATE { color: #ff9800; }
|
||||
|
||||
/* Ressourcenverwaltung */
|
||||
.action-RESOURCE_ALLOCATE { color: #009688; }
|
||||
.action-QUARANTINE { color: #ff6b6b; }
|
||||
.action-RELEASE { color: #51cf66; }
|
||||
.action-BULK_CREATE { color: #845ef7; }
|
||||
|
||||
/* Session-Verwaltung */
|
||||
.action-SESSION_TERMINATE { color: #e91e63; }
|
||||
.action-SESSION_TERMINATE_ALL { color: #c2255c; }
|
||||
.action-SESSION_CLEANUP { color: #868e96; }
|
||||
|
||||
/* System-Operationen */
|
||||
.action-BACKUP { color: #5a67d8; }
|
||||
.action-BACKUP_DOWNLOAD { color: #4299e1; }
|
||||
.action-BACKUP_DELETE { color: #e53e3e; }
|
||||
.action-RESTORE { color: #4299e1; }
|
||||
.action-EXPORT { color: #ffc107; }
|
||||
.action-PASSWORD_CHANGE { color: #805ad5; }
|
||||
.action-UNBLOCK_IP { color: #22b8cf; }
|
||||
.action-CLEAR_LOGIN_ATTEMPTS { color: #37b24d; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="mb-4">
|
||||
<h2>📝 Log</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('admin.audit_log') }}" id="auditFilterForm">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label for="user" class="form-label">Benutzer</label>
|
||||
<select class="form-select" id="user" name="user">
|
||||
<option value="">Alle Benutzer</option>
|
||||
<option value="rac00n" {% if filter_user == 'rac00n' %}selected{% endif %}>rac00n</option>
|
||||
<option value="w@rh@mm3r" {% if filter_user == 'w@rh@mm3r' %}selected{% endif %}>w@rh@mm3r</option>
|
||||
<option value="system" {% if filter_user == 'system' %}selected{% endif %}>System</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="action" class="form-label">Aktion</label>
|
||||
<select class="form-select" id="action" name="action">
|
||||
<option value="">Alle Aktionen</option>
|
||||
{% for action in actions %}
|
||||
<option value="{{ action }}" {% if action_filter == action %}selected{% endif %}>
|
||||
{% if action == 'CREATE' %}➕ Erstellt
|
||||
{% elif action == 'UPDATE' %}✏️ Bearbeitet
|
||||
{% elif action == 'DELETE' %}🗑️ Gelöscht
|
||||
{% elif action == 'IMPORT' %}📤 Import
|
||||
{% elif action == 'LOGIN' %}🔑 Anmeldung
|
||||
{% elif action == 'LOGIN_SUCCESS' %}✅ Anmeldung erfolgreich
|
||||
{% elif action == 'LOGIN_FAILED' %}❌ Anmeldung fehlgeschlagen
|
||||
{% elif action == 'LOGIN_BLOCKED' %}🚫 Login blockiert
|
||||
{% elif action == 'LOGOUT' %}🚪 Abmeldung
|
||||
{% elif action == 'AUTO_LOGOUT' %}⏰ Auto-Logout
|
||||
{% elif action == 'LOGIN_2FA_SUCCESS' %}🔐 2FA-Anmeldung
|
||||
{% elif action == 'LOGIN_2FA_FAILED' %}⛔ 2FA fehlgeschlagen
|
||||
{% elif action == 'LOGIN_2FA_BACKUP' %}🔒 2FA-Backup-Code
|
||||
{% elif action == '2FA_ENABLED' %}✅ 2FA aktiviert
|
||||
{% elif action == '2FA_DISABLED' %}❌ 2FA deaktiviert
|
||||
{% elif action == 'GENERATE_KEY' %}🔑 Key generiert
|
||||
{% elif action == 'CREATE_BATCH' %}📦 Batch erstellt
|
||||
{% elif action == 'BATCH_UPDATE' %}🔄 Batch Update
|
||||
{% elif action == 'TOGGLE' %}🔄 Status geändert
|
||||
{% elif action == 'QUICK_EDIT' %}⚡ Schnellbearbeitung
|
||||
{% elif action == 'BULK_ACTIVATE' %}✅ Bulk-Aktivierung
|
||||
{% elif action == 'BULK_DEACTIVATE' %}❌ Bulk-Deaktivierung
|
||||
{% elif action == 'BULK_DELETE' %}🗑️ Bulk-Löschung
|
||||
{% elif action == 'DEVICE_REGISTER' %}📱 Gerät registriert
|
||||
{% elif action == 'DEVICE_DEACTIVATE' %}📵 Gerät deaktiviert
|
||||
{% elif action == 'RESOURCE_ALLOCATE' %}📎 Ressource zugewiesen
|
||||
{% elif action == 'QUARANTINE' %}⚠️ In Quarantäne
|
||||
{% elif action == 'RELEASE' %}✅ Freigegeben
|
||||
{% elif action == 'BULK_CREATE' %}➕ Bulk-Erstellung
|
||||
{% elif action == 'SESSION_TERMINATE' %}🛑 Session beendet
|
||||
{% elif action == 'SESSION_TERMINATE_ALL' %}🛑 Alle Sessions beendet
|
||||
{% elif action == 'SESSION_CLEANUP' %}🧹 Session-Bereinigung
|
||||
{% elif action == 'BACKUP' %}💾 Backup
|
||||
{% elif action == 'BACKUP_DOWNLOAD' %}⬇️ Backup Download
|
||||
{% elif action == 'BACKUP_DELETE' %}🗑️ Backup gelöscht
|
||||
{% elif action == 'RESTORE' %}🔄 Wiederhergestellt
|
||||
{% elif action == 'EXPORT' %}📥 Export
|
||||
{% elif action == 'PASSWORD_CHANGE' %}🔐 Passwort geändert
|
||||
{% elif action == 'UNBLOCK_IP' %}🔓 IP entsperrt
|
||||
{% elif action == 'CLEAR_LOGIN_ATTEMPTS' %}🧹 Login-Versuche gelöscht
|
||||
{% else %}{{ action }}
|
||||
{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="entity" class="form-label">Entität</label>
|
||||
<select class="form-select" id="entity" name="entity">
|
||||
<option value="">Alle Entitäten</option>
|
||||
{% for entity in entities %}
|
||||
<option value="{{ entity }}" {% if entity_filter == entity %}selected{% endif %}>
|
||||
{% if entity == 'license' %}Lizenz
|
||||
{% elif entity == 'customer' %}Kunde
|
||||
{% elif entity == 'user' %}Benutzer
|
||||
{% elif entity == 'session' %}Session
|
||||
{% elif entity == 'resource' %}Ressource
|
||||
{% elif entity == 'backup' %}Backup
|
||||
{% elif entity == 'database' %}Datenbank
|
||||
{% elif entity == 'system' %}System
|
||||
{% else %}{{ entity|title }}
|
||||
{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('admin.audit_log') }}" class="btn btn-outline-secondary">Zurücksetzen</a>
|
||||
<!-- Export Buttons -->
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('export.export_audit', format='excel', user=filter_user, action=action_filter, entity=entity_filter) }}" class="btn btn-success btn-sm">
|
||||
<i class="bi bi-file-earmark-excel"></i> Excel
|
||||
</a>
|
||||
<a href="{{ url_for('export.export_audit', format='csv', user=filter_user, action=action_filter, entity=entity_filter) }}" class="btn btn-secondary btn-sm">
|
||||
<i class="bi bi-file-earmark-text"></i> CSV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Log Tabelle -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitstempel</th>
|
||||
<th>Benutzer</th>
|
||||
<th>Aktion</th>
|
||||
<th>Entität</th>
|
||||
<th>Details</th>
|
||||
<th>IP-Adresse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.timestamp.strftime('%d.%m.%Y %H:%M:%S') }}</td>
|
||||
<td><strong>{{ log.username }}</strong></td>
|
||||
<td>
|
||||
<span class="action-{{ log.action }}">
|
||||
{% if log.action == 'CREATE' %}➕ Erstellt
|
||||
{% elif log.action == 'UPDATE' %}✏️ Bearbeitet
|
||||
{% elif log.action == 'DELETE' %}🗑️ Gelöscht
|
||||
{% elif log.action == 'LOGIN' %}🔑 Anmeldung
|
||||
{% elif log.action == 'LOGOUT' %}🚪 Abmeldung
|
||||
{% elif log.action == 'AUTO_LOGOUT' %}⏰ Auto-Logout
|
||||
{% elif log.action == 'EXPORT' %}📥 Export
|
||||
{% elif log.action == 'GENERATE_KEY' %}🔑 Key generiert
|
||||
{% elif log.action == 'CREATE_BATCH' %}🔑 Batch erstellt
|
||||
{% elif log.action == 'BACKUP' %}💾 Backup erstellt
|
||||
{% elif log.action == 'LOGIN_2FA_SUCCESS' %}🔐 2FA-Anmeldung
|
||||
{% elif log.action == 'LOGIN_2FA_BACKUP' %}🔒 2FA-Backup-Code
|
||||
{% elif log.action == 'LOGIN_2FA_FAILED' %}⛔ 2FA-Fehlgeschlagen
|
||||
{% elif log.action == 'LOGIN_BLOCKED' %}🚫 Login-Blockiert
|
||||
{% elif log.action == 'RESTORE' %}🔄 Wiederhergestellt
|
||||
{% elif log.action == 'PASSWORD_CHANGE' %}🔐 Passwort geändert
|
||||
{% elif log.action == '2FA_ENABLED' %}✅ 2FA aktiviert
|
||||
{% elif log.action == '2FA_DISABLED' %}❌ 2FA deaktiviert
|
||||
{% else %}{{ log.action }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ log.entity_type }}
|
||||
{% if log.entity_id %}
|
||||
<small class="text-muted">#{{ log.entity_id }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="audit-details">
|
||||
{% if log.additional_info %}
|
||||
<div class="mb-1"><small class="text-muted">{{ log.additional_info }}</small></div>
|
||||
{% endif %}
|
||||
|
||||
{% if log.old_values and log.action == 'DELETE' %}
|
||||
<details>
|
||||
<summary>Gelöschte Werte</summary>
|
||||
<div class="json-display">
|
||||
{% for key, value in log.old_values.items() %}
|
||||
<strong>{{ key }}:</strong> {{ value }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elif log.old_values and log.new_values and log.action == 'UPDATE' %}
|
||||
<details>
|
||||
<summary>Änderungen anzeigen</summary>
|
||||
<div class="json-display">
|
||||
<strong>Vorher:</strong><br>
|
||||
{% for key, value in log.old_values.items() %}
|
||||
{% if log.new_values[key] != value %}
|
||||
{{ key }}: {{ value }}<br>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<hr class="my-1">
|
||||
<strong>Nachher:</strong><br>
|
||||
{% for key, value in log.new_values.items() %}
|
||||
{% if log.old_values[key] != value %}
|
||||
{{ key }}: {{ value }}<br>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elif log.new_values and log.action == 'CREATE' %}
|
||||
<details>
|
||||
<summary>Erstellte Werte</summary>
|
||||
<div class="json-display">
|
||||
{% for key, value in log.new_values.items() %}
|
||||
<strong>{{ key }}:</strong> {{ value }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{ log.ip_address or '-' }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not logs %}
|
||||
<div class="text-center py-5">
|
||||
<p class="text-muted">Keine Audit-Log-Einträge gefunden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav aria-label="Seitennavigation" class="mt-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
<!-- Erste Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_log', page=1, user=filter_user, action=action_filter, entity=entity_filter, sort=sort, order=order) }}">Erste</a>
|
||||
</li>
|
||||
|
||||
<!-- Vorherige Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_log', page=page-1, user=filter_user, action=action_filter, entity=entity_filter, sort=sort, order=order) }}">←</a>
|
||||
</li>
|
||||
|
||||
<!-- Seitenzahlen -->
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p >= page - 2 and p <= page + 2 %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_log', page=p, user=filter_user, action=action_filter, entity=entity_filter, sort=sort, order=order) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Nächste Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_log', page=page+1, user=filter_user, action=action_filter, entity=entity_filter, sort=sort, order=order) }}">→</a>
|
||||
</li>
|
||||
|
||||
<!-- Letzte Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_log', page=total_pages, user=filter_user, action=action_filter, entity=entity_filter, sort=sort, order=order) }}">Letzte</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted">
|
||||
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Einträge
|
||||
</p>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Live Filtering für Audit Log
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const filterForm = document.getElementById('auditFilterForm');
|
||||
const userInput = document.getElementById('user');
|
||||
const actionSelect = document.getElementById('action');
|
||||
const entitySelect = document.getElementById('entity');
|
||||
|
||||
// Debounce timer für Textfelder
|
||||
let searchTimeout;
|
||||
|
||||
// Live-Filter für Benutzer-Dropdown (sofort)
|
||||
userInput.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
|
||||
// Live-Filter für Dropdowns (sofort)
|
||||
actionSelect.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
|
||||
entitySelect.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
228
v2_adminpanel/templates/backup_codes.html
Normale Datei
228
v2_adminpanel/templates/backup_codes.html
Normale Datei
@@ -0,0 +1,228 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Backup-Codes{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.success-icon {
|
||||
font-size: 5rem;
|
||||
animation: bounce 0.5s ease-in-out;
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
.backup-code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
padding: 8px 15px;
|
||||
background-color: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
}
|
||||
.backup-codes-container {
|
||||
background-color: #fff;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.action-buttons .btn {
|
||||
margin: 5px;
|
||||
}
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
.backup-codes-container {
|
||||
border: 1px solid #000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<!-- Success Message -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="success-icon text-success">✅</div>
|
||||
<h1 class="mt-3">2FA erfolgreich aktiviert!</h1>
|
||||
<p class="lead text-muted">Ihre Zwei-Faktor-Authentifizierung ist jetzt aktiv.</p>
|
||||
</div>
|
||||
|
||||
<!-- Backup Codes Card -->
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h4 class="mb-0">
|
||||
<span style="font-size: 1.5rem; vertical-align: middle;">⚠️</span>
|
||||
Wichtig: Ihre Backup-Codes
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-4">
|
||||
<strong>Was sind Backup-Codes?</strong><br>
|
||||
Diese Codes ermöglichen Ihnen den Zugang zu Ihrem Account, falls Sie keinen Zugriff auf Ihre Authenticator-App haben.
|
||||
<strong>Jeder Code kann nur einmal verwendet werden.</strong>
|
||||
</div>
|
||||
|
||||
<!-- Backup Codes Display -->
|
||||
<div class="backup-codes-container text-center">
|
||||
<h5 class="mb-4">Ihre 8 Backup-Codes:</h5>
|
||||
<div class="row justify-content-center">
|
||||
{% for code in backup_codes %}
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="backup-code">{{ code }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="text-center action-buttons no-print">
|
||||
<button type="button" class="btn btn-primary btn-lg" onclick="downloadCodes()">
|
||||
💾 Als Datei speichern
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-lg" onclick="printCodes()">
|
||||
🖨️ Drucken
|
||||
</button>
|
||||
<button type="button" class="btn btn-info btn-lg" onclick="copyCodes()">
|
||||
📋 Alle kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Security Tips -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-danger">
|
||||
<h6>❌ Nicht empfohlen:</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li>Im selben Passwort-Manager wie Ihr Passwort</li>
|
||||
<li>Als Foto auf Ihrem Handy</li>
|
||||
<li>In einer unverschlüsselten Datei</li>
|
||||
<li>Per E-Mail an sich selbst</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-success">
|
||||
<h6>✅ Empfohlen:</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li>Ausgedruckt in einem Safe</li>
|
||||
<li>In einem separaten Passwort-Manager</li>
|
||||
<li>Verschlüsselt auf einem USB-Stick</li>
|
||||
<li>An einem sicheren Ort zu Hause</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation -->
|
||||
<div class="text-center mt-4 no-print">
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="confirmSaved" onchange="checkConfirmation()">
|
||||
<label class="form-check-label" for="confirmSaved">
|
||||
Ich habe die Backup-Codes sicher gespeichert
|
||||
</label>
|
||||
</div>
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-lg btn-success" id="continueBtn" style="display: none;">
|
||||
✅ Weiter zum Profil
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const backupCodes = {{ backup_codes | tojson }};
|
||||
|
||||
function checkConfirmation() {
|
||||
const checkbox = document.getElementById('confirmSaved');
|
||||
const continueBtn = document.getElementById('continueBtn');
|
||||
continueBtn.style.display = checkbox.checked ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
function downloadCodes() {
|
||||
const content = `V2 Admin Panel - Backup Codes
|
||||
=====================================
|
||||
Generiert am: ${new Date().toLocaleString('de-DE')}
|
||||
|
||||
WICHTIG: Bewahren Sie diese Codes sicher auf!
|
||||
Jeder Code kann nur einmal verwendet werden.
|
||||
|
||||
Ihre 8 Backup-Codes:
|
||||
--------------------
|
||||
${backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}
|
||||
|
||||
Sicherheitshinweise:
|
||||
-------------------
|
||||
✓ Bewahren Sie diese Codes getrennt von Ihrem Passwort auf
|
||||
✓ Speichern Sie sie an einem sicheren physischen Ort
|
||||
✓ Teilen Sie diese Codes niemals mit anderen
|
||||
✓ Jeder Code funktioniert nur einmal
|
||||
|
||||
Bei Verlust Ihrer Authenticator-App:
|
||||
------------------------------------
|
||||
1. Gehen Sie zur Login-Seite
|
||||
2. Geben Sie Benutzername und Passwort ein
|
||||
3. Klicken Sie auf "Backup-Code verwenden"
|
||||
4. Geben Sie einen dieser Codes ein
|
||||
|
||||
Nach Verwendung eines Codes sollten Sie neue Backup-Codes generieren.`;
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `v2-backup-codes-${new Date().toISOString().split('T')[0]}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
// Visual feedback
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '✅ Heruntergeladen!';
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-success');
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-primary');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function printCodes() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
function copyCodes() {
|
||||
const codesText = backupCodes.join('\n');
|
||||
navigator.clipboard.writeText(codesText).then(function() {
|
||||
// Visual feedback
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '✅ Kopiert!';
|
||||
btn.classList.remove('btn-info');
|
||||
btn.classList.add('btn-success');
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-info');
|
||||
}, 2000);
|
||||
}).catch(function(err) {
|
||||
alert('Fehler beim Kopieren. Bitte manuell kopieren.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
301
v2_adminpanel/templates/backups.html
Normale Datei
301
v2_adminpanel/templates/backups.html
Normale Datei
@@ -0,0 +1,301 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Backup-Verwaltung{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.status-success { color: #28a745; }
|
||||
.status-failed { color: #dc3545; }
|
||||
.status-in_progress { color: #ffc107; }
|
||||
.backup-actions { white-space: nowrap; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>💾 Backup-Verwaltung</h2>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup-Info -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">📅 Letztes erfolgreiches Backup</h5>
|
||||
{% if last_backup %}
|
||||
<p class="mb-1"><strong>Zeitpunkt:</strong> {{ last_backup.id.strftime('%d.%m.%Y %H:%M:%S') }}</p>
|
||||
<p class="mb-1"><strong>Größe:</strong> {{ (last_backup.filename / 1024 / 1024)|round(2) }} MB</p>
|
||||
<p class="mb-0"><strong>Dauer:</strong> {{ last_backup.filesize|round(1) }} Sekunden</p>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">🔧 Backup-Aktionen</h5>
|
||||
<button id="createBackupBtn" class="btn btn-primary" onclick="createBackup()">
|
||||
💾 Backup jetzt erstellen
|
||||
</button>
|
||||
<p class="text-muted mt-2 mb-0">
|
||||
<small>Automatische Backups: Täglich um 03:00 Uhr</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup-Historie -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📋 Backup-Historie</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-type="date">Zeitstempel</th>
|
||||
<th class="sortable">Dateiname</th>
|
||||
<th class="sortable" data-type="numeric">Größe</th>
|
||||
<th class="sortable">Typ</th>
|
||||
<th class="sortable">Status</th>
|
||||
<th class="sortable">Erstellt von</th>
|
||||
<th>Details</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td>{{ backup.created_at.strftime('%d.%m.%Y %H:%M:%S') }}</td>
|
||||
<td>
|
||||
<small>{{ backup.filename }}</small>
|
||||
{% if backup.is_encrypted %}
|
||||
<span class="badge bg-info ms-1">🔒 Verschlüsselt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if backup.filesize %}
|
||||
{{ (backup.filesize / 1024 / 1024)|round(2) }} MB
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if backup.backup_type == 'manual' %}
|
||||
<span class="badge bg-primary">Manuell</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Automatisch</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if backup.status == 'success' %}
|
||||
<span class="status-success">✅ Erfolgreich</span>
|
||||
{% elif backup.status == 'failed' %}
|
||||
<span class="status-failed" title="{{ backup.error_message }}">❌ Fehlgeschlagen</span>
|
||||
{% else %}
|
||||
<span class="status-in_progress">⏳ In Bearbeitung</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ backup.created_by }}</td>
|
||||
<td>
|
||||
{% if backup.tables_count and backup.records_count %}
|
||||
<small>
|
||||
{{ backup.tables_count }} Tabellen<br>
|
||||
{{ backup.records_count }} Datensätze<br>
|
||||
{% if backup.duration_seconds %}
|
||||
{{ backup.duration_seconds|round(1) }}s
|
||||
{% endif %}
|
||||
</small>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="backup-actions">
|
||||
{% if backup.status == 'success' %}
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('admin.download_backup', backup_id=backup.id) }}"
|
||||
class="btn btn-outline-primary"
|
||||
title="Backup herunterladen">
|
||||
📥 Download
|
||||
</a>
|
||||
<button class="btn btn-outline-success"
|
||||
onclick="restoreBackup({{ backup.id }}, '{{ backup.filename }}')"
|
||||
title="Backup wiederherstellen">
|
||||
🔄 Wiederherstellen
|
||||
</button>
|
||||
<button class="btn btn-outline-danger"
|
||||
onclick="deleteBackup({{ backup.id }}, '{{ backup.filename }}')"
|
||||
title="Backup löschen">
|
||||
🗑️ Löschen
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not backups %}
|
||||
<div class="text-center py-5">
|
||||
<p class="text-muted">Noch keine Backups vorhanden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wiederherstellungs-Modal -->
|
||||
<div class="modal fade" id="restoreModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">🔄 Backup wiederherstellen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<strong>⚠️ Warnung:</strong> Bei der Wiederherstellung werden alle aktuellen Daten überschrieben!
|
||||
</div>
|
||||
<p>Backup: <strong id="restoreFilename"></strong></p>
|
||||
<form id="restoreForm">
|
||||
<input type="hidden" id="restoreBackupId">
|
||||
<div class="mb-3">
|
||||
<label for="encryptionKey" class="form-label">Verschlüsselungs-Passwort (optional)</label>
|
||||
<input type="password" class="form-control" id="encryptionKey"
|
||||
placeholder="Leer lassen für Standard-Passwort">
|
||||
<small class="text-muted">
|
||||
Falls das Backup mit einem anderen Passwort verschlüsselt wurde
|
||||
</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-danger" onclick="confirmRestore()">
|
||||
⚠️ Wiederherstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function createBackup() {
|
||||
const btn = document.getElementById('createBackupBtn');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '⏳ Backup wird erstellt...';
|
||||
|
||||
fetch('{{ url_for('admin.create_backup_route') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ ' + data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('❌ ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('❌ Fehler beim Erstellen des Backups: ' + error);
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
function restoreBackup(backupId, filename) {
|
||||
document.getElementById('restoreBackupId').value = backupId;
|
||||
document.getElementById('restoreFilename').textContent = filename;
|
||||
document.getElementById('encryptionKey').value = '';
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('restoreModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function confirmRestore() {
|
||||
if (!confirm('Wirklich wiederherstellen? Alle aktuellen Daten werden überschrieben!')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backupId = document.getElementById('restoreBackupId').value;
|
||||
const encryptionKey = document.getElementById('encryptionKey').value;
|
||||
|
||||
const formData = new FormData();
|
||||
if (encryptionKey) {
|
||||
formData.append('encryption_key', encryptionKey);
|
||||
}
|
||||
|
||||
// Modal schließen
|
||||
bootstrap.Modal.getInstance(document.getElementById('restoreModal')).hide();
|
||||
|
||||
// Loading anzeigen
|
||||
const loadingDiv = document.createElement('div');
|
||||
loadingDiv.className = 'position-fixed top-50 start-50 translate-middle';
|
||||
loadingDiv.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
|
||||
document.body.appendChild(loadingDiv);
|
||||
|
||||
fetch(`/backup/restore/${backupId}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ ' + data.message + '\n\nDie Seite wird neu geladen...');
|
||||
window.location.href = '{{ url_for('admin.dashboard') }}';
|
||||
} else {
|
||||
alert('❌ ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('❌ Fehler bei der Wiederherstellung: ' + error);
|
||||
})
|
||||
.finally(() => {
|
||||
document.body.removeChild(loadingDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteBackup(backupId, filename) {
|
||||
if (!confirm(`Soll das Backup "${filename}" wirklich gelöscht werden?\n\nDieser Vorgang kann nicht rückgängig gemacht werden!`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/backup/delete/${backupId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ ' + data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('❌ ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('❌ Fehler beim Löschen des Backups: ' + error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
705
v2_adminpanel/templates/base.html
Normale Datei
705
v2_adminpanel/templates/base.html
Normale Datei
@@ -0,0 +1,705 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin Panel{% endblock %} - Lizenzverwaltung</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||
{% block extra_css %}{% endblock %}
|
||||
<style>
|
||||
/* Global Status Colors */
|
||||
:root {
|
||||
--status-active: #28a745;
|
||||
--status-warning: #ffc107;
|
||||
--status-danger: #dc3545;
|
||||
--status-inactive: #6c757d;
|
||||
--status-info: #17a2b8;
|
||||
--sidebar-width: 250px;
|
||||
--sidebar-collapsed: 60px;
|
||||
}
|
||||
|
||||
/* Status Classes - Global */
|
||||
.status-aktiv { color: var(--status-active) !important; }
|
||||
.status-ablaufend { color: var(--status-warning) !important; }
|
||||
.status-abgelaufen { color: var(--status-danger) !important; }
|
||||
.status-deaktiviert { color: var(--status-inactive) !important; }
|
||||
|
||||
/* Badge Variants */
|
||||
.badge-aktiv { background-color: var(--status-active) !important; }
|
||||
.badge-ablaufend { background-color: var(--status-warning) !important; color: #000 !important; }
|
||||
.badge-abgelaufen { background-color: var(--status-danger) !important; }
|
||||
.badge-deaktiviert { background-color: var(--status-inactive) !important; }
|
||||
|
||||
/* Session Timer Styles */
|
||||
#session-timer {
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.timer-normal {
|
||||
background-color: var(--status-active);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timer-warning {
|
||||
background-color: var(--status-warning);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.timer-danger {
|
||||
background-color: var(--status-danger);
|
||||
color: white;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.timer-critical {
|
||||
background-color: var(--status-danger);
|
||||
color: white;
|
||||
animation: blink 0.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.session-warning-modal {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Table Improvements */
|
||||
.table-container {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-sticky thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #fff;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table-sticky thead th {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
/* Inline Actions */
|
||||
.btn-copy {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-copy:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.btn-copy.copied {
|
||||
background-color: var(--status-active);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.form-switch-custom {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.form-switch-custom .form-check-input {
|
||||
cursor: pointer;
|
||||
width: 3em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
.form-switch-custom .form-check-input:checked {
|
||||
background-color: var(--status-active);
|
||||
border-color: var(--status-active);
|
||||
}
|
||||
|
||||
/* Bulk Actions Bar */
|
||||
.bulk-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: #212529;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bulk-actions.show {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Checkbox Styling */
|
||||
.checkbox-cell {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.form-check-input-custom {
|
||||
cursor: pointer;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
}
|
||||
|
||||
/* Sortable Table Styles */
|
||||
.sortable-table th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
padding-right: 25px;
|
||||
}
|
||||
|
||||
.sortable-table th.sortable:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sortable-table th.sortable::after {
|
||||
content: '↕';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.sortable-table th.sortable.asc::after {
|
||||
content: '↑';
|
||||
opacity: 1;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
.sortable-table th.sortable.desc::after {
|
||||
content: '↓';
|
||||
opacity: 1;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
/* Server-side sortable styles */
|
||||
.server-sortable {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.server-sortable:hover {
|
||||
color: var(--bs-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
margin-left: 5px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.sort-indicator.active {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* Sidebar Navigation */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
height: calc(100vh - 56px);
|
||||
width: var(--sidebar-width);
|
||||
background-color: #f8f9fa;
|
||||
border-right: 1px solid #dee2e6;
|
||||
overflow-y: auto;
|
||||
transition: all 0.3s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: var(--sidebar-collapsed);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem;
|
||||
background-color: #e9ecef;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-item {
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link:hover {
|
||||
background-color: #e9ecef;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link.active {
|
||||
background-color: var(--bs-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link i {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-nav .nav-link span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-submenu {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: #f1f3f4;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar-submenu .nav-link {
|
||||
padding-left: 2.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Arrow indicator for items with submenus */
|
||||
.nav-link.has-submenu::after {
|
||||
content: '▾';
|
||||
float: right;
|
||||
opacity: 0.5;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Main Content with Sidebar */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
transition: all 0.3s;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.main-content.expanded {
|
||||
margin-left: var(--sidebar-collapsed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<nav class="navbar navbar-dark bg-dark navbar-expand-lg sticky-top">
|
||||
<div class="container-fluid">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="navbar-brand text-decoration-none">🎛️ AccountForger - Admin Panel</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<!-- Navigation removed - access via sidebar -->
|
||||
</ul>
|
||||
<div class="d-flex align-items-center">
|
||||
<div id="session-timer" class="timer-normal me-3">
|
||||
⏱️ <span id="timer-display">5:00</span>
|
||||
</div>
|
||||
<span class="text-white me-3">Angemeldet als: {{ username }}</span>
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-light btn-sm me-2">👤 Profil</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-light btn-sm">Abmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<ul class="sidebar-nav">
|
||||
<li class="nav-item {% if request.endpoint in ['customers.customers', 'customers.customers_licenses', 'customers.edit_customer', 'customers.create_customer', 'licenses.edit_license', 'licenses.create_license', 'batch.batch_create'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link has-submenu {% if request.endpoint == 'customers.customers_licenses' %}active{% endif %}" href="{{ url_for('customers.customers_licenses') }}">
|
||||
<i class="bi bi-people"></i>
|
||||
<span>Kunden & Lizenzen</span>
|
||||
</a>
|
||||
<ul class="sidebar-submenu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'customers.customers' %}active{% endif %}" href="{{ url_for('customers.customers') }}">
|
||||
<i class="bi bi-list"></i>
|
||||
<span>Alle Kunden</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'licenses.licenses' %}active{% endif %}" href="{{ url_for('licenses.licenses') }}">
|
||||
<i class="bi bi-card-list"></i>
|
||||
<span>Alle Lizenzen</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'customers.create_customer' %}active{% endif %}" href="{{ url_for('customers.create_customer') }}">
|
||||
<i class="bi bi-person-plus"></i>
|
||||
<span>Neuer Kunde</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'licenses.create_license' %}active{% endif %}" href="{{ url_for('licenses.create_license') }}">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
<span>Neue Lizenz</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'batch.batch_create' %}active{% endif %}" href="{{ url_for('batch.batch_create') }}">
|
||||
<i class="bi bi-stack"></i>
|
||||
<span>Batch-Erstellung</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('leads.') %}active{% endif %}" href="{{ url_for('leads.lead_management') }}">
|
||||
<i class="bi bi-people"></i>
|
||||
<span>Lead Management</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if request.endpoint in ['resources.resources', 'resources.add_resources'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link has-submenu {% if request.endpoint == 'resources.resources' %}active{% endif %}" href="{{ url_for('resources.resources') }}">
|
||||
<i class="bi bi-box-seam"></i>
|
||||
<span>Ressourcen Pool</span>
|
||||
</a>
|
||||
<ul class="sidebar-submenu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'resources.add_resources' %}active{% endif %}" href="{{ url_for('resources.add_resources') }}">
|
||||
<i class="bi bi-plus-square"></i>
|
||||
<span>Ressourcen hinzufügen</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item {% if request.endpoint in ['monitoring.unified_monitoring', 'monitoring.live_dashboard', 'monitoring.alerts'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link {% if request.endpoint == 'monitoring.unified_monitoring' %}active{% endif %}" href="{{ url_for('monitoring.unified_monitoring') }}">
|
||||
<i class="bi bi-activity"></i>
|
||||
<span>Monitoring</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if request.endpoint in ['admin.audit_log', 'admin.backups', 'admin.blocked_ips', 'admin.license_config'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link has-submenu" href="{{ url_for('admin.license_config') }}">
|
||||
<i class="bi bi-tools"></i>
|
||||
<span>Administration</span>
|
||||
</a>
|
||||
<ul class="sidebar-submenu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.audit_log' %}active{% endif %}" href="{{ url_for('admin.audit_log') }}">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
<span>Audit-Log</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.backups' %}active{% endif %}" href="{{ url_for('admin.backups') }}">
|
||||
<i class="bi bi-cloud-download"></i>
|
||||
<span>Backups</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.blocked_ips' %}active{% endif %}" href="{{ url_for('admin.blocked_ips') }}">
|
||||
<i class="bi bi-slash-circle"></i>
|
||||
<span>Gesperrte IPs</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-content" id="main-content">
|
||||
<!-- Page Content -->
|
||||
<div class="container-fluid p-4">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Warning Modal -->
|
||||
<div id="session-warning" class="session-warning-modal" style="display: none;">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<strong>⚠️ Session läuft ab!</strong><br>
|
||||
Ihre Session läuft in weniger als 1 Minute ab.<br>
|
||||
<button type="button" class="btn btn-sm btn-success mt-2" onclick="extendSession()">
|
||||
Session verlängern
|
||||
</button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Session-Timer Konfiguration
|
||||
const SESSION_TIMEOUT = 5 * 60; // 5 Minuten in Sekunden
|
||||
let timeRemaining = SESSION_TIMEOUT;
|
||||
let timerInterval;
|
||||
let warningShown = false;
|
||||
let lastActivity = Date.now();
|
||||
|
||||
// Timer Display Update
|
||||
function updateTimerDisplay() {
|
||||
const minutes = Math.floor(timeRemaining / 60);
|
||||
const seconds = timeRemaining % 60;
|
||||
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
document.getElementById('timer-display').textContent = display;
|
||||
|
||||
// Timer-Farbe ändern
|
||||
const timerElement = document.getElementById('session-timer');
|
||||
timerElement.className = timerElement.className.replace(/timer-\w+/, '');
|
||||
|
||||
if (timeRemaining <= 30) {
|
||||
timerElement.classList.add('timer-critical');
|
||||
} else if (timeRemaining <= 60) {
|
||||
timerElement.classList.add('timer-danger');
|
||||
if (!warningShown) {
|
||||
showSessionWarning();
|
||||
warningShown = true;
|
||||
}
|
||||
} else if (timeRemaining <= 120) {
|
||||
timerElement.classList.add('timer-warning');
|
||||
} else {
|
||||
timerElement.classList.add('timer-normal');
|
||||
warningShown = false;
|
||||
hideSessionWarning();
|
||||
}
|
||||
}
|
||||
|
||||
// Session Warning anzeigen
|
||||
function showSessionWarning() {
|
||||
document.getElementById('session-warning').style.display = 'block';
|
||||
}
|
||||
|
||||
// Session Warning verstecken
|
||||
function hideSessionWarning() {
|
||||
document.getElementById('session-warning').style.display = 'none';
|
||||
}
|
||||
|
||||
// Timer zurücksetzen
|
||||
function resetTimer() {
|
||||
timeRemaining = SESSION_TIMEOUT;
|
||||
lastActivity = Date.now();
|
||||
updateTimerDisplay();
|
||||
}
|
||||
|
||||
// Session verlängern
|
||||
function extendSession() {
|
||||
fetch('{{ url_for('auth.heartbeat') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
resetTimer();
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Heartbeat error:', error));
|
||||
}
|
||||
|
||||
// Timer Countdown
|
||||
function countdown() {
|
||||
timeRemaining--;
|
||||
updateTimerDisplay();
|
||||
|
||||
if (timeRemaining <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
window.location.href = '{{ url_for('auth.logout') }}';
|
||||
}
|
||||
}
|
||||
|
||||
// Aktivitäts-Tracking
|
||||
function trackActivity() {
|
||||
const now = Date.now();
|
||||
// Nur wenn mehr als 5 Sekunden seit letzter Aktivität
|
||||
if (now - lastActivity > 5000) {
|
||||
lastActivity = now;
|
||||
extendSession(); // resetTimer() wird in extendSession nach erfolgreicher Response aufgerufen
|
||||
}
|
||||
}
|
||||
|
||||
// Event Listeners für Benutzeraktivität
|
||||
document.addEventListener('click', trackActivity);
|
||||
document.addEventListener('keypress', trackActivity);
|
||||
document.addEventListener('mousemove', () => {
|
||||
const now = Date.now();
|
||||
// Mausbewegung nur alle 30 Sekunden tracken
|
||||
if (now - lastActivity > 30000) {
|
||||
trackActivity();
|
||||
}
|
||||
});
|
||||
|
||||
// AJAX Interceptor für automatische Session-Verlängerung
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
// Nur für non-heartbeat requests den Timer verlängern
|
||||
if (!args[0].includes('/heartbeat')) {
|
||||
trackActivity();
|
||||
}
|
||||
return originalFetch.apply(this, args);
|
||||
};
|
||||
|
||||
// Timer starten
|
||||
timerInterval = setInterval(countdown, 1000);
|
||||
updateTimerDisplay();
|
||||
|
||||
// Initial Heartbeat
|
||||
extendSession();
|
||||
|
||||
// Preserve show_test parameter across navigation
|
||||
function preserveShowTestParameter() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const showTest = urlParams.get('show_test');
|
||||
|
||||
if (showTest === 'true') {
|
||||
// Update all internal links to include show_test parameter
|
||||
document.querySelectorAll('a[href^="/"]').forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
// Skip if already has parameters or is just a fragment
|
||||
if (!href.includes('?') && !href.startsWith('#')) {
|
||||
link.setAttribute('href', href + '?show_test=true');
|
||||
} else if (href.includes('?') && !href.includes('show_test=')) {
|
||||
link.setAttribute('href', href + '&show_test=true');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side table sorting
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Preserve show_test parameter on page load
|
||||
preserveShowTestParameter();
|
||||
|
||||
// Initialize all sortable tables
|
||||
const sortableTables = document.querySelectorAll('.sortable-table');
|
||||
|
||||
sortableTables.forEach(table => {
|
||||
const headers = table.querySelectorAll('th.sortable');
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
header.addEventListener('click', function() {
|
||||
sortTable(table, index, header);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function sortTable(table, columnIndex, header) {
|
||||
const tbody = table.querySelector('tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
const isNumeric = header.dataset.type === 'numeric';
|
||||
const isDate = header.dataset.type === 'date';
|
||||
|
||||
// Determine sort direction
|
||||
let direction = 'asc';
|
||||
if (header.classList.contains('asc')) {
|
||||
direction = 'desc';
|
||||
}
|
||||
|
||||
// Remove all sort classes from headers
|
||||
table.querySelectorAll('th.sortable').forEach(th => {
|
||||
th.classList.remove('asc', 'desc');
|
||||
});
|
||||
|
||||
// Add appropriate class to clicked header
|
||||
header.classList.add(direction);
|
||||
|
||||
// Sort rows
|
||||
rows.sort((a, b) => {
|
||||
let aValue = a.cells[columnIndex].textContent.trim();
|
||||
let bValue = b.cells[columnIndex].textContent.trim();
|
||||
|
||||
// Handle different data types
|
||||
if (isNumeric) {
|
||||
aValue = parseFloat(aValue.replace(/[^0-9.-]/g, '')) || 0;
|
||||
bValue = parseFloat(bValue.replace(/[^0-9.-]/g, '')) || 0;
|
||||
} else if (isDate) {
|
||||
// Parse German date format (DD.MM.YYYY HH:MM)
|
||||
aValue = parseGermanDate(aValue);
|
||||
bValue = parseGermanDate(bValue);
|
||||
} else {
|
||||
// Text comparison with locale support for umlauts
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Reorder rows in DOM
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
}
|
||||
|
||||
function parseGermanDate(dateStr) {
|
||||
// Handle DD.MM.YYYY HH:MM format
|
||||
const parts = dateStr.match(/(\d{2})\.(\d{2})\.(\d{4})\s*(\d{2}:\d{2})?/);
|
||||
if (parts) {
|
||||
const [_, day, month, year, time] = parts;
|
||||
const timeStr = time || '00:00';
|
||||
return new Date(`${year}-${month}-${day}T${timeStr}`);
|
||||
}
|
||||
return new Date(0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
514
v2_adminpanel/templates/batch_form.html
Normale Datei
514
v2_adminpanel/templates/batch_form.html
Normale Datei
@@ -0,0 +1,514 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Batch-Lizenzen erstellen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>🔑 Batch-Lizenzen erstellen</h2>
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>ℹ️ Batch-Generierung:</strong> Erstellen Sie mehrere Lizenzen auf einmal für einen Kunden.
|
||||
Die Lizenzen werden automatisch generiert und können anschließend als CSV exportiert werden.
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Customer Type Indicator -->
|
||||
<div id="customerTypeIndicator" class="alert d-none mb-3" role="alert">
|
||||
<i class="fas fa-info-circle"></i> <span id="customerTypeMessage"></span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('batch.batch_create') }}" accept-charset="UTF-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label for="customerSelect" class="form-label">Kunde auswählen</label>
|
||||
<select class="form-select" id="customerSelect" name="customer_id" required>
|
||||
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
|
||||
<option value="new">➕ Neuer Kunde</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6" id="customerNameDiv" style="display: none;">
|
||||
<label for="customerName" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="customerName" name="customer_name"
|
||||
placeholder="Firma GmbH" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-md-6" id="emailDiv" style="display: none;">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
placeholder="kontakt@firma.de" accept-charset="UTF-8">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label for="quantity" class="form-label">Anzahl Lizenzen</label>
|
||||
<input type="number" class="form-control" id="quantity" name="quantity"
|
||||
min="1" max="100" value="10" required>
|
||||
<div class="form-text">Max. 100 Lizenzen pro Batch</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||
<select class="form-select" id="licenseType" name="license_type" required>
|
||||
<option value="full">Vollversion</option>
|
||||
<option value="test">Testversion</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="validFrom" class="form-label">Gültig ab</label>
|
||||
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-1">
|
||||
<label for="duration" class="form-label">Laufzeit</label>
|
||||
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="durationType" class="form-label">Einheit</label>
|
||||
<select class="form-select" id="durationType" name="duration_type" required>
|
||||
<option value="days">Tage</option>
|
||||
<option value="months">Monate</option>
|
||||
<option value="years" selected>Jahre</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="validUntil" class="form-label">Gültig bis</label>
|
||||
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Pool Allocation -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-server"></i> Ressourcen-Zuweisung pro Lizenz
|
||||
<small class="text-muted float-end" id="resourceStatus"></small>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="domainCount" class="form-label">
|
||||
<i class="fas fa-globe"></i> Domains
|
||||
</label>
|
||||
<select class="form-select" id="domainCount" name="domain_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
|
||||
| Benötigt: <span id="domainsNeeded" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="ipv4Count" class="form-label">
|
||||
<i class="fas fa-network-wired"></i> IPv4-Adressen
|
||||
</label>
|
||||
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
|
||||
| Benötigt: <span id="ipv4Needed" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="phoneCount" class="form-label">
|
||||
<i class="fas fa-phone"></i> Telefonnummern
|
||||
</label>
|
||||
<select class="form-select" id="phoneCount" name="phone_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
|
||||
| Benötigt: <span id="phoneNeeded" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-3 mb-0" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Batch-Ressourcen:</strong> Jede Lizenz erhält die angegebene Anzahl an Ressourcen.
|
||||
Bei 10 Lizenzen mit je 2 Domains werden insgesamt 20 Domains benötigt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Limit -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-laptop"></i> Gerätelimit pro Lizenz
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label for="deviceLimit" class="form-label">
|
||||
Maximale Anzahl Geräte pro Lizenz
|
||||
</label>
|
||||
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Jede generierte Lizenz kann auf maximal dieser Anzahl von Geräten gleichzeitig aktiviert werden.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-4 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
🔑 Batch generieren
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="previewKeys()">
|
||||
👁️ Vorschau
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Vorschau Modal -->
|
||||
<div class="modal fade" id="previewModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Vorschau der generierten Keys</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Es werden <strong id="previewQuantity">10</strong> Lizenzen im folgenden Format generiert:</p>
|
||||
<div class="bg-light p-3 rounded font-monospace" id="previewFormat">
|
||||
AF-F-YYYYMM-XXXX-YYYY-ZZZZ
|
||||
</div>
|
||||
<p class="mt-3 mb-0">Die Keys werden automatisch eindeutig generiert und in der Datenbank gespeichert.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Funktion zur Berechnung des Ablaufdatums
|
||||
function calculateValidUntil() {
|
||||
const validFrom = document.getElementById('validFrom').value;
|
||||
const duration = parseInt(document.getElementById('duration').value) || 1;
|
||||
const durationType = document.getElementById('durationType').value;
|
||||
|
||||
if (!validFrom) return;
|
||||
|
||||
const startDate = new Date(validFrom);
|
||||
let endDate = new Date(startDate);
|
||||
|
||||
switch(durationType) {
|
||||
case 'days':
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
break;
|
||||
case 'months':
|
||||
endDate.setMonth(endDate.getMonth() + duration);
|
||||
break;
|
||||
case 'years':
|
||||
endDate.setFullYear(endDate.getFullYear() + duration);
|
||||
break;
|
||||
}
|
||||
|
||||
// Kein Tag abziehen - die Lizenz ist bis einschließlich des Enddatums gültig
|
||||
|
||||
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Event Listener für Änderungen
|
||||
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
|
||||
document.getElementById('duration').addEventListener('input', calculateValidUntil);
|
||||
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
|
||||
|
||||
// Setze heutiges Datum als Standard
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('validFrom').value = today;
|
||||
|
||||
// Initialize customer is_fake map
|
||||
window.customerIsFakeMap = {};
|
||||
|
||||
// Berechne initiales Ablaufdatum
|
||||
calculateValidUntil();
|
||||
|
||||
// Initialisiere Select2 für Kundenauswahl
|
||||
$('#customerSelect').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
|
||||
allowClear: true,
|
||||
ajax: {
|
||||
url: '/api/customers',
|
||||
dataType: 'json',
|
||||
delay: 250,
|
||||
data: function (params) {
|
||||
return {
|
||||
q: params.term,
|
||||
page: params.page || 1
|
||||
};
|
||||
},
|
||||
processResults: function (data, params) {
|
||||
params.page = params.page || 1;
|
||||
|
||||
// Store is_fake status for each customer
|
||||
const results = data.results || [];
|
||||
results.forEach(customer => {
|
||||
if (customer.id !== 'new') {
|
||||
window.customerIsFakeMap[customer.id] = customer.is_fake || false;
|
||||
}
|
||||
});
|
||||
|
||||
// "Neuer Kunde" Option immer oben anzeigen
|
||||
if (params.page === 1) {
|
||||
results.unshift({
|
||||
id: 'new',
|
||||
text: '➕ Neuer Kunde',
|
||||
isNew: true
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
results: results,
|
||||
pagination: data.pagination
|
||||
};
|
||||
},
|
||||
cache: true
|
||||
},
|
||||
minimumInputLength: 0,
|
||||
language: {
|
||||
inputTooShort: function() { return ''; },
|
||||
noResults: function() { return 'Keine Kunden gefunden'; },
|
||||
searching: function() { return 'Suche...'; },
|
||||
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
|
||||
}
|
||||
});
|
||||
|
||||
// Event Handler für Kundenauswahl
|
||||
$('#customerSelect').on('select2:select', function (e) {
|
||||
const selectedValue = e.params.data.id;
|
||||
const nameDiv = document.getElementById('customerNameDiv');
|
||||
const emailDiv = document.getElementById('emailDiv');
|
||||
const nameInput = document.getElementById('customerName');
|
||||
const emailInput = document.getElementById('email');
|
||||
|
||||
if (selectedValue === 'new') {
|
||||
// Zeige Eingabefelder für neuen Kunden
|
||||
nameDiv.style.display = 'block';
|
||||
emailDiv.style.display = 'block';
|
||||
nameInput.required = true;
|
||||
emailInput.required = true;
|
||||
|
||||
// Zeige Indikator für neuen Kunden
|
||||
showCustomerTypeIndicator('new');
|
||||
} else {
|
||||
// Verstecke Eingabefelder bei bestehendem Kunden
|
||||
nameDiv.style.display = 'none';
|
||||
emailDiv.style.display = 'none';
|
||||
nameInput.required = false;
|
||||
emailInput.required = false;
|
||||
nameInput.value = '';
|
||||
emailInput.value = '';
|
||||
|
||||
// Zeige Indikator basierend auf Kundendaten
|
||||
if (e.params.data.is_fake !== undefined) {
|
||||
showCustomerTypeIndicator(e.params.data.is_fake ? 'fake' : 'real');
|
||||
}
|
||||
}
|
||||
|
||||
// Update resource availability check when customer changes
|
||||
checkResourceAvailability();
|
||||
});
|
||||
|
||||
// Clear handler
|
||||
$('#customerSelect').on('select2:clear', function (e) {
|
||||
document.getElementById('customerNameDiv').style.display = 'none';
|
||||
document.getElementById('emailDiv').style.display = 'none';
|
||||
document.getElementById('customerName').required = false;
|
||||
document.getElementById('email').required = false;
|
||||
hideCustomerTypeIndicator();
|
||||
window.customerIsFakeMap = {};
|
||||
checkResourceAvailability();
|
||||
});
|
||||
|
||||
// Resource Availability Check
|
||||
checkResourceAvailability();
|
||||
|
||||
// Event Listener für Resource Count und Quantity Änderungen
|
||||
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('quantity').addEventListener('input', checkResourceAvailability);
|
||||
});
|
||||
|
||||
// Vorschau-Funktion
|
||||
function previewKeys() {
|
||||
const quantity = document.getElementById('quantity').value;
|
||||
const type = document.getElementById('licenseType').value;
|
||||
const typeChar = type === 'full' ? 'F' : 'T';
|
||||
const date = new Date();
|
||||
const dateStr = date.getFullYear() + ('0' + (date.getMonth() + 1)).slice(-2);
|
||||
|
||||
document.getElementById('previewQuantity').textContent = quantity;
|
||||
document.getElementById('previewFormat').textContent = `AF-${typeChar}-${dateStr}-XXXX-YYYY-ZZZZ`;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Validierung
|
||||
document.getElementById('quantity').addEventListener('input', function(e) {
|
||||
if (e.target.value > 100) {
|
||||
e.target.value = 100;
|
||||
}
|
||||
if (e.target.value < 1) {
|
||||
e.target.value = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit für Batch
|
||||
function checkResourceAvailability() {
|
||||
const quantity = parseInt(document.getElementById('quantity').value) || 1;
|
||||
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
|
||||
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
|
||||
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
|
||||
|
||||
// Berechne Gesamtbedarf
|
||||
const totalDomains = domainCount * quantity;
|
||||
const totalIpv4 = ipv4Count * quantity;
|
||||
const totalPhones = phoneCount * quantity;
|
||||
|
||||
// Update "Benötigt" Anzeigen
|
||||
document.getElementById('domainsNeeded').textContent = totalDomains;
|
||||
document.getElementById('ipv4Needed').textContent = totalIpv4;
|
||||
document.getElementById('phoneNeeded').textContent = totalPhones;
|
||||
|
||||
// Get customer's is_fake status
|
||||
const selectedCustomer = document.getElementById('customer_id');
|
||||
let isFake = 'false';
|
||||
if (selectedCustomer && selectedCustomer.value && window.customerIsFakeMap) {
|
||||
isFake = window.customerIsFakeMap[selectedCustomer.value] ? 'true' : 'false';
|
||||
}
|
||||
|
||||
// API-Call zur Verfügbarkeitsprüfung
|
||||
fetch(`/api/resources/check-availability?domain=${totalDomains}&ipv4=${totalIpv4}&phone=${totalPhones}&is_fake=${isFake}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update der Verfügbarkeitsanzeigen
|
||||
updateAvailabilityDisplay('domainsAvailable', data.domain_available, totalDomains);
|
||||
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, totalIpv4);
|
||||
updateAvailabilityDisplay('phoneAvailable', data.phone_available, totalPhones);
|
||||
|
||||
// Gesamtstatus aktualisieren
|
||||
updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Hilfsfunktion zur Anzeige der Verfügbarkeit
|
||||
function updateAvailabilityDisplay(elementId, available, requested) {
|
||||
const element = document.getElementById(elementId);
|
||||
element.textContent = available;
|
||||
|
||||
const neededElement = element.parentElement.querySelector('.fw-bold:last-child');
|
||||
|
||||
if (requested > 0 && available < requested) {
|
||||
element.classList.remove('text-success');
|
||||
element.classList.add('text-danger');
|
||||
neededElement.classList.add('text-danger');
|
||||
neededElement.classList.remove('text-success');
|
||||
} else if (available < 50) {
|
||||
element.classList.remove('text-success', 'text-danger');
|
||||
element.classList.add('text-warning');
|
||||
} else {
|
||||
element.classList.remove('text-danger', 'text-warning');
|
||||
element.classList.add('text-success');
|
||||
neededElement.classList.remove('text-danger');
|
||||
neededElement.classList.add('text-success');
|
||||
}
|
||||
}
|
||||
|
||||
// Gesamtstatus der Ressourcen-Verfügbarkeit für Batch
|
||||
function updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity) {
|
||||
const statusElement = document.getElementById('resourceStatus');
|
||||
let hasIssue = false;
|
||||
let message = '';
|
||||
|
||||
if (totalDomains > 0 && data.domain_available < totalDomains) {
|
||||
hasIssue = true;
|
||||
message = `⚠️ Nicht genügend Domains (${data.domain_available}/${totalDomains})`;
|
||||
} else if (totalIpv4 > 0 && data.ipv4_available < totalIpv4) {
|
||||
hasIssue = true;
|
||||
message = `⚠️ Nicht genügend IPv4-Adressen (${data.ipv4_available}/${totalIpv4})`;
|
||||
} else if (totalPhones > 0 && data.phone_available < totalPhones) {
|
||||
hasIssue = true;
|
||||
message = `⚠️ Nicht genügend Telefonnummern (${data.phone_available}/${totalPhones})`;
|
||||
} else {
|
||||
message = `✅ Ressourcen für ${quantity} Lizenzen verfügbar`;
|
||||
}
|
||||
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
|
||||
|
||||
// Disable submit button if not enough resources
|
||||
const submitButton = document.querySelector('button[type="submit"]');
|
||||
submitButton.disabled = hasIssue;
|
||||
if (hasIssue) {
|
||||
submitButton.classList.add('btn-secondary');
|
||||
submitButton.classList.remove('btn-primary');
|
||||
} else {
|
||||
submitButton.classList.add('btn-primary');
|
||||
submitButton.classList.remove('btn-secondary');
|
||||
}
|
||||
}
|
||||
|
||||
// Funktion zur Anzeige des Kundentyp-Indikators
|
||||
function showCustomerTypeIndicator(type) {
|
||||
const indicator = document.getElementById('customerTypeIndicator');
|
||||
const message = document.getElementById('customerTypeMessage');
|
||||
|
||||
indicator.classList.remove('d-none', 'alert-info', 'alert-warning', 'alert-success');
|
||||
|
||||
if (type === 'new') {
|
||||
indicator.classList.add('alert-info');
|
||||
message.textContent = 'Neue Kunden werden in der Testphase als TEST-Kunden erstellt. Alle Batch-Lizenzen werden automatisch als TEST-Lizenzen markiert.';
|
||||
} else if (type === 'fake') {
|
||||
indicator.classList.add('alert-warning');
|
||||
message.textContent = 'Dies ist ein TEST-Kunde. Alle Batch-Lizenzen werden automatisch als TEST-Lizenzen markiert und von der Software ignoriert.';
|
||||
} else if (type === 'real') {
|
||||
indicator.classList.add('alert-success');
|
||||
message.textContent = 'Dies ist ein PRODUKTIV-Kunde. Alle Batch-Lizenzen werden als produktive Lizenzen erstellt.';
|
||||
}
|
||||
}
|
||||
|
||||
function hideCustomerTypeIndicator() {
|
||||
document.getElementById('customerTypeIndicator').classList.add('d-none');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
156
v2_adminpanel/templates/batch_result.html
Normale Datei
156
v2_adminpanel/templates/batch_result.html
Normale Datei
@@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Batch-Lizenzen generiert{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>✅ Batch-Lizenzen erfolgreich generiert</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('batch.batch_create') }}" class="btn btn-primary">🔑 Weitere Batch erstellen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success">
|
||||
<h5 class="alert-heading">🎉 {{ licenses|length }} Lizenzen wurden erfolgreich generiert!</h5>
|
||||
<p class="mb-0">Die Lizenzen wurden in der Datenbank gespeichert und dem Kunden zugeordnet.</p>
|
||||
</div>
|
||||
|
||||
<!-- Kunden-Info -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📋 Kundeninformationen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Kunde:</strong> {{ customer }}</p>
|
||||
<p><strong>E-Mail:</strong> {{ email or 'Nicht angegeben' }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Gültig von:</strong> {{ valid_from }}</p>
|
||||
<p><strong>Gültig bis:</strong> {{ valid_until }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export-Optionen -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📥 Export-Optionen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Exportieren Sie die generierten Lizenzen für den Kunden:</p>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('batch.export_batch') }}" class="btn btn-success">
|
||||
📄 Als CSV exportieren
|
||||
</a>
|
||||
<button class="btn btn-outline-primary" onclick="copyAllKeys()">
|
||||
📋 Alle Keys kopieren
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="window.print()">
|
||||
🖨️ Drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generierte Lizenzen -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">🔑 Generierte Lizenzen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">#</th>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th width="120">Typ</th>
|
||||
<th width="100">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in licenses %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td class="font-monospace">{{ license.key }}</td>
|
||||
<td>
|
||||
{% if license.type == 'full' %}
|
||||
<span class="badge bg-success">Vollversion</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">Testversion</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="copyKey('{{ license.key }}')"
|
||||
title="Kopieren">
|
||||
📋
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hinweis -->
|
||||
<div class="alert alert-info mt-4">
|
||||
<strong>💡 Tipp:</strong> Die generierten Lizenzen sind sofort aktiv und können verwendet werden.
|
||||
Sie finden alle Lizenzen auch in der <a href="{{ url_for('licenses.licenses', search=customer) }}">Lizenzübersicht</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Textarea für Kopieren (unsichtbar) -->
|
||||
<textarea id="copyArea" style="position: absolute; left: -9999px;"></textarea>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
.btn, .alert-info, .card-header h5 { display: none !important; }
|
||||
.card { border: 1px solid #000 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Einzelnen Key kopieren
|
||||
function copyKey(key) {
|
||||
navigator.clipboard.writeText(key).then(function() {
|
||||
// Visuelles Feedback
|
||||
event.target.textContent = '✓';
|
||||
setTimeout(() => {
|
||||
event.target.textContent = '📋';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Alle Keys kopieren
|
||||
function copyAllKeys() {
|
||||
const keys = [];
|
||||
{% for license in licenses %}
|
||||
keys.push('{{ license.key }}');
|
||||
{% endfor %}
|
||||
|
||||
const text = keys.join('\n');
|
||||
const textarea = document.getElementById('copyArea');
|
||||
textarea.value = text;
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
// Visuelles Feedback
|
||||
event.target.textContent = '✓ Kopiert!';
|
||||
setTimeout(() => {
|
||||
event.target.textContent = '📋 Alle Keys kopieren';
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
// Fallback für moderne Browser
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
98
v2_adminpanel/templates/blocked_ips.html
Normale Datei
98
v2_adminpanel/templates/blocked_ips.html
Normale Datei
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Gesperrte IPs{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>🔒 Gesperrte IPs</h1>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">IP-Sperrverwaltung</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if blocked_ips %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">IP-Adresse</th>
|
||||
<th class="sortable" data-type="numeric">Versuche</th>
|
||||
<th class="sortable" data-type="date">Erster Versuch</th>
|
||||
<th class="sortable" data-type="date">Letzter Versuch</th>
|
||||
<th class="sortable" data-type="date">Gesperrt bis</th>
|
||||
<th class="sortable">Letzter User</th>
|
||||
<th class="sortable">Letzte Meldung</th>
|
||||
<th class="sortable">Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ip in blocked_ips %}
|
||||
<tr class="{% if ip.is_active %}table-danger{% else %}table-secondary{% endif %}">
|
||||
<td><code>{{ ip.ip_address }}</code></td>
|
||||
<td><span class="badge bg-danger">{{ ip.attempt_count }}</span></td>
|
||||
<td>{{ ip.first_attempt }}</td>
|
||||
<td>{{ ip.last_attempt }}</td>
|
||||
<td>{{ ip.blocked_until }}</td>
|
||||
<td>{{ ip.last_username or '-' }}</td>
|
||||
<td><strong>{{ ip.last_error or '-' }}</strong></td>
|
||||
<td>
|
||||
{% if ip.is_active %}
|
||||
<span class="badge bg-danger">GESPERRT</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">ABGELAUFEN</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if ip.is_active %}
|
||||
<form method="post" action="{{ url_for('admin.unblock_ip') }}" class="d-inline">
|
||||
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
|
||||
<button type="submit" class="btn btn-success"
|
||||
onclick="return confirm('IP {{ ip.ip_address }} wirklich entsperren?')">
|
||||
🔓 Entsperren
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('admin.clear_attempts') }}" class="d-inline ms-1">
|
||||
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
|
||||
<button type="submit" class="btn btn-warning"
|
||||
onclick="return confirm('Alle Versuche für IP {{ ip.ip_address }} zurücksetzen?')">
|
||||
🗑️ Reset
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<strong>Keine gesperrten IPs vorhanden.</strong>
|
||||
Das System läuft ohne Sicherheitsvorfälle.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">ℹ️ Informationen</h5>
|
||||
<ul class="mb-0">
|
||||
<li>IPs werden nach <strong>{{ 5 }} fehlgeschlagenen Login-Versuchen</strong> für <strong>24 Stunden</strong> gesperrt.</li>
|
||||
<li>Nach <strong>2 Versuchen</strong> wird ein CAPTCHA angezeigt.</li>
|
||||
<li>Bei <strong>5 Versuchen</strong> wird eine E-Mail-Benachrichtigung gesendet (wenn aktiviert).</li>
|
||||
<li>Gesperrte IPs können manuell entsperrt werden.</li>
|
||||
<li>Die Fehlermeldungen werden zufällig ausgewählt für zusätzliche Verwirrung.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
71
v2_adminpanel/templates/create_customer.html
Normale Datei
71
v2_adminpanel/templates/create_customer.html
Normale Datei
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Neuer Kunde{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>👤 Neuer Kunde anlegen</h2>
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('customers.create_customer') }}" accept-charset="UTF-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="name" class="form-label">Kundenname <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
placeholder="Firmenname oder Vor- und Nachname"
|
||||
accept-charset="UTF-8" required autofocus>
|
||||
<div class="form-text">Der Name des Kunden oder der Firma</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="email" class="form-label">E-Mail <span class="text-danger">*</span></label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
placeholder="kunde@beispiel.de"
|
||||
accept-charset="UTF-8" required>
|
||||
<div class="form-text">Kontakt-E-Mail-Adresse des Kunden</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="isFake" name="is_fake">
|
||||
<label class="form-check-label" for="isFake">
|
||||
<i class="fas fa-flask"></i> Als Fake-Daten markieren
|
||||
<small class="text-muted">(Kunde wird von der Software ignoriert)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>Hinweis:</strong> Nach dem Anlegen des Kunden können Sie direkt Lizenzen für diesen Kunden erstellen.
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">💾 Kunde anlegen</button>
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
|
||||
{% for category, message in messages %}
|
||||
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
185
v2_adminpanel/templates/customers.html
Normale Datei
185
v2_adminpanel/templates/customers.html
Normale Datei
@@ -0,0 +1,185 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Kundenverwaltung{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('customers.customers', sort=field, order='desc' if current_order == 'asc' else 'asc', search=search, show_fake=show_fake, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('customers.customers', sort=field, order='asc', search=search, show_fake=show_fake, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="mb-4">
|
||||
<h2>Kundenverwaltung</h2>
|
||||
</div>
|
||||
|
||||
<!-- Suchformular -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('customers.customers') }}" id="customerSearchForm" class="row g-3 align-items-end">
|
||||
<div class="col-md-8">
|
||||
<label for="search" class="form-label">🔍 Suchen</label>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
placeholder="Kundenname oder E-Mail..."
|
||||
value="{{ search }}" autofocus>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="form-check mt-4">
|
||||
<input class="form-check-input" type="checkbox" id="show_fake" name="show_fake" value="true"
|
||||
{% if show_fake %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label" for="show_fake">
|
||||
🧪 Fake-Daten anzeigen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<a href="{{ url_for('customers.customers') }}" class="btn btn-outline-secondary w-100">Zurücksetzen</a>
|
||||
</div>
|
||||
</form>
|
||||
{% if search %}
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">Suchergebnisse für: <strong>{{ search }}</strong></small>
|
||||
<a href="{{ url_for('customers.customers') }}" class="btn btn-sm btn-outline-secondary ms-2">✖ Suche zurücksetzen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ sortable_header('ID', 'id', sort, order) }}
|
||||
{{ sortable_header('Name', 'name', sort, order) }}
|
||||
{{ sortable_header('E-Mail', 'email', sort, order) }}
|
||||
{{ sortable_header('Erstellt am', 'created_at', sort, order) }}
|
||||
{{ sortable_header('Lizenzen (Aktiv/Gesamt)', 'licenses', sort, order) }}
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in customers %}
|
||||
<tr>
|
||||
<td>{{ customer.id }}</td>
|
||||
<td>
|
||||
{{ customer.name }}
|
||||
{% if customer.is_test %}
|
||||
<span class="badge bg-secondary ms-1" title="Testdaten">🧪</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ customer.email or '-' }}</td>
|
||||
<td>{{ customer.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ customer.active_licenses }}/{{ customer.license_count }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('customers.edit_customer', customer_id=customer.id) }}" class="btn btn-outline-primary">✏️ Bearbeiten</a>
|
||||
{% if customer.license_count == 0 %}
|
||||
<form method="post" action="{{ url_for('customers.delete_customer', customer_id=customer.id) }}" style="display: inline;" onsubmit="return confirm('Kunde wirklich löschen?');">
|
||||
<button type="submit" class="btn btn-outline-danger">🗑️ Löschen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-danger" disabled title="Kunde hat Lizenzen">🗑️ Löschen</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="text-center py-5">
|
||||
{% if search %}
|
||||
<p class="text-muted">Keine Kunden gefunden für: <strong>{{ search }}</strong></p>
|
||||
<a href="{{ url_for('customers.customers') }}" class="btn btn-secondary">Alle Kunden anzeigen</a>
|
||||
{% else %}
|
||||
<p class="text-muted">Noch keine Kunden vorhanden.</p>
|
||||
<a href="{{ url_for('licenses.create_license') }}" class="btn btn-primary">Erste Lizenz erstellen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav aria-label="Seitennavigation" class="mt-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
<!-- Erste Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers.customers', page=1, search=search, sort=sort, order=order, show_fake=show_fake) }}">Erste</a>
|
||||
</li>
|
||||
|
||||
<!-- Vorherige Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers.customers', page=page-1, search=search, sort=sort, order=order, show_fake=show_fake) }}">←</a>
|
||||
</li>
|
||||
|
||||
<!-- Seitenzahlen -->
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p >= page - 2 and p <= page + 2 %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers.customers', page=p, search=search, sort=sort, order=order, show_fake=show_fake) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Nächste Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers.customers', page=page+1, search=search, sort=sort, order=order, show_fake=show_fake) }}">→</a>
|
||||
</li>
|
||||
|
||||
<!-- Letzte Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers.customers', page=total_pages, search=search, sort=sort, order=order, show_fake=show_fake) }}">Letzte</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted">
|
||||
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Kunden
|
||||
</p>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Live Search für Kunden
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchForm = document.getElementById('customerSearchForm');
|
||||
const searchInput = document.getElementById('search');
|
||||
|
||||
// Debounce timer für Suchfeld
|
||||
let searchTimeout;
|
||||
|
||||
// Live-Suche mit 300ms Verzögerung
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchForm.submit();
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
1153
v2_adminpanel/templates/customers_licenses.html
Normale Datei
1153
v2_adminpanel/templates/customers_licenses.html
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
477
v2_adminpanel/templates/dashboard.html
Normale Datei
477
v2_adminpanel/templates/dashboard.html
Normale Datei
@@ -0,0 +1,477 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.stat-card {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.stat-card .card-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.stat-card .card-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.stat-card .card-label {
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
a:hover .stat-card {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.text-decoration-none:hover {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* Session pulse effect */
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.8; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.pulse-effect {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* Progress bar styles */
|
||||
.progress-custom {
|
||||
height: 8px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar-custom {
|
||||
background-color: #28a745;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<h1 class="mb-4">Dashboard</h1>
|
||||
|
||||
<!-- Statistik-Karten -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="text-decoration-none">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon text-primary">👥</div>
|
||||
<div class="card-value text-primary">{{ stats.total_customers }}</div>
|
||||
<div class="card-label text-muted">Kunden Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="text-decoration-none">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon text-info">📋</div>
|
||||
<div class="card-value text-info">{{ stats.total_licenses }}</div>
|
||||
<div class="card-label text-muted">Lizenzen Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon text-success{% if stats.active_usage > 0 %} pulse-effect{% endif %}">🟢</div>
|
||||
<div class="card-value text-success">{{ stats.active_usage }}</div>
|
||||
<div class="card-label text-muted">Aktive Nutzung</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lizenztypen -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Lizenztypen</h5>
|
||||
<div class="row">
|
||||
<div class="col-6 text-center">
|
||||
<h3 class="text-success">{{ stats.full_licenses }}</h3>
|
||||
<p class="text-muted">Vollversionen</p>
|
||||
</div>
|
||||
<div class="col-6 text-center">
|
||||
<h3 class="text-warning">{{ stats.fake_licenses }}</h3>
|
||||
<p class="text-muted">Testversionen</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if stats.fake_data_count > 0 or stats.fake_customers_count > 0 or stats.fake_resources_count > 0 %}
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<i class="fas fa-flask"></i> Fake-Daten:
|
||||
{{ stats.fake_data_count }} Lizenzen,
|
||||
{{ stats.fake_customers_count }} Kunden,
|
||||
{{ stats.fake_resources_count }} Ressourcen
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Lizenzstatus</h5>
|
||||
<div class="row">
|
||||
<div class="col-4 text-center">
|
||||
<h3 class="text-success">{{ stats.active_licenses }}</h3>
|
||||
<p class="text-muted">Aktiv</p>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<h3 class="text-danger">{{ stats.expired_licenses }}</h3>
|
||||
<p class="text-muted">Abgelaufen</p>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<h3 class="text-secondary">{{ stats.inactive_licenses }}</h3>
|
||||
<p class="text-muted">Deaktiviert</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Health Status -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-heartbeat"></i> Service Status
|
||||
{% if service_health.overall_status == 'healthy' %}
|
||||
<span class="badge bg-success float-end">Alle Systeme betriebsbereit</span>
|
||||
{% elif service_health.overall_status == 'partial' %}
|
||||
<span class="badge bg-warning float-end">Teilweise Störungen</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger float-end">Kritische Störungen</span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% for service in service_health.services %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="d-flex align-items-center p-3 border rounded"
|
||||
style="border-left: 4px solid {% if service.status == 'healthy' %}#28a745{% elif service.status == 'unhealthy' %}#ffc107{% else %}#dc3545{% endif %} !important;">
|
||||
<div class="me-3 fs-2">{{ service.icon }}</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">{{ service.name }}</h6>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="badge bg-{% if service.status == 'healthy' %}success{% elif service.status == 'unhealthy' %}warning{% else %}danger{% endif %}">
|
||||
{% if service.status == 'healthy' %}Betriebsbereit{% elif service.status == 'unhealthy' %}Eingeschränkt{% else %}Ausgefallen{% endif %}
|
||||
</span>
|
||||
{% if service.response_time %}
|
||||
<small class="text-muted">{{ service.response_time }}ms</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if service.details %}
|
||||
<small class="text-muted">{{ service.details }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup-Status und Sicherheit nebeneinander -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<a href="{{ url_for('admin.backups') }}" class="text-decoration-none">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">💾 Backup-Status</h5>
|
||||
{% if stats.last_backup %}
|
||||
{% if stats.last_backup[4] == 'success' %}
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="text-success me-2">✅</span>
|
||||
<small>{{ stats.last_backup[0].strftime('%d.%m.%Y %H:%M') }}</small>
|
||||
</div>
|
||||
<div class="progress-custom">
|
||||
<div class="progress-bar-custom" style="width: 100%;"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-1 d-block">
|
||||
{{ (stats.last_backup[1] / 1024 / 1024)|round(1) }} MB • {{ stats.last_backup[2]|round(0)|int }}s
|
||||
</small>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-danger me-2">❌</span>
|
||||
<small>Backup fehlgeschlagen</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Sicherheitsstatus -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">🔒 Sicherheitsstatus</h5>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span>Sicherheitslevel:</span>
|
||||
<span class="badge bg-{{ stats.security_level }} fs-6">{{ stats.security_level_text }}</span>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<h4 class="text-danger mb-0">{{ stats.blocked_ips_count }}</h4>
|
||||
<small class="text-muted">Gesperrte IPs</small>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h4 class="text-warning mb-0">{{ stats.failed_attempts_today }}</h4>
|
||||
<small class="text-muted">Fehlversuche heute</small>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.blocked_ips') }}" class="btn btn-sm btn-outline-danger mt-3">IP-Verwaltung →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sicherheitsereignisse -->
|
||||
{% if stats.recent_security_events %}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark text-white">
|
||||
<h6 class="mb-0">🚨 Letzte Sicherheitsereignisse</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-type="date">Zeit</th>
|
||||
<th class="sortable">IP-Adresse</th>
|
||||
<th class="sortable" data-type="numeric">Versuche</th>
|
||||
<th class="sortable">Fehlermeldung</th>
|
||||
<th class="sortable">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in stats.recent_security_events %}
|
||||
<tr>
|
||||
<td>{{ event.last_attempt }}</td>
|
||||
<td><code>{{ event.ip_address }}</code></td>
|
||||
<td><span class="badge bg-secondary">{{ event.attempt_count }}</span></td>
|
||||
<td><strong class="text-danger">{{ event.error_message }}</strong></td>
|
||||
<td>
|
||||
{% if event.blocked_until %}
|
||||
<span class="badge bg-danger">Gesperrt bis {{ event.blocked_until }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Aktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Resource Pool Status -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-server"></i> Resource Pool Status
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% if resource_stats %}
|
||||
{% for type, data in resource_stats.items() %}
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3">
|
||||
<a href="{{ url_for('resources.resources', type=type, show_test=request.args.get('show_test')) }}"
|
||||
class="text-decoration-none">
|
||||
<i class="fas fa-{{ 'globe' if type == 'domain' else ('network-wired' if type == 'ipv4' else 'phone') }} fa-2x text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">
|
||||
<a href="{{ url_for('resources.resources', type=type, show_test=request.args.get('show_test')) }}"
|
||||
class="text-decoration-none text-dark">{{ type|upper }}</a>
|
||||
</h6>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong class="text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">{{ data.available }}</strong> / {{ data.total }} verfügbar
|
||||
</span>
|
||||
<span class="badge bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">
|
||||
{{ data.available_percent }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress mt-1" style="height: 8px;">
|
||||
<div class="progress-bar bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"
|
||||
style="width: {{ data.available_percent }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.available }} von {{ data.total }} verfügbar"></div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<small class="text-muted">
|
||||
<a href="{{ url_for('resources.resources', type=type, status='allocated', show_test=request.args.get('show_test')) }}"
|
||||
class="text-decoration-none text-muted">
|
||||
{{ data.allocated }} zugeteilt
|
||||
</a>
|
||||
</small>
|
||||
{% if data.quarantine > 0 %}
|
||||
<small>
|
||||
<a href="{{ url_for('resources.resources', type=type, status='quarantine', show_test=request.args.get('show_test')) }}"
|
||||
class="text-decoration-none text-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i> {{ data.quarantine }} in Quarantäne
|
||||
</a>
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="col-12 text-center text-muted">
|
||||
<i class="fas fa-inbox fa-3x mb-3"></i>
|
||||
<p>Keine Ressourcen im Pool vorhanden.</p>
|
||||
<a href="{{ url_for('resources.add_resources', show_test=request.args.get('show_test')) }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Ressourcen hinzufügen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if resource_warning %}
|
||||
<div class="alert alert-danger mt-3 mb-0 d-flex justify-content-between align-items-center" role="alert">
|
||||
<div>
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Kritisch:</strong> {{ resource_warning }}
|
||||
</div>
|
||||
<a href="{{ url_for('resources.add_resources', show_test=request.args.get('show_test')) }}"
|
||||
class="btn btn-sm btn-danger">
|
||||
<i class="bi bi-plus"></i> Ressourcen auffüllen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Bald ablaufende Lizenzen -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h5 class="mb-0">⏰ Bald ablaufende Lizenzen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.expiring_licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Kunde</th>
|
||||
<th class="sortable">Lizenz</th>
|
||||
<th class="sortable" data-type="numeric">Tage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in stats.expiring_licenses %}
|
||||
<tr>
|
||||
<td>{{ license[2] }}</td>
|
||||
<td><small><code>{{ license[1][:8] }}...</code></small></td>
|
||||
<td><span class="badge bg-warning">{{ license[4] }} Tage</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine Lizenzen laufen in den nächsten 30 Tagen ab.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzte Lizenzen -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">🆕 Zuletzt erstellte Lizenzen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.recent_licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Kunde</th>
|
||||
<th class="sortable">Lizenz</th>
|
||||
<th class="sortable">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in stats.recent_licenses %}
|
||||
<tr>
|
||||
<td>{{ license[2] }}</td>
|
||||
<td><small><code>{{ license[1][:8] }}...</code></small></td>
|
||||
<td>
|
||||
{% if license[4] == 'deaktiviert' %}
|
||||
<span class="status-deaktiviert">🚫 Deaktiviert</span>
|
||||
{% elif license[4] == 'abgelaufen' %}
|
||||
<span class="status-abgelaufen">⚠️ Abgelaufen</span>
|
||||
{% elif license[4] == 'läuft bald ab' %}
|
||||
<span class="status-ablaufend">⏰ Läuft bald ab</span>
|
||||
{% else %}
|
||||
<span class="status-aktiv">✅ Aktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Noch keine Lizenzen erstellt.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
103
v2_adminpanel/templates/edit_customer.html
Normale Datei
103
v2_adminpanel/templates/edit_customer.html
Normale Datei
@@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Kunde bearbeiten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Kunde bearbeiten</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">👥 Zurück zur Übersicht</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('customers.edit_customer', customer_id=customer.id) }}" accept-charset="UTF-8">
|
||||
{% if request.args.get('show_fake') == 'true' %}
|
||||
<input type="hidden" name="show_fake" value="true">
|
||||
{% endif %}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="name" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{ customer.name }}" accept-charset="UTF-8" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ customer.email or '' }}" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label text-muted">Erstellt am</label>
|
||||
<p class="form-control-plaintext">{{ customer.created_at.strftime('%d.%m.%Y %H:%M') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="isTest" name="is_fake" {% if customer.is_fake %}checked{% endif %}>
|
||||
<label class="form-check-label" for="isTest">
|
||||
<i class="fas fa-flask"></i> Als Fake-Daten markieren
|
||||
<small class="text-muted">(Kunde und seine Lizenzen werden von der Software ignoriert)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
|
||||
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Lizenzen des Kunden</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Typ</th>
|
||||
<th>Gültig von</th>
|
||||
<th>Gültig bis</th>
|
||||
<th>Aktiv</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in licenses %}
|
||||
<tr>
|
||||
<td><code>{{ license[1] }}</code></td>
|
||||
<td>
|
||||
{% if license[2] == 'full' %}
|
||||
<span class="badge bg-success">Vollversion</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Testversion</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ license[3].strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ license[4].strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
{% if license[5] %}
|
||||
<span class="text-success">✓</span>
|
||||
{% else %}
|
||||
<span class="text-danger">✗</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('licenses.edit_license', license_id=license[0]) }}" class="btn btn-outline-primary btn-sm">Bearbeiten</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Dieser Kunde hat noch keine Lizenzen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
88
v2_adminpanel/templates/edit_license.html
Normale Datei
88
v2_adminpanel/templates/edit_license.html
Normale Datei
@@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Lizenz bearbeiten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Lizenz bearbeiten</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">📋 Zurück zur Übersicht</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('licenses.edit_license', license_id=license.id) }}" accept-charset="UTF-8">
|
||||
{% if request.args.get('show_fake') == 'true' %}
|
||||
<input type="hidden" name="show_fake" value="true">
|
||||
{% endif %}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Kunde</label>
|
||||
<input type="text" class="form-control" value="{{ license.customer_name }}" disabled>
|
||||
<small class="text-muted">Kunde kann nicht geändert werden</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" value="-" disabled>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
|
||||
<input type="text" class="form-control" id="licenseKey" name="license_key" value="{{ license.license_key }}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||
<select class="form-select" id="licenseType" name="license_type" required>
|
||||
<option value="full" {% if license.license_type == 'full' %}selected{% endif %}>Vollversion</option>
|
||||
<option value="test" {% if license.license_type == 'test' %}selected{% endif %}>Testversion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="validFrom" class="form-label">Gültig von</label>
|
||||
<input type="date" class="form-control" id="validFrom" name="valid_from" value="{{ license.valid_from.strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="validUntil" class="form-label">Gültig bis</label>
|
||||
<input type="date" class="form-control" id="validUntil" name="valid_until" value="{{ license.valid_until.strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="isActive" name="is_active" {% if license.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label" for="isActive">
|
||||
Lizenz ist aktiv
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="deviceLimit" class="form-label">Gerätelimit</label>
|
||||
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }}" {% if license.get('device_limit', 3) == i %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">Maximale Anzahl gleichzeitig aktiver Geräte</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert {% if license.is_fake %}alert-warning{% else %}alert-success{% endif %} mt-3" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>Status:</strong>
|
||||
{% if license.is_fake %}
|
||||
TEST-Lizenz (wird von der Software ignoriert)
|
||||
{% else %}
|
||||
PRODUKTIV-Lizenz
|
||||
{% endif %}
|
||||
<br>
|
||||
<small class="text-muted">Der Status wird vom Kunden geerbt und kann nicht direkt geändert werden.</small>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
|
||||
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
55
v2_adminpanel/templates/error.html
Normale Datei
55
v2_adminpanel/templates/error.html
Normale Datei
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Fehler{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
Fehler
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-danger mb-4" role="alert">
|
||||
<h5 class="alert-heading">{{ error|default('Ein Fehler ist aufgetreten') }}</h5>
|
||||
{% if error_code %}
|
||||
<hr>
|
||||
<p class="mb-0">
|
||||
<strong>Fehlercode:</strong> {{ error_code }}<br>
|
||||
{% if request_id %}
|
||||
<strong>Anfrage-ID:</strong> <code>{{ request_id }}</code>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-light p-3 rounded mb-4">
|
||||
<h6 class="text-muted">Was können Sie tun?</h6>
|
||||
<ul class="mb-0">
|
||||
<li>Versuchen Sie die Aktion erneut</li>
|
||||
<li>Überprüfen Sie Ihre Eingaben</li>
|
||||
<li>Kontaktieren Sie den Support mit der Anfrage-ID</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-primary">
|
||||
<i class="bi bi-house"></i> Zum Dashboard
|
||||
</a>
|
||||
<button onclick="window.history.back();" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Zurück
|
||||
</button>
|
||||
<button onclick="location.reload();" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-clockwise"></i> Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
578
v2_adminpanel/templates/index.html
Normale Datei
578
v2_adminpanel/templates/index.html
Normale Datei
@@ -0,0 +1,578 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Panel{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Neue Lizenz erstellen</h2>
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||
</div>
|
||||
|
||||
<!-- Customer Type Indicator -->
|
||||
<div id="customerTypeIndicator" class="alert d-none mb-3" role="alert">
|
||||
<i class="fas fa-info-circle"></i> <span id="customerTypeMessage"></span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('licenses.create_license') }}" accept-charset="UTF-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label for="customerSelect" class="form-label">Kunde auswählen</label>
|
||||
<select class="form-select" id="customerSelect" name="customer_id" required>
|
||||
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
|
||||
<option value="new">➕ Neuer Kunde</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6" id="customerNameDiv" style="display: none;">
|
||||
<label for="customerName" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="customerName" name="customer_name" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-md-6" id="emailDiv" style="display: none;">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="licenseKey" name="license_key"
|
||||
placeholder="AF-F-YYYYMM-XXXX-YYYY-ZZZZ" required
|
||||
pattern="AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}"
|
||||
title="Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="generateLicenseKey()">
|
||||
🔑 Generieren
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||
<select class="form-select" id="licenseType" name="license_type" required>
|
||||
<option value="full">Vollversion</option>
|
||||
<option value="test">Testversion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="validFrom" class="form-label">Kaufdatum</label>
|
||||
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label for="duration" class="form-label">Laufzeit</label>
|
||||
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label for="durationType" class="form-label">Einheit</label>
|
||||
<select class="form-select" id="durationType" name="duration_type" required>
|
||||
<option value="days">Tage</option>
|
||||
<option value="months">Monate</option>
|
||||
<option value="years" selected>Jahre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="validUntil" class="form-label">Ablaufdatum</label>
|
||||
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Pool Allocation -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-server"></i> Ressourcen-Zuweisung
|
||||
<small class="text-muted float-end" id="resourceStatus"></small>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="domainCount" class="form-label">
|
||||
<i class="fas fa-globe"></i> Domains
|
||||
</label>
|
||||
<select class="form-select" id="domainCount" name="domain_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="ipv4Count" class="form-label">
|
||||
<i class="fas fa-network-wired"></i> IPv4-Adressen
|
||||
</label>
|
||||
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="phoneCount" class="form-label">
|
||||
<i class="fas fa-phone"></i> Telefonnummern
|
||||
</label>
|
||||
<select class="form-select" id="phoneCount" name="phone_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info mt-3 mb-0" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Die Ressourcen werden bei der Lizenzerstellung automatisch aus dem Pool zugewiesen.
|
||||
Wählen Sie 0, wenn für diesen Typ keine Ressourcen benötigt werden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Limit -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-laptop"></i> Gerätelimit
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label for="deviceLimit" class="form-label">
|
||||
Maximale Anzahl Geräte
|
||||
</label>
|
||||
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">➕ Lizenz erstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
|
||||
{% for category, message in messages %}
|
||||
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<script>
|
||||
// License Key Generator
|
||||
function generateLicenseKey() {
|
||||
const licenseType = document.getElementById('licenseType').value;
|
||||
|
||||
// Zeige Ladeindikator
|
||||
const button = event.target;
|
||||
const originalText = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.innerHTML = '⏳ Generiere...';
|
||||
|
||||
// API-Call
|
||||
fetch('{{ url_for('api.api_generate_key') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({type: licenseType})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('licenseKey').value = data.key;
|
||||
// Visuelles Feedback
|
||||
document.getElementById('licenseKey').classList.add('border-success');
|
||||
setTimeout(() => {
|
||||
document.getElementById('licenseKey').classList.remove('border-success');
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('Fehler bei der Key-Generierung: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler:', error);
|
||||
alert('Netzwerkfehler bei der Key-Generierung');
|
||||
})
|
||||
.finally(() => {
|
||||
// Button zurücksetzen
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
// Event Listener für Lizenztyp-Änderung
|
||||
document.getElementById('licenseType').addEventListener('change', function() {
|
||||
const keyField = document.getElementById('licenseKey');
|
||||
if (keyField.value && keyField.value.startsWith('AF-')) {
|
||||
// Prüfe ob der Key zum neuen Typ passt
|
||||
const currentType = this.value;
|
||||
const keyType = keyField.value.charAt(3); // Position des F/T im Key (AF-F-...)
|
||||
|
||||
if ((currentType === 'full' && keyType === 'T') ||
|
||||
(currentType === 'test' && keyType === 'F')) {
|
||||
if (confirm('Der aktuelle Key passt nicht zum gewählten Lizenztyp. Neuen Key generieren?')) {
|
||||
generateLicenseKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-Uppercase für License Key Input
|
||||
document.getElementById('licenseKey').addEventListener('input', function(e) {
|
||||
e.target.value = e.target.value.toUpperCase();
|
||||
});
|
||||
|
||||
// Funktion zur Berechnung des Ablaufdatums
|
||||
function calculateValidUntil() {
|
||||
const validFrom = document.getElementById('validFrom').value;
|
||||
const duration = parseInt(document.getElementById('duration').value) || 1;
|
||||
const durationType = document.getElementById('durationType').value;
|
||||
|
||||
if (!validFrom) return;
|
||||
|
||||
const startDate = new Date(validFrom);
|
||||
let endDate = new Date(startDate);
|
||||
|
||||
switch(durationType) {
|
||||
case 'days':
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
break;
|
||||
case 'months':
|
||||
endDate.setMonth(endDate.getMonth() + duration);
|
||||
break;
|
||||
case 'years':
|
||||
endDate.setFullYear(endDate.getFullYear() + duration);
|
||||
break;
|
||||
}
|
||||
|
||||
// Kein Tag abziehen - die Lizenz ist bis einschließlich des Enddatums gültig
|
||||
|
||||
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Event Listener für Änderungen
|
||||
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
|
||||
document.getElementById('duration').addEventListener('input', calculateValidUntil);
|
||||
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
|
||||
|
||||
// Setze heutiges Datum als Standard für valid_from
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('validFrom').value = today;
|
||||
|
||||
// Berechne initiales Ablaufdatum
|
||||
calculateValidUntil();
|
||||
|
||||
// Initialisiere Select2 für Kundenauswahl
|
||||
$('#customerSelect').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
|
||||
allowClear: true,
|
||||
ajax: {
|
||||
url: '{{ url_for('api.api_customers') }}',
|
||||
dataType: 'json',
|
||||
delay: 250,
|
||||
data: function (params) {
|
||||
return {
|
||||
q: params.term,
|
||||
page: params.page || 1
|
||||
};
|
||||
},
|
||||
processResults: function (data, params) {
|
||||
params.page = params.page || 1;
|
||||
|
||||
// "Neuer Kunde" Option immer oben anzeigen
|
||||
const results = data.results || [];
|
||||
if (params.page === 1) {
|
||||
results.unshift({
|
||||
id: 'new',
|
||||
text: '➕ Neuer Kunde',
|
||||
isNew: true
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
results: results,
|
||||
pagination: data.pagination
|
||||
};
|
||||
},
|
||||
cache: true
|
||||
},
|
||||
minimumInputLength: 0,
|
||||
language: {
|
||||
inputTooShort: function() { return ''; },
|
||||
noResults: function() { return 'Keine Kunden gefunden'; },
|
||||
searching: function() { return 'Suche...'; },
|
||||
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
|
||||
}
|
||||
});
|
||||
|
||||
// Vorausgewählten Kunden setzen (falls von kombinierter Ansicht kommend)
|
||||
{% if preselected_customer_id %}
|
||||
// Lade Kundendetails und setze Auswahl
|
||||
fetch('{{ url_for('api.api_customers') }}?id={{ preselected_customer_id }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.results && data.results.length > 0) {
|
||||
const customer = data.results[0];
|
||||
// Erstelle Option und setze sie als ausgewählt
|
||||
const option = new Option(customer.text, customer.id, true, true);
|
||||
$('#customerSelect').append(option).trigger('change');
|
||||
// Verstecke die Eingabefelder
|
||||
document.getElementById('customerNameDiv').style.display = 'none';
|
||||
document.getElementById('emailDiv').style.display = 'none';
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
// Event Handler für Kundenauswahl
|
||||
$('#customerSelect').on('select2:select', function (e) {
|
||||
const selectedValue = e.params.data.id;
|
||||
const nameDiv = document.getElementById('customerNameDiv');
|
||||
const emailDiv = document.getElementById('emailDiv');
|
||||
const nameInput = document.getElementById('customerName');
|
||||
const emailInput = document.getElementById('email');
|
||||
|
||||
if (selectedValue === 'new') {
|
||||
// Zeige Eingabefelder für neuen Kunden
|
||||
nameDiv.style.display = 'block';
|
||||
emailDiv.style.display = 'block';
|
||||
nameInput.required = true;
|
||||
emailInput.required = true;
|
||||
|
||||
// New customers are currently hardcoded as fake (TODO in backend)
|
||||
window.selectedCustomerIsFake = true;
|
||||
|
||||
// Zeige Indikator für neuen Kunden
|
||||
showCustomerTypeIndicator('new');
|
||||
} else {
|
||||
// Verstecke Eingabefelder bei bestehendem Kunden
|
||||
nameDiv.style.display = 'none';
|
||||
emailDiv.style.display = 'none';
|
||||
nameInput.required = false;
|
||||
emailInput.required = false;
|
||||
nameInput.value = '';
|
||||
emailInput.value = '';
|
||||
|
||||
// Store customer's is_fake status
|
||||
window.selectedCustomerIsFake = e.params.data.is_fake || false;
|
||||
|
||||
// Zeige Indikator basierend auf Kundendaten
|
||||
if (e.params.data.is_fake !== undefined) {
|
||||
showCustomerTypeIndicator(e.params.data.is_fake ? 'fake' : 'real');
|
||||
}
|
||||
}
|
||||
|
||||
// Update resource availability check with new customer status
|
||||
checkResourceAvailability();
|
||||
});
|
||||
|
||||
// Clear handler
|
||||
$('#customerSelect').on('select2:clear', function (e) {
|
||||
document.getElementById('customerNameDiv').style.display = 'none';
|
||||
document.getElementById('emailDiv').style.display = 'none';
|
||||
document.getElementById('customerName').required = false;
|
||||
document.getElementById('email').required = false;
|
||||
window.selectedCustomerIsFake = false;
|
||||
checkResourceAvailability();
|
||||
hideCustomerTypeIndicator();
|
||||
});
|
||||
|
||||
// Store selected customer's is_fake status
|
||||
window.selectedCustomerIsFake = false;
|
||||
|
||||
// Resource Availability Check
|
||||
checkResourceAvailability();
|
||||
|
||||
// Event Listener für Resource Count Änderungen
|
||||
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
|
||||
});
|
||||
|
||||
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit
|
||||
function checkResourceAvailability() {
|
||||
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
|
||||
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
|
||||
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
|
||||
|
||||
// Include is_fake parameter based on selected customer
|
||||
const isFake = window.selectedCustomerIsFake ? 'true' : 'false';
|
||||
|
||||
// API-Call zur Verfügbarkeitsprüfung
|
||||
fetch(`{{ url_for('api.check_resource_availability') }}?domain=${domainCount}&ipv4=${ipv4Count}&phone=${phoneCount}&is_fake=${isFake}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update der Verfügbarkeitsanzeigen
|
||||
updateAvailabilityDisplay('domainsAvailable', data.domain_available, domainCount);
|
||||
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, ipv4Count);
|
||||
updateAvailabilityDisplay('phoneAvailable', data.phone_available, phoneCount);
|
||||
|
||||
// Gesamtstatus aktualisieren
|
||||
updateResourceStatus(data, domainCount, ipv4Count, phoneCount);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Hilfsfunktion zur Anzeige der Verfügbarkeit
|
||||
function updateAvailabilityDisplay(elementId, available, requested) {
|
||||
const element = document.getElementById(elementId);
|
||||
const container = element.parentElement;
|
||||
|
||||
// Verfügbarkeit mit Prozentanzeige
|
||||
const percent = Math.round((available / (available + requested + 50)) * 100);
|
||||
let statusHtml = `<strong>${available}</strong>`;
|
||||
|
||||
if (requested > 0 && available < requested) {
|
||||
element.classList.remove('text-success', 'text-warning');
|
||||
element.classList.add('text-danger');
|
||||
statusHtml += ` <i class="bi bi-exclamation-triangle"></i>`;
|
||||
|
||||
// Füge Warnung hinzu
|
||||
if (!container.querySelector('.availability-warning')) {
|
||||
const warning = document.createElement('div');
|
||||
warning.className = 'availability-warning text-danger small mt-1';
|
||||
warning.innerHTML = `<i class="bi bi-x-circle"></i> Nicht genügend Ressourcen verfügbar!`;
|
||||
container.appendChild(warning);
|
||||
}
|
||||
} else {
|
||||
// Entferne Warnung wenn vorhanden
|
||||
const warning = container.querySelector('.availability-warning');
|
||||
if (warning) warning.remove();
|
||||
|
||||
if (available < 20) {
|
||||
element.classList.remove('text-success');
|
||||
element.classList.add('text-danger');
|
||||
statusHtml += ` <span class="badge bg-danger">Kritisch</span>`;
|
||||
} else if (available < 50) {
|
||||
element.classList.remove('text-success', 'text-danger');
|
||||
element.classList.add('text-warning');
|
||||
statusHtml += ` <span class="badge bg-warning text-dark">Niedrig</span>`;
|
||||
} else {
|
||||
element.classList.remove('text-danger', 'text-warning');
|
||||
element.classList.add('text-success');
|
||||
statusHtml += ` <span class="badge bg-success">OK</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
element.innerHTML = statusHtml;
|
||||
|
||||
// Zeige Fortschrittsbalken
|
||||
updateResourceProgressBar(elementId.replace('Available', ''), available, requested);
|
||||
}
|
||||
|
||||
// Fortschrittsbalken für Ressourcen
|
||||
function updateResourceProgressBar(resourceType, available, requested) {
|
||||
const progressId = `${resourceType}Progress`;
|
||||
let progressBar = document.getElementById(progressId);
|
||||
|
||||
// Erstelle Fortschrittsbalken wenn nicht vorhanden
|
||||
if (!progressBar) {
|
||||
const container = document.querySelector(`#${resourceType}Available`).parentElement.parentElement;
|
||||
const progressDiv = document.createElement('div');
|
||||
progressDiv.className = 'mt-2';
|
||||
progressDiv.innerHTML = `
|
||||
<div class="progress" style="height: 20px;" id="${progressId}">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: 0%">
|
||||
<span class="progress-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(progressDiv);
|
||||
progressBar = document.getElementById(progressId);
|
||||
}
|
||||
|
||||
const total = available + requested;
|
||||
const availablePercent = total > 0 ? (available / total) * 100 : 100;
|
||||
const bar = progressBar.querySelector('.progress-bar');
|
||||
const text = progressBar.querySelector('.progress-text');
|
||||
|
||||
// Setze Farbe basierend auf Verfügbarkeit
|
||||
bar.classList.remove('bg-success', 'bg-warning', 'bg-danger');
|
||||
if (requested > 0 && available < requested) {
|
||||
bar.classList.add('bg-danger');
|
||||
} else if (availablePercent < 30) {
|
||||
bar.classList.add('bg-warning');
|
||||
} else {
|
||||
bar.classList.add('bg-success');
|
||||
}
|
||||
|
||||
// Animiere Fortschrittsbalken
|
||||
bar.style.width = `${availablePercent}%`;
|
||||
text.textContent = requested > 0 ? `${available} von ${total}` : `${available} verfügbar`;
|
||||
}
|
||||
|
||||
// Gesamtstatus der Ressourcen-Verfügbarkeit
|
||||
function updateResourceStatus(data, domainCount, ipv4Count, phoneCount) {
|
||||
const statusElement = document.getElementById('resourceStatus');
|
||||
let hasIssue = false;
|
||||
let message = '';
|
||||
|
||||
if (domainCount > 0 && data.domain_available < domainCount) {
|
||||
hasIssue = true;
|
||||
message = '⚠️ Nicht genügend Domains';
|
||||
} else if (ipv4Count > 0 && data.ipv4_available < ipv4Count) {
|
||||
hasIssue = true;
|
||||
message = '⚠️ Nicht genügend IPv4-Adressen';
|
||||
} else if (phoneCount > 0 && data.phone_available < phoneCount) {
|
||||
hasIssue = true;
|
||||
message = '⚠️ Nicht genügend Telefonnummern';
|
||||
} else {
|
||||
message = '✅ Alle Ressourcen verfügbar';
|
||||
}
|
||||
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
|
||||
}
|
||||
|
||||
// Funktion zur Anzeige des Kundentyp-Indikators
|
||||
function showCustomerTypeIndicator(type) {
|
||||
const indicator = document.getElementById('customerTypeIndicator');
|
||||
const message = document.getElementById('customerTypeMessage');
|
||||
|
||||
indicator.classList.remove('d-none', 'alert-info', 'alert-warning', 'alert-success');
|
||||
|
||||
if (type === 'new') {
|
||||
indicator.classList.add('alert-info');
|
||||
message.textContent = 'Neue Kunden werden in der Testphase als TEST-Kunden erstellt. Die Lizenz wird automatisch als TEST-Lizenz markiert.';
|
||||
} else if (type === 'fake') {
|
||||
indicator.classList.add('alert-warning');
|
||||
message.textContent = 'Dies ist ein TEST-Kunde. Die Lizenz wird automatisch als TEST-Lizenz markiert und von der Software ignoriert.';
|
||||
} else if (type === 'real') {
|
||||
indicator.classList.add('alert-success');
|
||||
message.textContent = 'Dies ist ein PRODUKTIV-Kunde. Die Lizenz wird als produktive Lizenz erstellt.';
|
||||
}
|
||||
}
|
||||
|
||||
function hideCustomerTypeIndicator() {
|
||||
document.getElementById('customerTypeIndicator').classList.add('d-none');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
445
v2_adminpanel/templates/license_analytics.html
Normale Datei
445
v2_adminpanel/templates/license_analytics.html
Normale Datei
@@ -0,0 +1,445 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}License Analytics{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h3">License Analytics</h1>
|
||||
|
||||
<!-- Time Range Selector -->
|
||||
<div class="btn-group mb-3" role="group">
|
||||
<a href="{{ url_for('admin.license_analytics', days=7) }}"
|
||||
class="btn btn-sm {% if days == 7 %}btn-primary{% else %}btn-outline-primary{% endif %}">7 Tage</a>
|
||||
<a href="{{ url_for('admin.license_analytics', days=30) }}"
|
||||
class="btn btn-sm {% if days == 30 %}btn-primary{% else %}btn-outline-primary{% endif %}">30 Tage</a>
|
||||
<a href="{{ url_for('admin.license_analytics', days=90) }}"
|
||||
class="btn btn-sm {% if days == 90 %}btn-primary{% else %}btn-outline-primary{% endif %}">90 Tage</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Aktive Lizenzen</h5>
|
||||
<h2 class="text-primary" id="active-licenses">-</h2>
|
||||
<small class="text-muted">In den letzten {{ days }} Tagen</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Aktive Geräte</h5>
|
||||
<h2 class="text-info" id="active-devices">-</h2>
|
||||
<small class="text-muted">Unique Hardware IDs</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Validierungen</h5>
|
||||
<h2 class="text-success" id="total-validations">-</h2>
|
||||
<small class="text-muted">Gesamt in {{ days }} Tagen</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Churn Risk</h5>
|
||||
<h2 class="text-warning" id="churn-risk">-</h2>
|
||||
<small class="text-muted">Kunden mit hohem Risiko</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Trends Chart -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Nutzungstrends</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="usageTrendsChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Metrics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Performance Metriken</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="performanceChart" height="150"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Lizenzverteilung</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="distributionChart" height="150"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenue Analysis -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Revenue Analysis</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="revenue-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenztyp</th>
|
||||
<th>Anzahl Lizenzen</th>
|
||||
<th>Aktive Lizenzen</th>
|
||||
<th>Gesamtumsatz</th>
|
||||
<th>Aktiver Umsatz</th>
|
||||
<th>Inaktiver Umsatz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Wird von JavaScript gefüllt -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Performers -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Top 10 Aktive Lizenzen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm" id="top-licenses">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenz</th>
|
||||
<th>Kunde</th>
|
||||
<th>Geräte</th>
|
||||
<th>Validierungen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Wird von JavaScript gefüllt -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Churn Risk Kunden</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm" id="churn-risk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Lizenzen</th>
|
||||
<th>Letzte Aktivität</th>
|
||||
<th>Risk Level</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Wird von JavaScript gefüllt -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Patterns Heatmap -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Nutzungsmuster (Heatmap)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="usagePatternsChart" height="60"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
// Configuration
|
||||
const API_BASE_URL = '/api/v1/analytics';
|
||||
const DAYS = {{ days }};
|
||||
|
||||
// Fetch data from Analytics Service
|
||||
async function fetchAnalyticsData() {
|
||||
try {
|
||||
// Get JWT token first (from license server auth)
|
||||
const tokenResponse = await fetch('/api/admin/license/auth-token');
|
||||
const tokenData = await tokenResponse.json();
|
||||
const token = tokenData.token;
|
||||
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
|
||||
// Fetch all analytics data
|
||||
const [usage, performance, distribution, revenue, patterns, churnRisk] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/usage?days=${DAYS}`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE_URL}/performance?days=${DAYS}`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE_URL}/distribution`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE_URL}/revenue?days=${DAYS}`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE_URL}/patterns`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE_URL}/churn-risk`, { headers }).then(r => r.json())
|
||||
]);
|
||||
|
||||
// Update UI with fetched data
|
||||
updateSummaryCards(usage.data, distribution.data, churnRisk.data);
|
||||
createUsageTrendsChart(usage.data);
|
||||
createPerformanceChart(performance.data);
|
||||
createDistributionChart(distribution.data);
|
||||
updateRevenueTable(revenue.data);
|
||||
updateChurnRiskTable(churnRisk.data);
|
||||
createUsagePatternsHeatmap(patterns.data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching analytics data:', error);
|
||||
// Fallback to database data if API is not available
|
||||
useFallbackData();
|
||||
}
|
||||
}
|
||||
|
||||
// Update summary cards
|
||||
function updateSummaryCards(usageData, distributionData, churnData) {
|
||||
if (usageData && usageData.length > 0) {
|
||||
const totalValidations = usageData.reduce((sum, day) => sum + day.total_heartbeats, 0);
|
||||
const uniqueLicenses = new Set(usageData.flatMap(day => day.active_licenses)).size;
|
||||
const uniqueDevices = new Set(usageData.flatMap(day => day.active_devices)).size;
|
||||
|
||||
document.getElementById('active-licenses').textContent = uniqueLicenses.toLocaleString();
|
||||
document.getElementById('active-devices').textContent = uniqueDevices.toLocaleString();
|
||||
document.getElementById('total-validations').textContent = totalValidations.toLocaleString();
|
||||
}
|
||||
|
||||
if (churnData && churnData.length > 0) {
|
||||
const highRiskCount = churnData.filter(c => c.churn_risk === 'high').length;
|
||||
document.getElementById('churn-risk').textContent = highRiskCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Create usage trends chart
|
||||
function createUsageTrendsChart(data) {
|
||||
const ctx = document.getElementById('usageTrendsChart').getContext('2d');
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => new Date(d.date).toLocaleDateString('de-DE')),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Aktive Lizenzen',
|
||||
data: data.map(d => d.active_licenses),
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||
tension: 0.1
|
||||
},
|
||||
{
|
||||
label: 'Aktive Geräte',
|
||||
data: data.map(d => d.active_devices),
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
tension: 0.1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create performance chart
|
||||
function createPerformanceChart(data) {
|
||||
const ctx = document.getElementById('performanceChart').getContext('2d');
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => new Date(d.hour).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit'
|
||||
})),
|
||||
datasets: [{
|
||||
label: 'Validierungen pro Stunde',
|
||||
data: data.map(d => d.validation_count),
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.6)',
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create distribution chart
|
||||
function createDistributionChart(data) {
|
||||
const ctx = document.getElementById('distributionChart').getContext('2d');
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => `${d.license_type} (${d.is_test ? 'Test' : 'Prod'})`),
|
||||
datasets: [{
|
||||
label: 'Lizenzverteilung',
|
||||
data: data.map(d => d.active_count),
|
||||
backgroundColor: [
|
||||
'rgba(255, 99, 132, 0.6)',
|
||||
'rgba(54, 162, 235, 0.6)',
|
||||
'rgba(255, 206, 86, 0.6)',
|
||||
'rgba(75, 192, 192, 0.6)',
|
||||
'rgba(153, 102, 255, 0.6)'
|
||||
],
|
||||
borderWidth: 1
|
||||
}]
|
||||
};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update revenue table
|
||||
function updateRevenueTable(data) {
|
||||
const tbody = document.querySelector('#revenue-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
data.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${row.license_type}</td>
|
||||
<td>${row.total_licenses}</td>
|
||||
<td>${row.total_licenses > 0 ? Math.round((row.active_revenue / row.total_revenue) * 100) : 0}%</td>
|
||||
<td>€${(row.total_revenue || 0).toFixed(2)}</td>
|
||||
<td>€${(row.active_revenue || 0).toFixed(2)}</td>
|
||||
<td>€${(row.inactive_revenue || 0).toFixed(2)}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// Update churn risk table
|
||||
function updateChurnRiskTable(data) {
|
||||
const tbody = document.querySelector('#churn-risk-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
data.filter(d => d.churn_risk !== 'low').slice(0, 10).forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
const riskClass = row.churn_risk === 'high' ? 'danger' : 'warning';
|
||||
tr.innerHTML = `
|
||||
<td>${row.customer_id}</td>
|
||||
<td>${row.total_licenses}</td>
|
||||
<td>${row.avg_days_since_activity ? Math.round(row.avg_days_since_activity) + ' Tage' : '-'}</td>
|
||||
<td><span class="badge bg-${riskClass}">${row.churn_risk}</span></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// Create usage patterns heatmap
|
||||
function createUsagePatternsHeatmap(data) {
|
||||
// Implementation for heatmap would go here
|
||||
// For now, just log the data
|
||||
console.log('Usage patterns data:', data);
|
||||
}
|
||||
|
||||
// Fallback to use existing database data
|
||||
function useFallbackData() {
|
||||
// Use the data passed from the server template
|
||||
const usageTrends = {{ usage_trends | tojson | safe }};
|
||||
const licenseMetrics = {{ license_metrics | tojson | safe }};
|
||||
const deviceDistribution = {{ device_distribution | tojson | safe }};
|
||||
const revenueAnalysis = {{ revenue_analysis | tojson | safe }};
|
||||
|
||||
// Create charts with fallback data
|
||||
if (usageTrends) {
|
||||
const ctx = document.getElementById('usageTrendsChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: usageTrends.map(d => d[0]),
|
||||
datasets: [{
|
||||
label: 'Aktive Lizenzen',
|
||||
data: usageTrends.map(d => d[1]),
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
tension: 0.1
|
||||
}]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetchAnalyticsData();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
241
v2_adminpanel/templates/license_anomalies.html
Normale Datei
241
v2_adminpanel/templates/license_anomalies.html
Normale Datei
@@ -0,0 +1,241 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}License Anomalien{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h3">Anomalie-Erkennung</h1>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Schweregrad</label>
|
||||
<select name="severity" class="form-select">
|
||||
<option value="">Alle</option>
|
||||
<option value="low" {% if request.args.get('severity') == 'low' %}selected{% endif %}>Niedrig</option>
|
||||
<option value="medium" {% if request.args.get('severity') == 'medium' %}selected{% endif %}>Mittel</option>
|
||||
<option value="high" {% if request.args.get('severity') == 'high' %}selected{% endif %}>Hoch</option>
|
||||
<option value="critical" {% if request.args.get('severity') == 'critical' %}selected{% endif %}>Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="resolved" class="form-select">
|
||||
<option value="false" {% if request.args.get('resolved', 'false') == 'false' %}selected{% endif %}>Ungelöst</option>
|
||||
<option value="true" {% if request.args.get('resolved') == 'true' %}selected{% endif %}>Gelöst</option>
|
||||
<option value="">Alle</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Anomalie-Typ</label>
|
||||
<select name="anomaly_type" class="form-select">
|
||||
<option value="">Alle</option>
|
||||
<option value="multiple_ips" {% if request.args.get('anomaly_type') == 'multiple_ips' %}selected{% endif %}>Multiple IPs</option>
|
||||
<option value="rapid_hardware_change" {% if request.args.get('anomaly_type') == 'rapid_hardware_change' %}selected{% endif %}>Schneller Hardware-Wechsel</option>
|
||||
<option value="suspicious_pattern" {% if request.args.get('anomaly_type') == 'suspicious_pattern' %}selected{% endif %}>Verdächtiges Muster</option>
|
||||
<option value="concurrent_use" {% if request.args.get('anomaly_type') == 'concurrent_use' %}selected{% endif %}>Gleichzeitige Nutzung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary">Filter anwenden</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-danger">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-danger">Kritisch</h5>
|
||||
<h2 class="mb-0">
|
||||
{% set critical_count = namespace(value=0) %}
|
||||
{% for stat in anomaly_stats if stat[1] == 'critical' %}
|
||||
{% set critical_count.value = critical_count.value + stat[2] %}
|
||||
{% endfor %}
|
||||
{{ critical_count.value }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-warning">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-warning">Hoch</h5>
|
||||
<h2 class="mb-0">
|
||||
{% set high_count = namespace(value=0) %}
|
||||
{% for stat in anomaly_stats if stat[1] == 'high' %}
|
||||
{% set high_count.value = high_count.value + stat[2] %}
|
||||
{% endfor %}
|
||||
{{ high_count.value }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-info">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-info">Mittel</h5>
|
||||
<h2 class="mb-0">
|
||||
{% set medium_count = namespace(value=0) %}
|
||||
{% for stat in anomaly_stats if stat[1] == 'medium' %}
|
||||
{% set medium_count.value = medium_count.value + stat[2] %}
|
||||
{% endfor %}
|
||||
{{ medium_count.value }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted">Niedrig</h5>
|
||||
<h2 class="mb-0">
|
||||
{% set low_count = namespace(value=0) %}
|
||||
{% for stat in anomaly_stats if stat[1] == 'low' %}
|
||||
{% set low_count.value = low_count.value + stat[2] %}
|
||||
{% endfor %}
|
||||
{{ low_count.value }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anomaly List -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Anomalien</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Lizenz</th>
|
||||
<th>Typ</th>
|
||||
<th>Schweregrad</th>
|
||||
<th>Details</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for anomaly in anomalies %}
|
||||
<tr class="{% if anomaly[6] == 'critical' %}table-danger{% elif anomaly[6] == 'high' %}table-warning{% endif %}">
|
||||
<td>{{ anomaly[3].strftime('%d.%m.%Y %H:%M') if anomaly[3] else '-' }}</td>
|
||||
<td>
|
||||
{% if anomaly[8] %}
|
||||
<small>{{ anomaly[8][:8] }}...</small><br>
|
||||
<span class="text-muted">{{ anomaly[9] }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">Unbekannt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ anomaly[5] }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if anomaly[6] == 'critical' %}danger{% elif anomaly[6] == 'high' %}warning{% elif anomaly[6] == 'medium' %}info{% else %}secondary{% endif %}">
|
||||
{{ anomaly[6] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-link" onclick="showDetails('{{ anomaly[7] }}')">
|
||||
Details anzeigen
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{% if anomaly[2] %}
|
||||
<span class="badge bg-success">Gelöst</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Ungelöst</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not anomaly[2] %}
|
||||
<button class="btn btn-sm btn-success" onclick="resolveAnomaly('{{ anomaly[0] }}')">
|
||||
Lösen
|
||||
</button>
|
||||
{% else %}
|
||||
<small class="text-muted">{{ anomaly[4].strftime('%d.%m %H:%M') if anomaly[4] else '' }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted">Keine Anomalien gefunden</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Modal -->
|
||||
<div class="modal fade" id="detailsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Anomalie Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre id="anomalyDetails"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolve Modal -->
|
||||
<div class="modal fade" id="resolveModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="resolveForm" method="post">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Anomalie lösen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Ergriffene Maßnahme</label>
|
||||
<textarea name="action_taken" class="form-control" rows="3" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-success">Als gelöst markieren</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function showDetails(detailsJson) {
|
||||
try {
|
||||
const details = JSON.parse(detailsJson);
|
||||
document.getElementById('anomalyDetails').textContent = JSON.stringify(details, null, 2);
|
||||
new bootstrap.Modal(document.getElementById('detailsModal')).show();
|
||||
} catch (e) {
|
||||
alert('Fehler beim Anzeigen der Details');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAnomaly(anomalyId) {
|
||||
const form = document.getElementById('resolveForm');
|
||||
form.action = `/lizenzserver/anomaly/${anomalyId}/resolve`;
|
||||
new bootstrap.Modal(document.getElementById('resolveModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
373
v2_adminpanel/templates/license_config.html
Normale Datei
373
v2_adminpanel/templates/license_config.html
Normale Datei
@@ -0,0 +1,373 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Administration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h3">Administration</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Forger Configuration -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">Account Forger Konfiguration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('admin.update_client_config') }}" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Aktuelle Version</label>
|
||||
<input type="text" class="form-control" name="current_version"
|
||||
value="{{ client_config[4] if client_config else '1.0.0' }}"
|
||||
pattern="^\d+\.\d+\.\d+$" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Minimum Version</label>
|
||||
<input type="text" class="form-control" name="minimum_version"
|
||||
value="{{ client_config[5] if client_config else '1.0.0' }}"
|
||||
pattern="^\d+\.\d+\.\d+$" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Aktive Sitzungen</h5>
|
||||
<div>
|
||||
<span class="badge bg-white text-dark" id="sessionCount">{{ active_sessions|length if active_sessions else 0 }}</span>
|
||||
<a href="{{ url_for('admin.license_sessions') }}" class="btn btn-sm btn-light ms-2">Alle anzeigen</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Version</th>
|
||||
<th>Letztes Heartbeat</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessionTableBody">
|
||||
{% if active_sessions %}
|
||||
{% for session in active_sessions[:5] %}
|
||||
<tr>
|
||||
<td>{{ session[3] or 'Unbekannt' }}</td>
|
||||
<td>{{ session[6] }}</td>
|
||||
<td>{{ session[8].strftime('%H:%M:%S') }}</td>
|
||||
<td>
|
||||
{% if session[9] < 90 %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Timeout</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted">Keine aktiven Sitzungen</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System API Key Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h5 class="mb-0"><i class="bi bi-key"></i> API Key für Account Forger</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if system_api_key %}
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="bi bi-info-circle"></i> Dies ist der einzige API Key, den Account Forger benötigt.
|
||||
Verwenden Sie diesen Key im Header <code>X-API-Key</code> für alle API-Anfragen.
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-bold">Aktueller API Key:</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="systemApiKey"
|
||||
value="{{ system_api_key.api_key }}" readonly>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copySystemApiKey()">
|
||||
<i class="bi bi-clipboard"></i> Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Key Informationen:</h6>
|
||||
<ul class="list-unstyled small">
|
||||
<li><strong>Erstellt:</strong>
|
||||
{% if system_api_key.created_at %}
|
||||
{{ system_api_key.created_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</li>
|
||||
<li><strong>Erstellt von:</strong> {{ system_api_key.created_by or 'System' }}</li>
|
||||
{% if system_api_key.regenerated_at %}
|
||||
<li><strong>Zuletzt regeneriert:</strong>
|
||||
{{ system_api_key.regenerated_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
</li>
|
||||
<li><strong>Regeneriert von:</strong> {{ system_api_key.regenerated_by }}</li>
|
||||
{% else %}
|
||||
<li><strong>Zuletzt regeneriert:</strong> Nie</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Nutzungsstatistiken:</h6>
|
||||
<ul class="list-unstyled small">
|
||||
<li><strong>Letzte Nutzung:</strong>
|
||||
{% if system_api_key.last_used_at %}
|
||||
{{ system_api_key.last_used_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
{% else %}
|
||||
Noch nie genutzt
|
||||
{% endif %}
|
||||
</li>
|
||||
<li><strong>Gesamte Anfragen:</strong> {{ system_api_key.usage_count or 0 }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<form action="{{ url_for('admin.regenerate_api_key') }}" method="POST"
|
||||
onsubmit="return confirmRegenerate()">
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-arrow-clockwise"></i> API Key regenerieren
|
||||
</button>
|
||||
<span class="text-muted ms-2">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
Dies wird den aktuellen Key ungültig machen!
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<details>
|
||||
<summary class="text-primary" style="cursor: pointer;">Verwendungsbeispiel anzeigen</summary>
|
||||
<div class="mt-2">
|
||||
<pre class="bg-light p-3"><code>import requests
|
||||
|
||||
headers = {
|
||||
"X-API-Key": "{{ system_api_key.api_key }}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
"{{ request.url_root }}api/license/verify",
|
||||
headers=headers,
|
||||
json={"license_key": "YOUR_LICENSE_KEY"}
|
||||
)</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle"></i> Kein System API Key gefunden!
|
||||
Bitte kontaktieren Sie den Administrator.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technical Settings (collapsible) -->
|
||||
<div class="accordion mb-4" id="technicalSettings">
|
||||
<!-- Feature Flags -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#featureFlags">
|
||||
Feature Flags
|
||||
</button>
|
||||
</h2>
|
||||
<div id="featureFlags" class="accordion-collapse collapse" data-bs-parent="#technicalSettings">
|
||||
<div class="accordion-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for flag in feature_flags %}
|
||||
<tr>
|
||||
<td><strong>{{ flag[1] }}</strong></td>
|
||||
<td><small>{{ flag[2] }}</small></td>
|
||||
<td>
|
||||
{% if flag[3] %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inaktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('admin.toggle_feature_flag', flag_id=flag[0]) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm {% if flag[3] %}btn-danger{% else %}btn-success{% endif %}">
|
||||
{% if flag[3] %}Deaktivieren{% else %}Aktivieren{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted">Keine Feature Flags konfiguriert</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limits -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#rateLimits">
|
||||
Rate Limits
|
||||
</button>
|
||||
</h2>
|
||||
<div id="rateLimits" class="accordion-collapse collapse" data-bs-parent="#technicalSettings">
|
||||
<div class="accordion-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>API Key</th>
|
||||
<th>Requests/Minute</th>
|
||||
<th>Requests/Stunde</th>
|
||||
<th>Requests/Tag</th>
|
||||
<th>Burst Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for limit in rate_limits %}
|
||||
<tr>
|
||||
<td><code>{{ limit[1][:12] }}...</code></td>
|
||||
<td>{{ limit[2] }}</td>
|
||||
<td>{{ limit[3] }}</td>
|
||||
<td>{{ limit[4] }}</td>
|
||||
<td>{{ limit[5] }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">Keine Rate Limits konfiguriert</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
// Show success message instead of alert
|
||||
const button = event.target.closest('button');
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<i class="bi bi-check"></i> Kopiert!';
|
||||
button.classList.remove('btn-outline-secondary');
|
||||
button.classList.add('btn-success');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.classList.remove('btn-success');
|
||||
button.classList.add('btn-outline-secondary');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function copySystemApiKey() {
|
||||
const apiKeyInput = document.getElementById('systemApiKey');
|
||||
apiKeyInput.select();
|
||||
apiKeyInput.setSelectionRange(0, 99999);
|
||||
|
||||
navigator.clipboard.writeText(apiKeyInput.value).then(function() {
|
||||
const button = event.currentTarget;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="bi bi-check"></i> Kopiert!';
|
||||
button.classList.remove('btn-outline-secondary');
|
||||
button.classList.add('btn-success');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('btn-success');
|
||||
button.classList.add('btn-outline-secondary');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmRegenerate() {
|
||||
return confirm('Sind Sie sicher, dass Sie den API Key regenerieren möchten?\n\n' +
|
||||
'Dies wird den aktuellen Key ungültig machen und alle bestehenden ' +
|
||||
'Integrationen müssen mit dem neuen Key aktualisiert werden.');
|
||||
}
|
||||
|
||||
// Auto-refresh sessions every 30 seconds
|
||||
function refreshSessions() {
|
||||
fetch('{{ url_for("admin.license_live_stats") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('sessionCount').textContent = data.active_licenses || 0;
|
||||
|
||||
// Update session table
|
||||
const tbody = document.getElementById('sessionTableBody');
|
||||
if (data.latest_sessions && data.latest_sessions.length > 0) {
|
||||
tbody.innerHTML = data.latest_sessions.map(session => `
|
||||
<tr>
|
||||
<td>${session.customer_name || 'Unbekannt'}</td>
|
||||
<td>${session.version}</td>
|
||||
<td>${session.last_heartbeat}</td>
|
||||
<td>
|
||||
${session.seconds_since < 90
|
||||
? '<span class="badge bg-success">Aktiv</span>'
|
||||
: '<span class="badge bg-warning">Timeout</span>'}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">Keine aktiven Sitzungen</td></tr>';
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error refreshing sessions:', error));
|
||||
}
|
||||
|
||||
// Refresh sessions every 30 seconds
|
||||
setInterval(refreshSessions, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
151
v2_adminpanel/templates/license_sessions.html
Normale Datei
151
v2_adminpanel/templates/license_sessions.html
Normale Datei
@@ -0,0 +1,151 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Aktive Lizenzsitzungen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<h1>Lizenzsitzungen</h1>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Aktive Sitzungen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if active_sessions %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Hardware ID</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Version</th>
|
||||
<th>Gestartet</th>
|
||||
<th>Letztes Heartbeat</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in active_sessions %}
|
||||
<tr>
|
||||
<td><code>{{ session[2][:8] }}...</code></td>
|
||||
<td>{{ session[3] or 'Unbekannt' }}</td>
|
||||
<td><code>{{ session[4][:12] }}...</code></td>
|
||||
<td>{{ session[5] or 'Unbekannt' }}</td>
|
||||
<td>{{ session[6] }}</td>
|
||||
<td>{{ session[7].strftime('%H:%M:%S') }}</td>
|
||||
<td>{{ session[8].strftime('%H:%M:%S') }}</td>
|
||||
<td>
|
||||
{% if session[9] < 90 %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% elif session[9] < 120 %}
|
||||
<span class="badge bg-warning">Timeout bald</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Timeout</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if session.get('username') in ['rac00n', 'w@rh@mm3r'] %}
|
||||
<form method="POST" action="{{ url_for('admin.terminate_session', session_id=session[0]) }}"
|
||||
style="display: inline;" onsubmit="return confirm('Sitzung wirklich beenden?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Beenden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Keine aktiven Sitzungen vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Sitzungsverlauf (letzte 24 Stunden)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if session_history %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Hardware ID</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Version</th>
|
||||
<th>Gestartet</th>
|
||||
<th>Beendet</th>
|
||||
<th>Dauer</th>
|
||||
<th>Grund</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for hist in session_history %}
|
||||
<tr>
|
||||
<td><code>{{ hist[1][:8] }}...</code></td>
|
||||
<td>{{ hist[2] or 'Unbekannt' }}</td>
|
||||
<td><code>{{ hist[3][:12] }}...</code></td>
|
||||
<td>{{ hist[4] or 'Unbekannt' }}</td>
|
||||
<td>{{ hist[5] }}</td>
|
||||
<td>{{ hist[6].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ hist[7].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>
|
||||
{% set duration = hist[9] %}
|
||||
{% if duration < 60 %}
|
||||
{{ duration|int }}s
|
||||
{% elif duration < 3600 %}
|
||||
{{ (duration / 60)|int }}m
|
||||
{% else %}
|
||||
{{ (duration / 3600)|round(1) }}h
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if hist[8] == 'normal' %}
|
||||
<span class="badge bg-success">Normal</span>
|
||||
{% elif hist[8] == 'timeout' %}
|
||||
<span class="badge bg-warning">Timeout</span>
|
||||
{% elif hist[8] == 'forced' %}
|
||||
<span class="badge bg-danger">Erzwungen</span>
|
||||
{% elif hist[8] == 'replaced' %}
|
||||
<span class="badge bg-info">Ersetzt</span>
|
||||
{% else %}
|
||||
{{ hist[8] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Keine Sitzungen in den letzten 24 Stunden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('admin.license_config') }}" class="btn btn-secondary">Zurück zur Konfiguration</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-refresh every 30 seconds
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
455
v2_adminpanel/templates/licenses.html
Normale Datei
455
v2_adminpanel/templates/licenses.html
Normale Datei
@@ -0,0 +1,455 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Lizenzübersicht{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% set base_url = url_for('licenses.licenses') %}
|
||||
{% set params = [] %}
|
||||
{% if search %}{% set _ = params.append('search=' + search|urlencode) %}{% endif %}
|
||||
{% if request.args.get('data_source') %}{% set _ = params.append('data_source=' + request.args.get('data_source')|urlencode) %}{% endif %}
|
||||
{% if request.args.get('license_type') %}{% set _ = params.append('license_type=' + request.args.get('license_type')|urlencode) %}{% endif %}
|
||||
{% if request.args.get('license_status') %}{% set _ = params.append('license_status=' + request.args.get('license_status')|urlencode) %}{% endif %}
|
||||
{% set _ = params.append('sort=' + field) %}
|
||||
{% if current_sort == field %}
|
||||
{% set _ = params.append('order=' + ('desc' if current_order == 'asc' else 'asc')) %}
|
||||
{% else %}
|
||||
{% set _ = params.append('order=asc') %}
|
||||
{% endif %}
|
||||
{% set _ = params.append('page=1') %}
|
||||
|
||||
<a href="{{ base_url }}?{{ params|join('&') }}" class="server-sortable">
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.filter-group {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.filter-group h6 {
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.bg-info {
|
||||
background-color: #0dcaf0 !important;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.active-filters .badge {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.35em 0.65em;
|
||||
}
|
||||
|
||||
.active-filters a {
|
||||
text-decoration: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.active-filters a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#advancedFilters {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="mb-4">
|
||||
<h2>Lizenzübersicht</h2>
|
||||
</div>
|
||||
|
||||
<!-- Such- und Filterformular -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('licenses.licenses') }}" id="filterForm">
|
||||
<!-- Filter Controls -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3">
|
||||
<label for="dataSource" class="form-label">Datenquelle</label>
|
||||
<select class="form-select" name="data_source" id="dataSource" onchange="this.form.submit()">
|
||||
<option value="real" {% if request.args.get('data_source', 'real') == 'real' %}selected{% endif %}>Echte Lizenzen</option>
|
||||
<option value="fake" {% if request.args.get('data_source') == 'fake' %}selected{% endif %}>🧪 Fake-Daten</option>
|
||||
<option value="all" {% if request.args.get('data_source') == 'all' %}selected{% endif %}>Alle Daten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||
<select class="form-select" name="license_type" id="licenseType" onchange="this.form.submit()">
|
||||
<option value="" {% if not request.args.get('license_type') %}selected{% endif %}>Alle Typen</option>
|
||||
<option value="full" {% if request.args.get('license_type') == 'full' %}selected{% endif %}>Vollversion</option>
|
||||
<option value="test" {% if request.args.get('license_type') == 'test' %}selected{% endif %}>Testversion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="licenseStatus" class="form-label">Status</label>
|
||||
<select class="form-select" name="license_status" id="licenseStatus" onchange="this.form.submit()">
|
||||
<option value="" {% if not request.args.get('license_status') %}selected{% endif %}>Alle Status</option>
|
||||
<option value="active" {% if request.args.get('license_status') == 'active' %}selected{% endif %}>✅ Aktiv</option>
|
||||
<option value="expired" {% if request.args.get('license_status') == 'expired' %}selected{% endif %}>⚠️ Abgelaufen</option>
|
||||
<option value="inactive" {% if request.args.get('license_status') == 'inactive' %}selected{% endif %}>❌ Deaktiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<a href="{{ url_for('licenses.licenses') }}" class="btn btn-secondary w-100">
|
||||
<i class="bi bi-arrow-clockwise"></i> Filter zurücksetzen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Field -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-9">
|
||||
<label for="search" class="form-label">🔍 Suchen</label>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
placeholder="Lizenzschlüssel, Kunde, E-Mail..."
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-search"></i> Suchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden fields for sorting and pagination -->
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<input type="hidden" name="order" value="{{ order }}">
|
||||
<!-- Note: Other filters are preserved by the form elements themselves -->
|
||||
</form>
|
||||
{% if search or request.args.get('data_source') != 'real' or request.args.get('license_type') or request.args.get('license_status') %}
|
||||
<div class="mt-2">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<small class="text-muted">
|
||||
Gefiltert: {{ total }} Ergebnisse
|
||||
</small>
|
||||
<div class="active-filters">
|
||||
{% if search %}
|
||||
<span class="badge bg-secondary me-1">
|
||||
<i class="bi bi-search"></i> {{ search }}
|
||||
{% set clear_search_params = [] %}
|
||||
{% for type in filter_types %}{% set _ = clear_search_params.append('types[]=' + type|urlencode) %}{% endfor %}
|
||||
{% for status in filter_statuses %}{% set _ = clear_search_params.append('statuses[]=' + status|urlencode) %}{% endfor %}
|
||||
{% if show_fake %}{% set _ = clear_search_params.append('show_fake=1') %}{% endif %}
|
||||
{% set _ = clear_search_params.append('sort=' + sort) %}
|
||||
{% set _ = clear_search_params.append('order=' + order) %}
|
||||
<a href="{{ url_for('licenses.licenses') }}?{{ clear_search_params|join('&') }}" class="text-white ms-1">×</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 %}
|
||||
125
v2_adminpanel/templates/login.html
Normale Datei
125
v2_adminpanel/templates/login.html
Normale Datei
@@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Login - Lizenzverwaltung</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.error-failed {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 1.5rem !important;
|
||||
text-align: center !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
text-transform: uppercase !important;
|
||||
animation: shake 0.5s;
|
||||
box-shadow: 0 0 20px rgba(220, 53, 69, 0.5);
|
||||
}
|
||||
|
||||
.error-blocked {
|
||||
background-color: #6f42c1 !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 1.2rem !important;
|
||||
text-align: center !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.error-captcha {
|
||||
background-color: #fd7e14 !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 1.2rem !important;
|
||||
text-align: center !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(10px); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.attempts-warning {
|
||||
background-color: #ffc107;
|
||||
color: #000;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.security-info {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container">
|
||||
<div class="row min-vh-100 align-items-center justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="text-center mb-4">🔐 Admin Login</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="error-{{ error_type|default('failed') }}">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if attempts_left is defined and attempts_left > 0 and attempts_left < 5 %}
|
||||
<div class="attempts-warning">
|
||||
⚠️ Noch {{ attempts_left }} Versuch(e) bis zur IP-Sperre!
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Benutzername</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Passwort</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
{% if show_captcha and recaptcha_site_key %}
|
||||
<div class="mb-3">
|
||||
<div class="g-recaptcha" data-sitekey="{{ recaptcha_site_key }}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<div class="security-info">
|
||||
🛡️ Geschützt durch Rate-Limiting und IP-Sperre
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_captcha and recaptcha_site_key %}
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
322
v2_adminpanel/templates/monitoring/alerts.html
Normale Datei
322
v2_adminpanel/templates/monitoring/alerts.html
Normale Datei
@@ -0,0 +1,322 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Alerts{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.alert-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
border-left: 5px solid #dee2e6;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.alert-critical {
|
||||
border-left-color: #dc3545;
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
|
||||
.alert-high {
|
||||
border-left-color: #fd7e14;
|
||||
background-color: #ffe5d1;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
border-left-color: #ffc107;
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
border-left-color: #17a2b8;
|
||||
background-color: #d1ecf1;
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 12px;
|
||||
border-radius: 15px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.severity-critical {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.severity-high {
|
||||
background-color: #fd7e14;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.severity-medium {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.severity-low {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-timestamp {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.alert-details {
|
||||
background: rgba(0,0,0,0.05);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.alert-stats {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.filter-pills {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-pill.active {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-exclamation-triangle"></i> Alerts</h2>
|
||||
<p class="text-muted">Aktive Warnungen und Anomalien</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-primary" onclick="location.reload()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Statistics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="alert-stats">
|
||||
<div class="stat-number text-danger">{{ alerts|selectattr('severity', 'equalto', 'critical')|list|length }}</div>
|
||||
<div class="text-muted">Kritisch</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="alert-stats">
|
||||
<div class="stat-number text-warning">{{ alerts|selectattr('severity', 'equalto', 'high')|list|length }}</div>
|
||||
<div class="text-muted">Hoch</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="alert-stats">
|
||||
<div class="stat-number text-info">{{ alerts|selectattr('severity', 'equalto', 'medium')|list|length }}</div>
|
||||
<div class="text-muted">Mittel</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="alert-stats">
|
||||
<div class="stat-number text-success">{{ alerts|selectattr('severity', 'equalto', 'low')|list|length }}</div>
|
||||
<div class="text-muted">Niedrig</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Pills -->
|
||||
<div class="filter-pills">
|
||||
<span class="badge bg-secondary filter-pill active me-2" onclick="filterAlerts('all')">
|
||||
Alle ({{ alerts|length }})
|
||||
</span>
|
||||
<span class="badge bg-danger filter-pill me-2" onclick="filterAlerts('critical')">
|
||||
Kritisch
|
||||
</span>
|
||||
<span class="badge bg-warning filter-pill me-2" onclick="filterAlerts('high')">
|
||||
Hoch
|
||||
</span>
|
||||
<span class="badge bg-info filter-pill me-2" onclick="filterAlerts('medium')">
|
||||
Mittel
|
||||
</span>
|
||||
<span class="badge bg-success filter-pill me-2" onclick="filterAlerts('low')">
|
||||
Niedrig
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Alerts List -->
|
||||
<div id="alerts-container">
|
||||
{% for alert in alerts %}
|
||||
<div class="alert-card alert-{{ alert.severity }}" data-severity="{{ alert.severity }}">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h5 class="mb-0 me-3">
|
||||
{% if alert.anomaly_type == 'multiple_ips' %}
|
||||
<i class="bi bi-geo-alt-fill"></i> Mehrere IP-Adressen erkannt
|
||||
{% elif alert.anomaly_type == 'rapid_hardware_change' %}
|
||||
<i class="bi bi-laptop"></i> Schneller Hardware-Wechsel
|
||||
{% elif alert.anomaly_type == 'suspicious_pattern' %}
|
||||
<i class="bi bi-shield-exclamation"></i> Verdächtiges Muster
|
||||
{% else %}
|
||||
<i class="bi bi-exclamation-circle"></i> {{ alert.anomaly_type }}
|
||||
{% endif %}
|
||||
</h5>
|
||||
<span class="severity-badge severity-{{ alert.severity }}">
|
||||
{{ alert.severity }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if alert.company_name %}
|
||||
<div class="mb-2">
|
||||
<strong>Kunde:</strong> {{ alert.company_name }}
|
||||
{% if alert.license_key %}
|
||||
<span class="text-muted">({{ alert.license_key[:8] }}...)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert-timestamp">
|
||||
<i class="bi bi-clock"></i> {{ alert.detected_at|default(alert.startsAt) }}
|
||||
</div>
|
||||
|
||||
{% if alert.details %}
|
||||
<div class="alert-details">
|
||||
<strong>Details:</strong><br>
|
||||
{{ alert.details }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="alert-actions">
|
||||
{% if not alert.resolved %}
|
||||
<button class="btn btn-sm btn-success w-100 mb-2" onclick="resolveAlert('{{ alert.id }}')">
|
||||
<i class="bi bi-check-circle"></i> Als gelöst markieren
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning w-100 mb-2" onclick="investigateAlert('{{ alert.id }}')">
|
||||
<i class="bi bi-search"></i> Untersuchen
|
||||
</button>
|
||||
{% if alert.severity in ['critical', 'high'] %}
|
||||
<button class="btn btn-sm btn-danger w-100" onclick="blockLicense('{{ alert.license_id }}')">
|
||||
<i class="bi bi-shield-lock"></i> Lizenz blockieren
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-success text-center">
|
||||
<i class="bi bi-check-circle-fill"></i> Gelöst
|
||||
{% if alert.resolved_at %}
|
||||
<div class="small">{{ alert.resolved_at }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-shield-check" style="font-size: 4rem; color: #28a745;"></i>
|
||||
<h4 class="mt-3">Keine aktiven Alerts</h4>
|
||||
<p class="text-muted">Alle Systeme laufen normal</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Filter alerts by severity
|
||||
function filterAlerts(severity) {
|
||||
// Update active pill
|
||||
document.querySelectorAll('.filter-pill').forEach(pill => {
|
||||
pill.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Filter alert cards
|
||||
document.querySelectorAll('.alert-card').forEach(card => {
|
||||
if (severity === 'all' || card.dataset.severity === severity) {
|
||||
card.style.display = 'block';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve alert
|
||||
async function resolveAlert(alertId) {
|
||||
if (!confirm('Möchten Sie diesen Alert als gelöst markieren?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/alerts/${alertId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Alert wurde als gelöst markiert');
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Markieren des Alerts');
|
||||
}
|
||||
}
|
||||
|
||||
// Investigate alert
|
||||
function investigateAlert(alertId) {
|
||||
// In production, this would open a detailed investigation view
|
||||
alert('Detaillierte Untersuchung wird geöffnet...');
|
||||
}
|
||||
|
||||
// Block license
|
||||
async function blockLicense(licenseId) {
|
||||
if (!confirm('WARNUNG: Möchten Sie diese Lizenz wirklich blockieren? Der Kunde kann die Software nicht mehr nutzen!')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/licenses/${licenseId}/block`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Lizenz wurde blockiert');
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Blockieren der Lizenz');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh alerts every 60 seconds
|
||||
setInterval(() => {
|
||||
location.reload();
|
||||
}, 60000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
453
v2_adminpanel/templates/monitoring/analytics.html
Normale Datei
453
v2_adminpanel/templates/monitoring/analytics.html
Normale Datei
@@ -0,0 +1,453 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Analytics & Lizenzserver Status{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Analytics Styles */
|
||||
.analytics-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.date-range-selector {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* License Monitor Styles */
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--status-active);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.validation-timeline {
|
||||
height: 300px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.anomaly-alert {
|
||||
padding: 1rem;
|
||||
border-left: 4px solid var(--status-danger);
|
||||
background: #fff5f5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.device-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="bi bi-bar-chart-line"></i> Analytics & Lizenzserver Status</h1>
|
||||
<div>
|
||||
<span class="live-indicator"></span>
|
||||
<span class="text-muted">Live-Daten</span>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-3" onclick="toggleAutoRefresh()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Auto-Refresh: <span id="refresh-status">AN</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Selector -->
|
||||
<div class="date-range-selector">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6">
|
||||
<label>Zeitraum auswählen:</label>
|
||||
<select class="form-select" id="date-range" onchange="updateAnalytics()">
|
||||
<option value="today">Heute</option>
|
||||
<option value="week" selected>Letzte 7 Tage</option>
|
||||
<option value="month">Letzte 30 Tage</option>
|
||||
<option value="quarter">Letztes Quartal</option>
|
||||
<option value="year">Letztes Jahr</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<button class="btn btn-outline-primary" onclick="refreshAnalytics()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="active-licenses">
|
||||
{{ live_stats[0] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Aktive Lizenzen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="total-validations">
|
||||
{{ live_stats[1] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Validierungen (5 Min)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="unique-devices">
|
||||
{{ live_stats[2] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Aktive Geräte</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="unique-ips">
|
||||
{{ live_stats[3] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Unique IPs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Validation Timeline -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Validierungen pro Minute</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="validationChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Anomalies -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Aktuelle Anomalien</h5>
|
||||
<a href="{{ url_for('admin.license_anomalies') }}" class="btn btn-sm btn-outline-primary">
|
||||
Alle anzeigen
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
||||
{% if recent_anomalies %}
|
||||
{% for anomaly in recent_anomalies %}
|
||||
<div class="anomaly-alert">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="badge badge-{{ 'danger' if anomaly['severity'] == 'critical' else anomaly['severity'] }}">
|
||||
{{ anomaly['severity'].upper() }}
|
||||
</span>
|
||||
<small class="text-muted">{{ anomaly['detected_at'].strftime('%H:%M') }}</small>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<strong>{{ anomaly['anomaly_type'].replace('_', ' ').title() }}</strong><br>
|
||||
<small>Lizenz: {{ anomaly['license_key'][:8] }}...</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted text-center">Keine aktiven Anomalien</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Active Licenses -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Top Aktive Lizenzen (letzte 15 Min)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Geräte</th>
|
||||
<th>Validierungen</th>
|
||||
<th>Zuletzt gesehen</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-licenses-tbody">
|
||||
{% for license in top_licenses %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ license['license_key'][:12] }}...</code>
|
||||
</td>
|
||||
<td>{{ license['customer_name'] }}</td>
|
||||
<td>
|
||||
<span class="device-badge">
|
||||
<i class="bi bi-laptop"></i> {{ license['device_count'] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ license['validation_count'] }}</td>
|
||||
<td>{{ license['last_seen'].strftime('%H:%M:%S') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latest Validations Stream -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Letzte Validierungen (Live-Stream)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="validation-stream" style="max-height: 300px; overflow-y: auto;">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Options -->
|
||||
<div class="analytics-card">
|
||||
<h5>Berichte exportieren</h5>
|
||||
<div class="export-buttons">
|
||||
<button class="btn btn-outline-success me-2" onclick="exportReport('excel')">
|
||||
<i class="bi bi-file-excel"></i> Excel Export
|
||||
</button>
|
||||
<button class="btn btn-outline-info" onclick="exportReport('csv')">
|
||||
<i class="bi bi-file-text"></i> CSV Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
let autoRefresh = true;
|
||||
let refreshInterval;
|
||||
let validationChart;
|
||||
|
||||
// Initialize validation chart
|
||||
const ctx = document.getElementById('validationChart').getContext('2d');
|
||||
validationChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Validierungen',
|
||||
data: [],
|
||||
borderColor: 'rgb(40, 167, 69)',
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||
tension: 0.1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update chart with validation rates
|
||||
{% if validation_rates %}
|
||||
const rates = {{ validation_rates|tojson }};
|
||||
validationChart.data.labels = rates.map(r => new Date(r[0]).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})).reverse();
|
||||
validationChart.data.datasets[0].data = rates.map(r => r[1]).reverse();
|
||||
validationChart.update();
|
||||
{% endif %}
|
||||
|
||||
function loadAnalyticsData() {
|
||||
// Load basic statistics from database
|
||||
fetch('/monitoring/api/live-stats')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('active-licenses').textContent = data.active_licenses || '0';
|
||||
document.getElementById('total-validations').textContent = data.validations_last_minute || '0';
|
||||
document.getElementById('unique-devices').textContent = data.active_devices || '0';
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading analytics:', error);
|
||||
document.getElementById('active-licenses').textContent = '0';
|
||||
document.getElementById('total-validations').textContent = '0';
|
||||
document.getElementById('unique-devices').textContent = '0';
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch live statistics
|
||||
function fetchLiveStats() {
|
||||
fetch('{{ url_for("admin.license_live_stats") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update statistics
|
||||
document.getElementById('active-licenses').textContent = data.active_licenses;
|
||||
document.getElementById('total-validations').textContent = data.validations_per_minute;
|
||||
document.getElementById('unique-devices').textContent = data.active_devices;
|
||||
|
||||
// Update validation stream
|
||||
const stream = document.getElementById('validation-stream');
|
||||
const newEntries = data.latest_validations.map(v =>
|
||||
`<div class="d-flex justify-content-between border-bottom py-2">
|
||||
<span>
|
||||
<code>${v.license_key}</code> |
|
||||
<span class="text-muted">${v.hardware_id}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-secondary">${v.ip_address}</span>
|
||||
<span class="text-muted ms-2">${v.timestamp}</span>
|
||||
</span>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
if (newEntries) {
|
||||
stream.innerHTML = newEntries + stream.innerHTML;
|
||||
// Keep only last 20 entries
|
||||
const entries = stream.querySelectorAll('div');
|
||||
if (entries.length > 20) {
|
||||
for (let i = 20; i < entries.length; i++) {
|
||||
entries[i].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching live stats:', error));
|
||||
}
|
||||
|
||||
// Toggle auto-refresh
|
||||
function toggleAutoRefresh() {
|
||||
autoRefresh = !autoRefresh;
|
||||
document.getElementById('refresh-status').textContent = autoRefresh ? 'AN' : 'AUS';
|
||||
|
||||
if (autoRefresh) {
|
||||
refreshInterval = setInterval(fetchLiveStats, 5000);
|
||||
} else {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAnalytics() {
|
||||
const range = document.getElementById('date-range').value;
|
||||
console.log('Updating analytics for range:', range);
|
||||
loadAnalyticsData();
|
||||
fetchLiveStats();
|
||||
}
|
||||
|
||||
function refreshAnalytics() {
|
||||
loadAnalyticsData();
|
||||
fetchLiveStats();
|
||||
}
|
||||
|
||||
function exportReport(format) {
|
||||
// Redirect to export endpoint with format parameter
|
||||
const hours = 24; // Default to 24 hours
|
||||
window.location.href = `/export/monitoring?format=${format}&hours=${hours}`;
|
||||
}
|
||||
|
||||
// Start auto-refresh
|
||||
if (autoRefresh) {
|
||||
refreshInterval = setInterval(fetchLiveStats, 5000);
|
||||
}
|
||||
|
||||
// Load data on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadAnalyticsData();
|
||||
fetchLiveStats();
|
||||
// Refresh every 30 seconds
|
||||
setInterval(loadAnalyticsData, 30000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
698
v2_adminpanel/templates/monitoring/live_dashboard.html
Normale Datei
698
v2_adminpanel/templates/monitoring/live_dashboard.html
Normale Datei
@@ -0,0 +1,698 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Live Dashboard & Analytics{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Combined styles from both dashboards */
|
||||
.stats-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.2s;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.stats-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
border-left: 4px solid #28a745;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.session-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.activity-indicator {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.activity-active {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.activity-recent {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
|
||||
.activity-inactive {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.geo-info {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
background: #f8f9fa;
|
||||
padding: 5px 10px;
|
||||
border-radius: 15px;
|
||||
font-size: 0.85rem;
|
||||
display: inline-block;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
/* Analytics specific styles */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--status-active);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.anomaly-alert {
|
||||
padding: 1rem;
|
||||
border-left: 4px solid var(--status-danger);
|
||||
background: #fff5f5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.device-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
color: #495057;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: var(--bs-primary);
|
||||
background: none;
|
||||
border-bottom-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-activity"></i> Live Dashboard & Analytics</h2>
|
||||
<p class="text-muted">Echtzeit-Übersicht und Analyse der Lizenznutzung</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="live-indicator"></span>
|
||||
<span class="text-muted">Live-Daten</span>
|
||||
<span class="text-muted ms-3">Auto-Refresh: <span id="refresh-countdown">30</span>s</span>
|
||||
<button class="btn btn-sm btn-outline-primary ms-2" onclick="refreshData()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Jetzt aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-people-fill text-primary" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-primary" id="active-licenses">{{ live_stats[0] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Aktive Lizenzen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-shield-check text-success" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-success" id="total-validations">{{ live_stats[1] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Validierungen (5 Min)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-laptop text-info" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-info" id="unique-devices">{{ live_stats[2] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Aktive Geräte</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-globe text-warning" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-warning" id="unique-ips">{{ live_stats[3] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Unique IPs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabbed Interface -->
|
||||
<ul class="nav nav-tabs" id="dashboardTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab">
|
||||
<i class="bi bi-speedometer2"></i> Übersicht
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="sessions-tab" data-bs-toggle="tab" data-bs-target="#sessions" type="button" role="tab">
|
||||
<i class="bi bi-people"></i> Aktive Sessions
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="analytics-tab" data-bs-toggle="tab" data-bs-target="#analytics" type="button" role="tab">
|
||||
<i class="bi bi-bar-chart-line"></i> Analytics
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="dashboardTabContent">
|
||||
<!-- Overview Tab -->
|
||||
<div class="tab-pane fade show active" id="overview" role="tabpanel">
|
||||
<div class="row">
|
||||
<!-- Activity Timeline Chart -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-graph-up"></i> Aktivität (letzte 60 Minuten)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="activityChart" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Anomalies -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Aktuelle Anomalien</h5>
|
||||
<a href="{{ url_for('admin.license_anomalies') }}" class="btn btn-sm btn-outline-primary">
|
||||
Alle anzeigen
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
||||
{% if recent_anomalies %}
|
||||
{% for anomaly in recent_anomalies %}
|
||||
<div class="anomaly-alert">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="badge bg-{{ 'danger' if anomaly['severity'] == 'critical' else anomaly['severity'] }}">
|
||||
{{ anomaly['severity'].upper() }}
|
||||
</span>
|
||||
<small class="text-muted">{{ anomaly['detected_at'].strftime('%H:%M') }}</small>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<strong>{{ anomaly['anomaly_type'].replace('_', ' ').title() }}</strong><br>
|
||||
<small>Lizenz: {{ anomaly['license_key'][:8] }}...</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted text-center">Keine aktiven Anomalien</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Active Licenses -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Top Aktive Lizenzen (letzte 15 Min)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Geräte</th>
|
||||
<th>Validierungen</th>
|
||||
<th>Zuletzt gesehen</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-licenses-tbody">
|
||||
{% for license in top_licenses %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ license['license_key'][:12] }}...</code>
|
||||
</td>
|
||||
<td>{{ license['customer_name'] }}</td>
|
||||
<td>
|
||||
<span class="device-badge">
|
||||
<i class="bi bi-laptop"></i> {{ license['device_count'] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ license['validation_count'] }}</td>
|
||||
<td>{{ license['last_seen'].strftime('%H:%M:%S') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions Tab -->
|
||||
<div class="tab-pane fade" id="sessions" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-people"></i> Aktive Kunden-Sessions (letzte 5 Minuten)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="active-sessions-container">
|
||||
{% for session in active_sessions %}
|
||||
<div class="session-card">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-4">
|
||||
<h6 class="mb-1">
|
||||
<span class="activity-indicator activity-active"></span>
|
||||
{{ session.company_name }}
|
||||
</h6>
|
||||
<small class="text-muted">{{ session.contact_person }}</small>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-1">
|
||||
<i class="bi bi-key"></i> {{ session.license_key[:8] }}...
|
||||
</div>
|
||||
<div class="device-info">
|
||||
<i class="bi bi-laptop"></i> {{ session.active_devices }} Gerät(e)
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="geo-info">
|
||||
<i class="bi bi-geo-alt"></i> {{ session.ip_address }}
|
||||
<div><small>Hardware: {{ session.hardware_id[:12] }}...</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 text-end">
|
||||
<div class="text-muted">
|
||||
<i class="bi bi-clock"></i>
|
||||
<span class="last-activity" data-timestamp="{{ session.last_activity }}">
|
||||
vor wenigen Sekunden
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
|
||||
<p>Keine aktiven Sessions in den letzten 5 Minuten</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latest Validations Stream -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Letzte Validierungen (Live-Stream)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="validation-stream" style="max-height: 300px; overflow-y: auto;">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Tab -->
|
||||
<div class="tab-pane fade" id="analytics" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Validierungen pro Minute (30 Min)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="validationChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Options -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Berichte exportieren</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label>Zeitraum auswählen:</label>
|
||||
<select class="form-select" id="date-range" onchange="updateAnalytics()">
|
||||
<option value="today">Heute</option>
|
||||
<option value="week" selected>Letzte 7 Tage</option>
|
||||
<option value="month">Letzte 30 Tage</option>
|
||||
<option value="quarter">Letztes Quartal</option>
|
||||
<option value="year">Letztes Jahr</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label> </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 %}
|
||||
609
v2_adminpanel/templates/monitoring/unified_monitoring.html
Normale Datei
609
v2_adminpanel/templates/monitoring/unified_monitoring.html
Normale Datei
@@ -0,0 +1,609 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Monitoring{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Header Status Bar */
|
||||
.status-header {
|
||||
background: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
padding: 0.75rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
margin: -1rem -1rem 1rem -1rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-indicator .badge {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Metric Cards */
|
||||
.metric-card {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-trend {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.metric-alert {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #dc3545;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Activity Stream */
|
||||
.activity-stream {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #f8f9fa;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.activity-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.activity-item.validation {
|
||||
border-left: 3px solid #28a745;
|
||||
}
|
||||
|
||||
.activity-item.anomaly-warning {
|
||||
border-left: 3px solid #ffc107;
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
|
||||
.activity-item.anomaly-critical {
|
||||
border-left: 3px solid #dc3545;
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
|
||||
/* Statistics Panel */
|
||||
.stats-panel {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.license-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #f8f9fa;
|
||||
}
|
||||
|
||||
.license-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.analysis-tabs .nav-link {
|
||||
color: #495057;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.analysis-tabs .nav-link.active {
|
||||
color: #0d6efd;
|
||||
border-bottom-color: #0d6efd;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Expandable sections */
|
||||
.expandable-section {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expandable-header {
|
||||
background: #f8f9fa;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expandable-header:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.expandable-content {
|
||||
padding: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.expandable-content.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Auto-refresh indicator */
|
||||
.refresh-indicator {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.refresh-indicator.active {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.metric-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.activity-stream {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid p-3">
|
||||
<!-- Status Header -->
|
||||
<div class="status-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="status-indicator">
|
||||
{% if system_status == 'normal' %}
|
||||
<span class="text-success">🟢 System Normal</span>
|
||||
{% elif system_status == 'warning' %}
|
||||
<span class="text-warning">🟡 System Warning</span>
|
||||
{% else %}
|
||||
<span class="text-danger">🔴 System Critical</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-{{ status_color }}">{{ active_alerts }} Aktive Alerts</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-muted">Letzte Aktualisierung: <span id="last-update">jetzt</span></span>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="auto-refresh" checked>
|
||||
<label class="form-check-label" for="auto-refresh">Auto-Refresh</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Executive Summary (Collapsible) -->
|
||||
<div class="expandable-section" id="executive-summary">
|
||||
<div class="expandable-header" onclick="toggleSection('executive-summary')">
|
||||
<h5 class="mb-0">📊 Executive Summary</h5>
|
||||
<i class="bi bi-chevron-down"></i>
|
||||
</div>
|
||||
<div class="expandable-content show">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Aktive Lizenzen</div>
|
||||
<div class="metric-value text-primary">{{ live_metrics.active_licenses or 0 }}</div>
|
||||
<div class="metric-trend text-success">
|
||||
<i class="bi bi-arrow-up"></i> <span class="trend-value">0%</span>
|
||||
</div>
|
||||
{% if live_metrics.active_licenses > 100 %}
|
||||
<div class="metric-alert">!</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Validierungen (5 Min)</div>
|
||||
<div class="metric-value text-info">{{ live_metrics.total_validations or 0 }}</div>
|
||||
<div class="metric-trend text-muted">
|
||||
<i class="bi bi-dash"></i> <span class="trend-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Aktive Geräte</div>
|
||||
<div class="metric-value text-success">{{ live_metrics.unique_devices or 0 }}</div>
|
||||
<div class="metric-trend text-success">
|
||||
<i class="bi bi-arrow-up"></i> <span class="trend-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Response Zeit</div>
|
||||
<div class="metric-value text-warning">{{ (live_metrics.avg_response_time or 0)|round(1) }}ms</div>
|
||||
<div class="metric-trend text-warning">
|
||||
<i class="bi bi-arrow-up"></i> <span class="trend-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<canvas id="trend-chart" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="row g-3">
|
||||
<!-- Activity Stream (Left Panel) -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">🔄 Activity Stream</h5>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary active" data-filter="all">Alle</button>
|
||||
<button type="button" class="btn btn-outline-success" data-filter="normal">Normal</button>
|
||||
<button type="button" class="btn btn-outline-warning" data-filter="warning">Warnungen</button>
|
||||
<button type="button" class="btn btn-outline-danger" data-filter="critical">Kritisch</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-stream">
|
||||
{% for event in activity_stream %}
|
||||
<div class="activity-item {{ event.event_type }} {% if event.event_type == 'anomaly' %}anomaly-{{ event.severity }}{% endif %}"
|
||||
data-severity="{{ event.severity or 'normal' }}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
{% if event.event_type == 'validation' %}
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-exclamation-triangle text-{{ 'warning' if event.severity == 'warning' else 'danger' }} me-2"></i>
|
||||
{% endif %}
|
||||
<strong>{{ event.customer_name or 'Unbekannt' }}</strong>
|
||||
<span class="text-muted ms-2">{{ event.license_key[:8] }}...</span>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
{% if event.event_type == 'anomaly' %}
|
||||
<span class="badge bg-{{ 'warning' if event.severity == 'warning' else 'danger' }} me-2">
|
||||
{{ event.anomaly_type }}
|
||||
</span>
|
||||
{{ event.description }}
|
||||
{% else %}
|
||||
Validierung von {{ event.ip_address }} • Gerät: {{ event.hardware_id[:8] }}...
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<small class="text-muted">{{ event.timestamp.strftime('%H:%M:%S') if event.timestamp else '-' }}</small>
|
||||
{% if event.event_type == 'anomaly' and event.severity == 'critical' %}
|
||||
<div class="mt-1">
|
||||
<button class="btn btn-sm btn-danger" onclick="blockIP('{{ event.ip_address }}')">
|
||||
<i class="bi bi-slash-circle"></i> Block
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not activity_stream %}
|
||||
<div class="text-center text-muted p-5">
|
||||
<i class="bi bi-inbox fs-1"></i>
|
||||
<p class="mt-2">Keine Aktivitäten in den letzten 60 Minuten</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Panel (Right Panel) -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Top Active Licenses -->
|
||||
<div class="stats-panel mb-3">
|
||||
<h6 class="mb-3">🏆 Top Aktive Lizenzen</h6>
|
||||
{% for license in top_licenses %}
|
||||
<div class="license-item">
|
||||
<div>
|
||||
<div class="fw-bold">{{ license.customer_name }}</div>
|
||||
<small class="text-muted">{{ license.device_count }} Geräte • {{ license.validation_count }} Validierungen</small>
|
||||
</div>
|
||||
{% if license.anomaly_count > 0 %}
|
||||
<span class="badge bg-warning">{{ license.anomaly_count }} ⚠️</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">OK</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not top_licenses %}
|
||||
<p class="text-muted text-center">Keine aktiven Lizenzen</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Anomaly Distribution -->
|
||||
<div class="stats-panel mb-3">
|
||||
<h6 class="mb-3">🎯 Anomalie-Verteilung</h6>
|
||||
<canvas id="anomaly-chart" height="200"></canvas>
|
||||
{% if not anomaly_distribution %}
|
||||
<p class="text-muted text-center">Keine Anomalien erkannt</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Geographic Distribution -->
|
||||
<div class="stats-panel">
|
||||
<h6 class="mb-3">🌍 Geografische Verteilung</h6>
|
||||
<div style="max-height: 200px; overflow-y: auto;">
|
||||
{% for geo in geo_data[:5] %}
|
||||
<div class="d-flex justify-content-between align-items-center py-1">
|
||||
<span class="text-truncate">{{ geo.ip_address }}</span>
|
||||
<span class="badge bg-secondary">{{ geo.request_count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if geo_data|length > 5 %}
|
||||
<small class="text-muted">+{{ geo_data|length - 5 }} weitere IPs</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analysis Tabs -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs analysis-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-bs-toggle="tab" href="#patterns">🔍 Patterns</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#performance">⚡ Performance</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#forensics">🔬 Forensics</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#predictions">📈 Predictions</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane active" id="patterns">
|
||||
<p class="text-muted">Ungewöhnliche Verhaltensmuster werden hier angezeigt...</p>
|
||||
</div>
|
||||
<div class="tab-pane" id="performance">
|
||||
<div class="chart-container">
|
||||
<canvas id="performance-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane" id="forensics">
|
||||
<p class="text-muted">Detaillierte Analyse spezifischer Lizenzen...</p>
|
||||
</div>
|
||||
<div class="tab-pane" id="predictions">
|
||||
<p class="text-muted">Vorhersagen und Kapazitätsplanung...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
// Auto-refresh functionality
|
||||
let refreshInterval;
|
||||
const AUTO_REFRESH_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
function startAutoRefresh() {
|
||||
if ($('#auto-refresh').is(':checked')) {
|
||||
refreshInterval = setInterval(() => {
|
||||
location.reload();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
$('#auto-refresh').change(function() {
|
||||
if (this.checked) {
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle expandable sections
|
||||
function toggleSection(sectionId) {
|
||||
const section = document.getElementById(sectionId);
|
||||
const content = section.querySelector('.expandable-content');
|
||||
const icon = section.querySelector('.bi');
|
||||
|
||||
content.classList.toggle('show');
|
||||
icon.classList.toggle('bi-chevron-down');
|
||||
icon.classList.toggle('bi-chevron-up');
|
||||
}
|
||||
|
||||
// Filter activity stream
|
||||
document.querySelectorAll('[data-filter]').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const filter = this.dataset.filter;
|
||||
document.querySelectorAll('[data-filter]').forEach(btn => btn.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
document.querySelectorAll('.activity-item').forEach(item => {
|
||||
if (filter === 'all') {
|
||||
item.style.display = 'block';
|
||||
} else {
|
||||
const severity = item.dataset.severity;
|
||||
item.style.display = severity === filter ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Block IP function
|
||||
function blockIP(ip) {
|
||||
if (confirm(`IP-Adresse ${ip} wirklich blockieren?`)) {
|
||||
// Implementation for blocking IP
|
||||
alert(`IP ${ip} wurde blockiert.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize charts
|
||||
const trendData = {{ trend_data|tojson }};
|
||||
const anomalyData = {{ anomaly_distribution|tojson }};
|
||||
const performanceData = {{ performance_data|tojson }};
|
||||
|
||||
// Trend Chart
|
||||
if (trendData && trendData.length > 0) {
|
||||
const trendCtx = document.getElementById('trend-chart').getContext('2d');
|
||||
new Chart(trendCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: trendData.map(d => new Date(d.hour).toLocaleTimeString('de-DE', {hour: '2-digit'})),
|
||||
datasets: [{
|
||||
label: 'Validierungen',
|
||||
data: trendData.map(d => d.validations),
|
||||
borderColor: '#0d6efd',
|
||||
backgroundColor: 'rgba(13, 110, 253, 0.1)',
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Anomaly Distribution Chart
|
||||
if (anomalyData && anomalyData.length > 0) {
|
||||
const anomalyCtx = document.getElementById('anomaly-chart').getContext('2d');
|
||||
new Chart(anomalyCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: anomalyData.map(d => d.anomaly_type),
|
||||
datasets: [{
|
||||
data: anomalyData.map(d => d.count),
|
||||
backgroundColor: ['#ffc107', '#dc3545', '#fd7e14', '#6f42c1']
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { boxWidth: 12 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Chart
|
||||
if (performanceData && performanceData.length > 0) {
|
||||
const perfCtx = document.getElementById('performance-chart').getContext('2d');
|
||||
new Chart(perfCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: performanceData.map(d => new Date(d.minute).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})),
|
||||
datasets: [{
|
||||
label: 'Avg Response Time',
|
||||
data: performanceData.map(d => d.avg_response_time),
|
||||
borderColor: '#28a745',
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||
tension: 0.4
|
||||
}, {
|
||||
label: 'Max Response Time',
|
||||
data: performanceData.map(d => d.max_response_time),
|
||||
borderColor: '#dc3545',
|
||||
backgroundColor: 'rgba(220, 53, 69, 0.1)',
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Response Time (ms)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update last refresh time
|
||||
function updateLastRefresh() {
|
||||
const now = new Date();
|
||||
document.getElementById('last-update').textContent = now.toLocaleTimeString('de-DE');
|
||||
}
|
||||
|
||||
// Start auto-refresh on load
|
||||
startAutoRefresh();
|
||||
updateLastRefresh();
|
||||
</script>
|
||||
{% endblock %}
|
||||
216
v2_adminpanel/templates/profile.html
Normale Datei
216
v2_adminpanel/templates/profile.html
Normale Datei
@@ -0,0 +1,216 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Benutzerprofil{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.profile-card {
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.profile-card:hover {
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.profile-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.security-badge {
|
||||
font-size: 2rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #6c757d;
|
||||
box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.25);
|
||||
}
|
||||
.password-strength {
|
||||
height: 4px;
|
||||
margin-top: 5px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.strength-very-weak { background-color: #dc3545; width: 20%; }
|
||||
.strength-weak { background-color: #fd7e14; width: 40%; }
|
||||
.strength-medium { background-color: #ffc107; width: 60%; }
|
||||
.strength-strong { background-color: #28a745; width: 80%; }
|
||||
.strength-very-strong { background-color: #0d6efd; width: 100%; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>👤 Benutzerprofil</h1>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- User Info Stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card profile-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="profile-icon text-primary">👤</div>
|
||||
<h5 class="card-title">{{ user.username }}</h5>
|
||||
<p class="text-muted mb-0">{{ user.email or 'Keine E-Mail angegeben' }}</p>
|
||||
<small class="text-muted">Mitglied seit: {{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'Unbekannt' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card profile-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="profile-icon text-info">🔐</div>
|
||||
<h5 class="card-title">Sicherheitsstatus</h5>
|
||||
{% if user.totp_enabled %}
|
||||
<span class="badge bg-success">2FA Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">2FA Inaktiv</span>
|
||||
{% endif %}
|
||||
<p class="text-muted mb-0 mt-2">
|
||||
<small>Letztes Passwort-Update:<br>{{ user.last_password_change.strftime('%d.%m.%Y') if user.last_password_change else 'Noch nie' }}</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Change Card -->
|
||||
<div class="card profile-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title d-flex align-items-center">
|
||||
<span class="security-badge">🔑</span>
|
||||
Passwort ändern
|
||||
</h5>
|
||||
<hr>
|
||||
<form method="POST" action="{{ url_for('auth.change_password') }}">
|
||||
<div class="mb-3">
|
||||
<label for="current_password" class="form-label">Aktuelles Passwort</label>
|
||||
<input type="password" class="form-control" id="current_password" name="current_password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">Neues Passwort</label>
|
||||
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
|
||||
<div class="password-strength" id="password-strength"></div>
|
||||
<div class="form-text" id="password-help">Mindestens 8 Zeichen</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm_password" class="form-label">Neues Passwort bestätigen</label>
|
||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">🔄 Passwort ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Card -->
|
||||
<div class="card profile-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title d-flex align-items-center">
|
||||
<span class="security-badge">🔐</span>
|
||||
Zwei-Faktor-Authentifizierung (2FA)
|
||||
</h5>
|
||||
<hr>
|
||||
{% if user.totp_enabled %}
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">Status: <span class="badge bg-success">Aktiv</span></h6>
|
||||
<p class="text-muted mb-0">Ihr Account ist durch 2FA geschützt</p>
|
||||
</div>
|
||||
<div class="text-success" style="font-size: 3rem;">✅</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('auth.disable_2fa') }}" onsubmit="return confirm('Sind Sie sicher, dass Sie 2FA deaktivieren möchten? Dies verringert die Sicherheit Ihres Accounts.');">
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Passwort zur Bestätigung</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required placeholder="Ihr aktuelles Passwort">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">🚫 2FA deaktivieren</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">Status: <span class="badge bg-warning text-dark">Inaktiv</span></h6>
|
||||
<p class="text-muted mb-0">Aktivieren Sie 2FA für zusätzliche Sicherheit</p>
|
||||
</div>
|
||||
<div class="text-warning" style="font-size: 3rem;">⚠️</div>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
Mit 2FA wird bei jeder Anmeldung zusätzlich ein Code aus Ihrer Authenticator-App benötigt.
|
||||
Dies schützt Ihren Account auch bei kompromittiertem Passwort.
|
||||
</p>
|
||||
<a href="{{ url_for('auth.setup_2fa') }}" class="btn btn-success">✨ 2FA einrichten</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Password strength indicator
|
||||
document.getElementById('new_password').addEventListener('input', function(e) {
|
||||
const password = e.target.value;
|
||||
let strength = 0;
|
||||
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength++;
|
||||
if (password.match(/[0-9]/)) strength++;
|
||||
if (password.match(/[^a-zA-Z0-9]/)) strength++;
|
||||
|
||||
const strengthText = ['Sehr schwach', 'Schwach', 'Mittel', 'Stark', 'Sehr stark'];
|
||||
const strengthClass = ['strength-very-weak', 'strength-weak', 'strength-medium', 'strength-strong', 'strength-very-strong'];
|
||||
const textClass = ['text-danger', 'text-warning', 'text-warning', 'text-success', 'text-primary'];
|
||||
|
||||
const strengthBar = document.getElementById('password-strength');
|
||||
const helpText = document.getElementById('password-help');
|
||||
|
||||
if (password.length > 0) {
|
||||
strengthBar.className = `password-strength ${strengthClass[strength]}`;
|
||||
strengthBar.style.display = 'block';
|
||||
helpText.textContent = `Stärke: ${strengthText[strength]}`;
|
||||
helpText.className = `form-text ${textClass[strength]}`;
|
||||
} else {
|
||||
strengthBar.style.display = 'none';
|
||||
helpText.textContent = 'Mindestens 8 Zeichen';
|
||||
helpText.className = 'form-text';
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm password validation
|
||||
document.getElementById('confirm_password').addEventListener('input', function(e) {
|
||||
const password = document.getElementById('new_password').value;
|
||||
const confirm = e.target.value;
|
||||
|
||||
if (confirm.length > 0) {
|
||||
if (password !== confirm) {
|
||||
e.target.classList.add('is-invalid');
|
||||
e.target.setCustomValidity('Passwörter stimmen nicht überein');
|
||||
} else {
|
||||
e.target.classList.remove('is-invalid');
|
||||
e.target.classList.add('is-valid');
|
||||
e.target.setCustomValidity('');
|
||||
}
|
||||
} else {
|
||||
e.target.classList.remove('is-invalid', 'is-valid');
|
||||
}
|
||||
});
|
||||
|
||||
// Also check when password field changes
|
||||
document.getElementById('new_password').addEventListener('input', function(e) {
|
||||
const confirm = document.getElementById('confirm_password');
|
||||
if (confirm.value.length > 0) {
|
||||
confirm.dispatchEvent(new Event('input'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
365
v2_adminpanel/templates/resource_history.html
Normale Datei
365
v2_adminpanel/templates/resource_history.html
Normale Datei
@@ -0,0 +1,365 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Historie{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Resource Info Card */
|
||||
.resource-info-card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Resource Value Display */
|
||||
.resource-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
background-color: #f8f9fa;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
display: inline-block;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Status Badge Large */
|
||||
.status-badge-large {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Timeline Styling */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(to bottom, #e9ecef 0%, #dee2e6 100%);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-left: 80px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.timeline-content:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 10px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.timeline-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 15px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 10px 10px 10px 0;
|
||||
border-color: transparent #fff transparent transparent;
|
||||
}
|
||||
|
||||
/* Action Icons */
|
||||
.action-icon {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 1rem;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.action-created { background-color: #d4edda; color: #155724; }
|
||||
.action-allocated { background-color: #cce5ff; color: #004085; }
|
||||
.action-deallocated { background-color: #d1ecf1; color: #0c5460; }
|
||||
.action-quarantined { background-color: #fff3cd; color: #856404; }
|
||||
.action-released { background-color: #d4edda; color: #155724; }
|
||||
.action-deleted { background-color: #f8d7da; color: #721c24; }
|
||||
|
||||
/* Details Box */
|
||||
.details-box {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: #212529;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">Resource Historie</h1>
|
||||
<p class="text-muted mb-0">Detaillierte Aktivitätshistorie</p>
|
||||
</div>
|
||||
<a href="{{ url_for('resources.resources') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Zurück zur Übersicht
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Resource Info Card -->
|
||||
<div class="card resource-info-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">📋 Resource Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Main Resource Info -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="mb-3">
|
||||
{% if resource.resource_type == 'domain' %}
|
||||
<span class="display-1">🌐</span>
|
||||
{% elif resource.resource_type == 'ipv4' %}
|
||||
<span class="display-1">🖥️</span>
|
||||
{% else %}
|
||||
<span class="display-1">📱</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="resource-value mb-3">{{ resource.resource_value }}</div>
|
||||
<div>
|
||||
{% if resource.status == 'available' %}
|
||||
<span class="status-badge-large badge bg-success">
|
||||
✅ Verfügbar
|
||||
</span>
|
||||
{% elif resource.status == 'allocated' %}
|
||||
<span class="status-badge-large badge bg-primary">
|
||||
🔗 Zugeteilt
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-badge-large badge bg-warning text-dark">
|
||||
⚠️ Quarantäne
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Info Grid -->
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<div class="info-label">Ressourcentyp</div>
|
||||
<div class="info-value">{{ resource.resource_type|upper }}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<div class="info-label">Erstellt am</div>
|
||||
<div class="info-value">
|
||||
{{ resource.created_at.strftime('%d.%m.%Y %H:%M') if resource.created_at else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<div class="info-label">Status geändert</div>
|
||||
<div class="info-value">
|
||||
{{ resource.status_changed_at.strftime('%d.%m.%Y %H:%M') if resource.status_changed_at else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if resource.allocated_to_license %}
|
||||
<div class="info-item">
|
||||
<div class="info-label">Zugewiesen an Lizenz</div>
|
||||
<div class="info-value">
|
||||
<a href="{{ url_for('licenses.edit_license', license_id=resource.allocated_to_license) }}"
|
||||
class="text-decoration-none">
|
||||
{{ license_info.license_key if license_info else 'ID: ' + resource.allocated_to_license|string }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if resource.quarantine_reason %}
|
||||
<div class="info-item">
|
||||
<div class="info-label">Quarantäne-Grund</div>
|
||||
<div class="info-value">
|
||||
<span class="badge bg-warning text-dark">{{ resource.quarantine_reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if resource.quarantine_until %}
|
||||
<div class="info-item">
|
||||
<div class="info-label">Quarantäne bis</div>
|
||||
<div class="info-value">
|
||||
{{ resource.quarantine_until.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if resource.notes %}
|
||||
<div class="mt-4">
|
||||
<div class="alert alert-info mb-0">
|
||||
<h6 class="alert-heading">📝 Notizen</h6>
|
||||
<p class="mb-0">{{ resource.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Timeline -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">⏱️ Aktivitäts-Historie</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if history %}
|
||||
<div class="timeline">
|
||||
{% for event in history %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker
|
||||
{% if event.action == 'created' %}bg-success
|
||||
{% elif event.action == 'allocated' %}bg-primary
|
||||
{% elif event.action == 'deallocated' %}bg-info
|
||||
{% elif event.action == 'quarantined' %}bg-warning
|
||||
{% elif event.action == 'released' %}bg-success
|
||||
{% elif event.action == 'deleted' %}bg-danger
|
||||
{% else %}bg-secondary{% endif %}">
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
{% if event.action == 'created' %}
|
||||
<span class="action-icon action-created">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Ressource erstellt</h6>
|
||||
{% elif event.action == 'allocated' %}
|
||||
<span class="action-icon action-allocated">
|
||||
<i class="fas fa-link"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">An Lizenz zugeteilt</h6>
|
||||
{% elif event.action == 'deallocated' %}
|
||||
<span class="action-icon action-deallocated">
|
||||
<i class="fas fa-unlink"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Von Lizenz freigegeben</h6>
|
||||
{% elif event.action == 'quarantined' %}
|
||||
<span class="action-icon action-quarantined">
|
||||
<i class="fas fa-ban"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">In Quarantäne gesetzt</h6>
|
||||
{% elif event.action == 'released' %}
|
||||
<span class="action-icon action-released">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Aus Quarantäne entlassen</h6>
|
||||
{% elif event.action == 'deleted' %}
|
||||
<span class="action-icon action-deleted">
|
||||
<i class="fas fa-trash"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Ressource gelöscht</h6>
|
||||
{% else %}
|
||||
<h6 class="mb-0">{{ event.action }}</h6>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-muted small">
|
||||
<i class="fas fa-user"></i> {{ event.action_by }}
|
||||
{% if event.ip_address %}
|
||||
• <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 %}
|
||||
559
v2_adminpanel/templates/resource_metrics.html
Normale Datei
559
v2_adminpanel/templates/resource_metrics.html
Normale Datei
@@ -0,0 +1,559 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Metriken{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Metric Cards */
|
||||
.metric-card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
.metric-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
|
||||
}
|
||||
.metric-card .card-body {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 1rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.metric-sublabel {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Chart Cards */
|
||||
.chart-card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
height: 100%;
|
||||
}
|
||||
.chart-card .card-header {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Performance Table */
|
||||
.performance-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.performance-table td {
|
||||
vertical-align: middle;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.performance-table .resource-link {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.performance-table .resource-link:hover {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
/* Progress Bars */
|
||||
.progress-custom {
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge {
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Icon Badges */
|
||||
.icon-badge {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.icon-badge.blue { background-color: #e7f3ff; color: #0066cc; }
|
||||
.icon-badge.green { background-color: #e8f5e9; color: #2e7d32; }
|
||||
.icon-badge.orange { background-color: #fff3e0; color: #ef6c00; }
|
||||
.icon-badge.red { background-color: #ffebee; color: #c62828; }
|
||||
|
||||
/* Trend Indicator */
|
||||
.trend-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.trend-up {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.trend-down {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
.trend-neutral {
|
||||
background-color: #f5f5f5;
|
||||
color: #616161;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">Performance Dashboard</h1>
|
||||
<p class="text-muted mb-0">Resource Pool Metriken und Analysen</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('resources.resource_report') }}" class="btn btn-info">
|
||||
📄 Report generieren
|
||||
</a>
|
||||
<a href="{{ url_for('resources.resources') }}" class="btn btn-secondary">
|
||||
← Zurück
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge blue">
|
||||
📊
|
||||
</div>
|
||||
<div class="metric-label">Ressourcen gesamt</div>
|
||||
<div class="metric-value text-primary">{{ stats.total_resources or 0 }}</div>
|
||||
<div class="metric-sublabel">Aktive Ressourcen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge green">
|
||||
📈
|
||||
</div>
|
||||
<div class="metric-label">Ø Performance</div>
|
||||
<div class="metric-value text-{{ 'success' if stats.avg_performance > 80 else ('warning' if stats.avg_performance > 60 else 'danger') }}">
|
||||
{{ "%.1f"|format(stats.avg_performance or 0) }}%
|
||||
</div>
|
||||
<div class="metric-sublabel">Letzte 30 Tage</div>
|
||||
{% if stats.performance_trend %}
|
||||
<div class="mt-2">
|
||||
<span class="trend-indicator trend-{{ stats.performance_trend }}">
|
||||
{% if stats.performance_trend == 'up' %}
|
||||
<i class="fas fa-arrow-up me-1"></i> Steigend
|
||||
{% elif stats.performance_trend == 'down' %}
|
||||
<i class="fas fa-arrow-down me-1"></i> Fallend
|
||||
{% else %}
|
||||
<i class="fas fa-minus me-1"></i> Stabil
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge orange">
|
||||
💰
|
||||
</div>
|
||||
<div class="metric-label">ROI</div>
|
||||
<div class="metric-value text-{{ 'success' if stats.roi > 1 else 'danger' }}">
|
||||
{{ "%.2f"|format(stats.roi) }}x
|
||||
</div>
|
||||
<div class="metric-sublabel">Revenue / Cost</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge red">
|
||||
⚠️
|
||||
</div>
|
||||
<div class="metric-label">Probleme</div>
|
||||
<div class="metric-value text-{{ 'danger' if stats.total_issues > 10 else ('warning' if stats.total_issues > 5 else 'success') }}">
|
||||
{{ stats.total_issues or 0 }}
|
||||
</div>
|
||||
<div class="metric-sublabel">Letzte 30 Tage</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📊 Performance nach Ressourcentyp</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="performanceByTypeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">🎯 Auslastung nach Typ</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="utilizationChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Tables -->
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">🏆 Top Performer</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table performance-table mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Ressource</th>
|
||||
<th width="80">Typ</th>
|
||||
<th width="140">Score</th>
|
||||
<th width="80" class="text-center">ROI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for resource in top_performers %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="me-2">{{ resource.resource_value }}</code>
|
||||
<a href="{{ url_for('resources.resource_history', resource_id=resource.id) }}"
|
||||
class="resource-link" title="Historie anzeigen">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">
|
||||
{% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %}
|
||||
{{ resource.resource_type|upper }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress progress-custom">
|
||||
<div class="progress-bar bg-success"
|
||||
style="width: {{ resource.avg_score }}%">
|
||||
{{ "%.1f"|format(resource.avg_score) }}%
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-success">
|
||||
{{ "%.2f"|format(resource.roi) }}x
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not top_performers %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
Keine Performance-Daten verfügbar
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0">⚠️ Problematische Ressourcen</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table performance-table mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Ressource</th>
|
||||
<th width="80">Typ</th>
|
||||
<th width="100" class="text-center">Probleme</th>
|
||||
<th width="120">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for resource in problem_resources %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="me-2">{{ resource.resource_value }}</code>
|
||||
<a href="{{ url_for('resources.resource_history', resource_id=resource.id) }}"
|
||||
class="resource-link" title="Historie anzeigen">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">
|
||||
{% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %}
|
||||
{{ resource.resource_type|upper }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-danger">
|
||||
{{ resource.total_issues }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if resource.status == 'quarantine' %}
|
||||
<span class="status-badge bg-warning text-dark">
|
||||
⚠️ Quarantäne
|
||||
</span>
|
||||
{% elif resource.status == 'allocated' %}
|
||||
<span class="status-badge bg-primary text-white">
|
||||
🔗 Zugeteilt
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-badge bg-success text-white">
|
||||
✅ Verfügbar
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not problem_resources %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
Keine problematischen Ressourcen gefunden
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trend Chart -->
|
||||
<div class="card chart-card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📈 30-Tage Performance Trend</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="trendChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
|
||||
<script>
|
||||
// Chart defaults
|
||||
Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
|
||||
// Performance by Type Chart
|
||||
const performanceCtx = document.getElementById('performanceByTypeChart').getContext('2d');
|
||||
new Chart(performanceCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ performance_by_type|map(attribute=0)|list|tojson }},
|
||||
datasets: [{
|
||||
label: 'Durchschnittliche Performance',
|
||||
data: {{ performance_by_type|map(attribute=1)|list|tojson }},
|
||||
backgroundColor: [
|
||||
'rgba(33, 150, 243, 0.8)',
|
||||
'rgba(156, 39, 176, 0.8)',
|
||||
'rgba(76, 175, 80, 0.8)'
|
||||
],
|
||||
borderColor: [
|
||||
'rgb(33, 150, 243)',
|
||||
'rgb(156, 39, 176)',
|
||||
'rgb(76, 175, 80)'
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderRadius: 8
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utilization Chart
|
||||
const utilizationCtx = document.getElementById('utilizationChart').getContext('2d');
|
||||
new Chart(utilizationCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: {{ utilization_data|map(attribute='type')|list|tojson }},
|
||||
datasets: [{
|
||||
data: {{ utilization_data|map(attribute='allocated_percent')|list|tojson }},
|
||||
backgroundColor: [
|
||||
'rgba(33, 150, 243, 0.8)',
|
||||
'rgba(156, 39, 176, 0.8)',
|
||||
'rgba(76, 175, 80, 0.8)'
|
||||
],
|
||||
borderColor: '#fff',
|
||||
borderWidth: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.label + ': ' + context.parsed + '% ausgelastet';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trend Chart
|
||||
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||||
new Chart(trendCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ daily_metrics|map(attribute='date')|list|tojson }},
|
||||
datasets: [{
|
||||
label: 'Performance Score',
|
||||
data: {{ daily_metrics|map(attribute='performance')|list|tojson }},
|
||||
borderColor: 'rgb(76, 175, 80)',
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||
tension: 0.4,
|
||||
borderWidth: 3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
yAxisID: 'y',
|
||||
}, {
|
||||
label: 'Probleme',
|
||||
data: {{ daily_metrics|map(attribute='issues')|list|tojson }},
|
||||
borderColor: 'rgb(244, 67, 54)',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
tension: 0.4,
|
||||
borderWidth: 3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
yAxisID: 'y1',
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Performance %'
|
||||
},
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Anzahl Probleme'
|
||||
},
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
212
v2_adminpanel/templates/resource_report.html
Normale Datei
212
v2_adminpanel/templates/resource_report.html
Normale Datei
@@ -0,0 +1,212 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Report Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Resource Report Generator</h1>
|
||||
<a href="{{ url_for('resources.resources') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Zurück
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Report-Einstellungen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('resources.resource_report') }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="report_type" class="form-label">Report-Typ</label>
|
||||
<select name="type" id="report_type" class="form-select" required>
|
||||
<option value="usage">Auslastungsreport</option>
|
||||
<option value="performance">Performance-Report</option>
|
||||
<option value="compliance">Compliance-Report</option>
|
||||
<option value="inventory">Bestands-Report</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="format" class="form-label">Export-Format</label>
|
||||
<select name="format" id="format" class="form-select" required>
|
||||
<option value="excel">Excel (.xlsx)</option>
|
||||
<option value="csv">CSV (.csv)</option>
|
||||
<option value="pdf">PDF (Vorschau)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="date_from" class="form-label">Von</label>
|
||||
<input type="date" name="from" id="date_from" class="form-control"
|
||||
value="{{ (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="date_to" class="form-label">Bis</label>
|
||||
<input type="date" name="to" id="date_to" class="form-control"
|
||||
value="{{ datetime.now().strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h6>Report-Beschreibungen:</h6>
|
||||
<div id="report_descriptions">
|
||||
<div class="alert alert-info report-desc" data-type="usage">
|
||||
<h6><i class="fas fa-chart-line"></i> Auslastungsreport</h6>
|
||||
<p class="mb-0">Zeigt die Nutzung aller Ressourcen im gewählten Zeitraum.
|
||||
Enthält Allokations-Historie, durchschnittliche Auslastung und Trends.</p>
|
||||
</div>
|
||||
<div class="alert alert-warning report-desc" data-type="performance" style="display: none;">
|
||||
<h6><i class="fas fa-tachometer-alt"></i> Performance-Report</h6>
|
||||
<p class="mb-0">Analysiert die Performance-Metriken aller Ressourcen.
|
||||
Enthält ROI-Berechnungen, Issue-Tracking und Performance-Scores.</p>
|
||||
</div>
|
||||
<div class="alert alert-success report-desc" data-type="compliance" style="display: none;">
|
||||
<h6><i class="fas fa-shield-alt"></i> Compliance-Report</h6>
|
||||
<p class="mb-0">Überprüft Compliance-Aspekte wie Quarantäne-Gründe,
|
||||
Sicherheitsvorfälle und Policy-Verletzungen.</p>
|
||||
</div>
|
||||
<div class="alert alert-primary report-desc" data-type="inventory" style="display: none;">
|
||||
<h6><i class="fas fa-boxes"></i> Bestands-Report</h6>
|
||||
<p class="mb-0">Aktueller Bestand aller Ressourcen mit Status-Übersicht,
|
||||
Verfügbarkeit und Zuordnungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="previewReport()">
|
||||
<i class="fas fa-eye"></i> Vorschau
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" name="download" value="true">
|
||||
<i class="fas fa-download"></i> Report generieren
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzte Reports -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Letzte generierte Reports</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">Auslastungsreport_2025-06-01.xlsx</h6>
|
||||
<small>vor 5 Tagen</small>
|
||||
</div>
|
||||
<p class="mb-1">Zeitraum: 01.05.2025 - 01.06.2025</p>
|
||||
<small>Generiert von: {{ username }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vorschau Modal -->
|
||||
<div class="modal fade" id="previewModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Report-Vorschau</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="previewContent">
|
||||
<p class="text-center">
|
||||
<i class="fas fa-spinner fa-spin"></i> Lade Vorschau...
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Report-Typ Beschreibungen
|
||||
document.getElementById('report_type').addEventListener('change', function() {
|
||||
const selectedType = this.value;
|
||||
document.querySelectorAll('.report-desc').forEach(desc => {
|
||||
desc.style.display = desc.dataset.type === selectedType ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Datum-Validierung
|
||||
document.getElementById('date_from').addEventListener('change', function() {
|
||||
document.getElementById('date_to').min = this.value;
|
||||
});
|
||||
|
||||
document.getElementById('date_to').addEventListener('change', function() {
|
||||
document.getElementById('date_from').max = this.value;
|
||||
});
|
||||
|
||||
// Report-Vorschau
|
||||
function previewReport() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
const content = document.getElementById('previewContent');
|
||||
|
||||
// Simuliere Lade-Vorgang
|
||||
content.innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i> Generiere Vorschau...</p>';
|
||||
modal.show();
|
||||
|
||||
setTimeout(() => {
|
||||
// Beispiel-Vorschau
|
||||
content.innerHTML = `
|
||||
<h5>Report-Vorschau</h5>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ressourcentyp</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Verfügbar</th>
|
||||
<th>Zugeteilt</th>
|
||||
<th>Auslastung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Domain</td>
|
||||
<td>150</td>
|
||||
<td>45</td>
|
||||
<td>100</td>
|
||||
<td>66.7%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>IPv4</td>
|
||||
<td>100</td>
|
||||
<td>20</td>
|
||||
<td>75</td>
|
||||
<td>75.0%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Phone</td>
|
||||
<td>50</td>
|
||||
<td>15</td>
|
||||
<td>30</td>
|
||||
<td>60.0%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-muted">Dies ist eine vereinfachte Vorschau. Der vollständige Report enthält weitere Details.</p>
|
||||
`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Initialisierung
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
|
||||
document.getElementById('date_from').value = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
document.getElementById('date_to').value = today.toISOString().split('T')[0];
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
896
v2_adminpanel/templates/resources.html
Normale Datei
896
v2_adminpanel/templates/resources.html
Normale Datei
@@ -0,0 +1,896 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Pool{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Statistik-Karten Design wie Dashboard */
|
||||
.stat-card {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
height: 100%;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.stat-card .card-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.stat-card .card-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.stat-card .card-label {
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Resource Type Icons */
|
||||
.resource-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.resource-icon.domain {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
.resource-icon.ipv4 {
|
||||
background-color: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
.resource-icon.phone {
|
||||
background-color: #e8f5e9;
|
||||
color: #388e3c;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge {
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-available {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.status-allocated {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
.status-quarantine {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* Progress Bar Custom */
|
||||
.progress-custom {
|
||||
height: 25px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.progress-bar-custom {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Table Styling */
|
||||
.table-custom {
|
||||
border: none;
|
||||
}
|
||||
.table-custom thead th {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
padding: 1rem;
|
||||
}
|
||||
.table-custom tbody tr {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.table-custom tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.table-custom td {
|
||||
padding: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.btn-action {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
margin: 0 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-action:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Copy Button */
|
||||
.copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6c757d;
|
||||
padding: 0.25rem 0.5rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
color: #28a745;
|
||||
}
|
||||
.copy-btn.copied {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
/* Filter Card */
|
||||
.filter-card {
|
||||
background-color: #f8f9fa;
|
||||
border: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
.empty-state i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Dropdown Verbesserungen */
|
||||
.dropdown-menu {
|
||||
min-width: 220px;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
border: none;
|
||||
}
|
||||
.dropdown-item {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.dropdown-item i {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Quick Action Buttons */
|
||||
.btn-success {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Sortable Headers */
|
||||
thead th a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-right: 20px;
|
||||
}
|
||||
thead th a:hover {
|
||||
color: #0d6efd !important;
|
||||
}
|
||||
thead th a i {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="mb-0">Ressourcen Pool</h1>
|
||||
<p class="text-muted mb-0">Verwalten Sie Domains, IPs und Telefonnummern</p>
|
||||
</div>
|
||||
|
||||
<!-- Test/Live Mode Toggle -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="showTestResources"
|
||||
{% if show_fake %}checked{% endif %}
|
||||
onchange="toggleTestResources()">
|
||||
<label class="form-check-label" for="showTestResources">
|
||||
Fake-Ressourcen anzeigen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistik-Karten -->
|
||||
<div class="row g-4 mb-4">
|
||||
{% for type, data in stats.items() %}
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon">
|
||||
{% if type == 'domain' %}
|
||||
🌐
|
||||
{% elif type == 'ipv4' %}
|
||||
🖥️
|
||||
{% else %}
|
||||
📱
|
||||
{% endif %}
|
||||
</div>
|
||||
<h5 class="text-muted mb-2">{{ type|upper }}</h5>
|
||||
<div class="card-value text-primary">{{ data.available }}</div>
|
||||
<div class="card-label text-muted mb-3">von {{ data.total }} verfügbar</div>
|
||||
|
||||
<div class="progress progress-custom">
|
||||
<div class="progress-bar bg-success progress-bar-custom"
|
||||
style="width: {{ data.available_percent }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.available }} verfügbar">
|
||||
{{ data.available_percent }}%
|
||||
</div>
|
||||
<div class="progress-bar bg-info progress-bar-custom"
|
||||
style="width: {{ (data.allocated / data.total * 100) if data.total > 0 else 0 }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.allocated }} zugeteilt">
|
||||
{% if data.allocated > 0 %}{{ data.allocated }}{% endif %}
|
||||
</div>
|
||||
<div class="progress-bar bg-warning progress-bar-custom"
|
||||
style="width: {{ (data.quarantined / data.total * 100) if data.total > 0 else 0 }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.quarantined }} in Quarantäne">
|
||||
{% if data.quarantined > 0 %}{{ data.quarantined }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
{% if data.available_percent < 20 %}
|
||||
<span class="badge bg-danger">⚠️ Niedriger Bestand</span>
|
||||
{% elif data.available_percent < 50 %}
|
||||
<span class="badge bg-warning text-dark">⚡ Bestand prüfen</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">✅ Gut gefüllt</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card filter-card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('resources.resources') }}" id="filterForm">
|
||||
<input type="hidden" name="show_fake" value="{{ 'true' if show_fake else 'false' }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="type" class="form-label">🏷️ Typ</label>
|
||||
<select name="type" id="type" class="form-select">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="domain" {% if resource_type == 'domain' %}selected{% endif %}>🌐 Domain</option>
|
||||
<option value="ipv4" {% if resource_type == 'ipv4' %}selected{% endif %}>🖥️ IPv4</option>
|
||||
<option value="phone" {% if resource_type == 'phone' %}selected{% endif %}>📱 Telefon</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label">📊 Status</label>
|
||||
<select name="status" id="status" class="form-select">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="available" {% if status_filter == 'available' %}selected{% endif %}>✅ Verfügbar</option>
|
||||
<option value="allocated" {% if status_filter == 'allocated' %}selected{% endif %}>🔗 Zugeteilt</option>
|
||||
<option value="quarantine" {% if status_filter == 'quarantine' %}selected{% endif %}>⚠️ Quarantäne</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="search" class="form-label">🔍 Suche</label>
|
||||
<input type="text" name="search" id="search" class="form-control"
|
||||
placeholder="Ressource suchen..." value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"> </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 %}
|
||||
183
v2_adminpanel/templates/sessions.html
Normale Datei
183
v2_adminpanel/templates/sessions.html
Normale Datei
@@ -0,0 +1,183 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Session-Tracking{% endblock %}
|
||||
|
||||
{% macro active_sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('sessions.sessions', active_sort=field, active_order='desc' if current_order == 'asc' else 'asc', ended_sort=ended_sort, ended_order=ended_order) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('sessions.sessions', active_sort=field, active_order='asc', ended_sort=ended_sort, ended_order=ended_order) }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro ended_sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('sessions.sessions', active_sort=active_sort, active_order=active_order, ended_sort=field, ended_order='desc' if current_order == 'asc' else 'asc') }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('sessions.sessions', active_sort=active_sort, active_order=active_order, ended_sort=field, ended_order='asc') }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.session-active { background-color: #d4edda; }
|
||||
.session-warning { background-color: #fff3cd; }
|
||||
.session-inactive { background-color: #f8f9fa; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Session-Tracking</h2>
|
||||
<!-- Export Buttons -->
|
||||
<div>
|
||||
<span class="text-muted me-2">Export:</span>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('export.export_sessions', active_only='true', format='excel') }}" class="btn btn-success btn-sm">
|
||||
<i class="bi bi-file-earmark-excel"></i> Aktive (Excel)
|
||||
</a>
|
||||
<a href="{{ url_for('export.export_sessions', active_only='true', format='csv') }}" class="btn btn-secondary btn-sm">
|
||||
<i class="bi bi-file-earmark-text"></i> Aktive (CSV)
|
||||
</a>
|
||||
<a href="{{ url_for('export.export_sessions', format='excel') }}" class="btn btn-success btn-sm">
|
||||
<i class="bi bi-file-earmark-excel"></i> Alle (Excel)
|
||||
</a>
|
||||
<a href="{{ url_for('export.export_sessions', format='csv') }}" class="btn btn-secondary btn-sm">
|
||||
<i class="bi bi-file-earmark-text"></i> Alle (CSV)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aktive Sessions -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">🟢 Aktive Sessions ({{ active_sessions|length }})</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if active_sessions %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Lizenz</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Gestartet</th>
|
||||
<th>Letzter Heartbeat</th>
|
||||
<th>Inaktiv seit</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in active_sessions %}
|
||||
<tr class="{% if session[8] > 5 %}session-warning{% else %}session-active{% endif %}">
|
||||
<td>{{ session[3] }}</td>
|
||||
<td><small><code>{{ session[2][:12] }}...</code></small></td>
|
||||
<td>{{ session[4] or '-' }}</td>
|
||||
<td>{{ session[6].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ session[7].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>
|
||||
{% if session[8] < 1 %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% elif session[8] < 5 %}
|
||||
<span class="badge bg-warning">{{ session[8]|round|int }} Min.</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">{{ session[8]|round|int }} Min.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('sessions.end_session', session_id=session[0]) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Session wirklich beenden?');">
|
||||
⏹️ Beenden
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Sessions gelten als inaktiv nach 5 Minuten ohne Heartbeat
|
||||
</small>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine aktiven Sessions vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beendete Sessions -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<h5 class="mb-0">⏸️ Beendete Sessions (letzte 24 Stunden)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_sessions %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Lizenz</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Gestartet</th>
|
||||
<th>Beendet</th>
|
||||
<th>Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in recent_sessions %}
|
||||
<tr class="session-inactive">
|
||||
<td>{{ session[3] }}</td>
|
||||
<td><small><code>{{ session[2][:12] }}...</code></small></td>
|
||||
<td>{{ session[4] or '-' }}</td>
|
||||
<td>{{ session[5].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ session[6].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>
|
||||
{% if session[7] < 60 %}
|
||||
{{ session[7]|round|int }} Min.
|
||||
{% else %}
|
||||
{{ (session[7]/60)|round(1) }} Std.
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine beendeten Sessions in den letzten 24 Stunden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
210
v2_adminpanel/templates/setup_2fa.html
Normale Datei
210
v2_adminpanel/templates/setup_2fa.html
Normale Datei
@@ -0,0 +1,210 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}2FA Einrichten{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.setup-card {
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.setup-card:hover {
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.step-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.app-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.qr-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
display: inline-block;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.secret-code {
|
||||
font-family: monospace;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 2px;
|
||||
background-color: #f8f9fa;
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.code-input {
|
||||
font-size: 2rem;
|
||||
letter-spacing: 0.5rem;
|
||||
text-align: center;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>🔐 2FA einrichten</h1>
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-secondary">← Zurück zum Profil</a>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Step 1: Install App -->
|
||||
<div class="card setup-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="step-number">1</span>
|
||||
Authenticator-App installieren
|
||||
</h5>
|
||||
<p class="ms-5">Wählen Sie eine der folgenden Apps für Ihr Smartphone:</p>
|
||||
<div class="row ms-4">
|
||||
<div class="col-md-4 mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<span style="font-size: 2rem; margin-right: 10px;">📱</span>
|
||||
<div>
|
||||
<strong>Google Authenticator</strong><br>
|
||||
<small class="text-muted">Android / iOS</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<span style="font-size: 2rem; margin-right: 10px;">🔷</span>
|
||||
<div>
|
||||
<strong>Microsoft Authenticator</strong><br>
|
||||
<small class="text-muted">Android / iOS</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<span style="font-size: 2rem; margin-right: 10px;">🔴</span>
|
||||
<div>
|
||||
<strong>Authy</strong><br>
|
||||
<small class="text-muted">Android / iOS / Desktop</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Scan QR Code -->
|
||||
<div class="card setup-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="step-number">2</span>
|
||||
QR-Code scannen oder Code eingeben
|
||||
</h5>
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6 text-center mb-4">
|
||||
<p class="fw-bold">Option A: QR-Code scannen</p>
|
||||
<div class="qr-container">
|
||||
<img src="data:image/png;base64,{{ qr_code }}" alt="2FA QR Code" style="max-width: 250px;">
|
||||
</div>
|
||||
<p class="text-muted mt-2">
|
||||
<small>Öffnen Sie Ihre Authenticator-App und scannen Sie diesen Code</small>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<p class="fw-bold">Option B: Code manuell eingeben</p>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">Account-Name:</label>
|
||||
<div class="alert alert-light py-2">
|
||||
<strong>V2 Admin Panel</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">Geheimer Schlüssel:</label>
|
||||
<div class="secret-code">{{ totp_secret }}</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="copySecret()">
|
||||
📋 Schlüssel kopieren
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-warning">
|
||||
<small>
|
||||
<strong>⚠️ Wichtiger Hinweis:</strong><br>
|
||||
Speichern Sie diesen Code sicher. Er ist Ihre einzige Möglichkeit,
|
||||
2FA auf einem neuen Gerät einzurichten.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Verify -->
|
||||
<div class="card setup-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="step-number">3</span>
|
||||
Code verifizieren
|
||||
</h5>
|
||||
<p class="ms-5">Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein:</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.enable_2fa') }}" class="ms-5 me-5">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6 mb-3">
|
||||
<input type="text"
|
||||
class="form-control code-input"
|
||||
id="token"
|
||||
name="token"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
required>
|
||||
<div class="form-text text-center">Der Code ändert sich alle 30 Sekunden</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<button type="submit" class="btn btn-success btn-lg w-100">
|
||||
✅ 2FA aktivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copySecret() {
|
||||
const secret = '{{ totp_secret }}';
|
||||
navigator.clipboard.writeText(secret).then(function() {
|
||||
alert('Code wurde in die Zwischenablage kopiert!');
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-format the code input
|
||||
document.getElementById('token').addEventListener('input', function(e) {
|
||||
// Remove non-digits
|
||||
e.target.value = e.target.value.replace(/\D/g, '');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden Mehr anzeigen
In neuem Issue referenzieren
Einen Benutzer sperren