diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 5fe3bfb..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(ls:*)", - "Bash(docker-compose ps:*)", - "Bash(docker-compose logs:*)", - "Bash(docker-compose up:*)", - "Bash(mkdir:*)", - "Bash(docker-compose down:*)", - "Bash(docker logs:*)", - "Bash(docker exec:*)", - "Bash(python3:*)", - "Bash(docker-compose restart:*)", - "Bash(docker-compose build:*)", - "Bash(docker restart:*)", - "Bash(docker network inspect:*)", - "Bash(mkdir:*)", - "Bash(sudo touch:*)", - "Bash(docker volume rm:*)", - "Bash(rm:*)", - "Bash(docker-compose stop:*)", - "Bash(docker-compose rm:*)", - "Bash(docker-compose down:*)", - "Bash(docker stop:*)", - "Bash(docker rm:*)", - "Bash(docker-compose build:*)", - "Bash(docker-compose up:*)", - "Bash(docker-compose ps:*)", - "Bash(docker logs:*)", - "Bash(nslookup:*)", - "Bash(getent:*)", - "Bash(ipconfig:*)", - "Bash(ss:*)", - "Bash(curl:*)", - "Bash(powershell.exe:*)", - "Bash(cp:*)", - "Bash(chmod:*)", - "Bash(unzip:*)", - "Bash(python3:*)", - "Bash(grep:*)", - "Bash(docker exec:*)", - "Bash(rm:*)", - "Bash(mv:*)", - "Bash(docker-compose restart:*)", - "Bash(find:*)", - "Bash(docker network:*)", - "Bash(curl:*)", - "Bash(find:*)", - "Bash(openssl x509:*)", - "Bash(cat:*)", - "Bash(openssl dhparam:*)", - "Bash(rg:*)", - "Bash(docker cp:*)", - "Bash(docker-compose:*)", - "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"href=[''\"\"]/?[''\"\"].*📊 Dashboard\" --type html)", - "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n 'href=[\"\"\\']/?[\"\\''].*Dashboard'' --type html)", - "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"Dashboard\" --type html)", - "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"Dashboard\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/base.html)", - "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -A5 -B5 \"navbar|nav\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/base.html)", - "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"href=[''\"\"][/]?(dashboard)?[''\"\"]\" --type html)", - "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"Dashboard\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/resources.html)", - "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"Dashboard\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/profile.html /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/resource_metrics.html)", - "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"BACKUP|LOGIN_2FA_SUCCESS\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/app.py)", - "Bash(sed:*)", - "Bash(python:*)", - "Bash(awk:*)", - "Bash(./backup_before_cleanup.sh:*)", - "Bash(for template in add_resource.html batch_create.html batch_import.html batch_update.html session_history.html session_statistics.html)", - "Bash(do if [ ! -f \"/mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/$template\" ])", - "Bash(then echo \"- $template\")", - "Bash(fi)", - "Bash(done)", - "Bash(docker compose:*)", - "Bash(true)", - "Bash(git checkout:*)", - "Bash(touch:*)", - "Bash(wget:*)", - "Bash(docker inspect:*)", - "Bash(docker run:*)", - "Bash(ping:*)", - "Bash(timeout:*)", - "Bash(nc:*)" - ], - "deny": [] - } -} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 137a171..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -server-backups/*.tar.gz filter=lfs diff=lfs merge=lfs -text diff --git a/API_REFERENCE.md b/API_REFERENCE.md deleted file mode 100644 index f3aac34..0000000 --- a/API_REFERENCE.md +++ /dev/null @@ -1,846 +0,0 @@ -# V2-Docker API Reference - -## Authentication - -### API Key Authentication - -All License Server API endpoints require authentication using an API key. The API key must be included in the request headers. - -**Header Format:** -``` -X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -``` - -**API Key Management:** -- API keys can be managed through the Admin Panel under "Lizenzserver Administration" → "System-API-Key generieren" -- Keys follow the format: `AF-YYYY-[32 random characters]` -- Only one system API key is active at a time -- Regenerating the key will immediately invalidate the old key -- The initial API key is automatically generated on first startup -- To retrieve the initial API key from database: `SELECT api_key FROM system_api_key WHERE id = 1;` - -**Error Response (401 Unauthorized):** -```json -{ - "error": "Invalid or missing API key", - "code": "INVALID_API_KEY", - "status": 401 -} -``` - -## License Server API - -**Base URL:** `https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com` - -### Public Endpoints - -#### GET / -Root endpoint - Service status. - -**Response:** -```json -{ - "status": "ok", - "service": "V2 License Server", - "timestamp": "2025-06-19T10:30:00Z" -} -``` - -#### GET /health -Health check endpoint. - -**Response:** -```json -{ - "status": "healthy", - "timestamp": "2025-06-19T10:30:00Z" -} -``` - -#### GET /metrics -Prometheus metrics endpoint. - -**Response:** -Prometheus metrics in CONTENT_TYPE_LATEST format. - -### License API Endpoints - -All license endpoints require API key authentication via `X-API-Key` header. - -#### POST /api/license/activate -Activate a license on a new system. - -**Headers:** -``` -X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -Content-Type: application/json -``` - -**Request:** -```json -{ - "license_key": "XXXX-XXXX-XXXX-XXXX", - "hardware_hash": "unique-hardware-identifier", - "machine_name": "DESKTOP-ABC123", - "app_version": "1.0.0" -} -``` - -**Response:** -```json -{ - "message": "License activated successfully", - "activation": { - "id": 123, - "license_key": "XXXX-XXXX-XXXX-XXXX", - "hardware_hash": "unique-hardware-identifier", - "machine_name": "DESKTOP-ABC123", - "activated_at": "2025-06-19T10:30:00Z", - "last_heartbeat": "2025-06-19T10:30:00Z", - "is_active": true - } -} -``` - -#### POST /api/license/verify -Verify an active license. - -**Headers:** -``` -X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -Content-Type: application/json -``` - -**Request:** -```json -{ - "license_key": "XXXX-XXXX-XXXX-XXXX", - "hardware_hash": "unique-hardware-identifier", - "app_version": "1.0.0" -} -``` - -**Response:** -```json -{ - "valid": true, - "message": "License is valid", - "license": { - "key": "XXXX-XXXX-XXXX-XXXX", - "valid_until": "2026-01-01", - "max_users": 10 - }, - "update_available": false, - "latest_version": "1.0.0" -} -``` - -#### GET /api/license/info/{license_key} -Get license information. - -**Headers:** -``` -X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -``` - -**Response:** -```json -{ - "license": { - "id": 123, - "key": "XXXX-XXXX-XXXX-XXXX", - "customer_name": "ACME Corp", - "type": "perpetual", - "valid_from": "2025-01-01", - "valid_until": "2026-01-01", - "max_activations": 5, - "max_users": 10, - "is_active": true - }, - "activations": [ - { - "id": 456, - "hardware_hash": "unique-hardware-identifier", - "machine_name": "DESKTOP-ABC123", - "activated_at": "2025-06-19T10:00:00Z", - "last_heartbeat": "2025-06-19T14:30:00Z", - "is_active": true - } - ] -} -``` - -### Session Management API Endpoints - -**Note:** Session endpoints require that the client application is configured in the `client_configs` table. The default client "Account Forger" is pre-configured. - -#### POST /api/license/session/start -Start a new session for a license. - -**Headers:** -``` -X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -Content-Type: application/json -``` - -**Request:** -```json -{ - "license_key": "XXXX-XXXX-XXXX-XXXX", - "machine_id": "DESKTOP-ABC123", - "hardware_hash": "unique-hardware-identifier", - "version": "1.0.0" -} -``` - -**Response:** -- 200 OK: Returns session_token and optional update info -- 409 Conflict: "Es ist nur eine Sitzung erlaubt..." (single session enforcement) - -#### POST /api/license/session/heartbeat -Keep session alive with heartbeat. - -**Headers:** -``` -X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -Content-Type: application/json -``` - -**Request:** -```json -{ - "session_token": "550e8400-e29b-41d4-a716-446655440000", - "license_key": "XXXX-XXXX-XXXX-XXXX" -} -``` - -**Response:** 200 OK with last_heartbeat timestamp - -#### POST /api/license/session/end -End an active session. - -**Headers:** -``` -X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -Content-Type: application/json -``` - -**Request:** -```json -{ - "session_token": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -**Response:** 200 OK with session duration and end reason - -### Version API Endpoints - -#### POST /api/version/check -Check for available updates. - -**Headers:** -``` -X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -``` - -**Request:** -```json -{ - "current_version": "1.0.0", - "license_key": "XXXX-XXXX-XXXX-XXXX" -} -``` - -**Response:** -```json -{ - "update_available": true, - "latest_version": "1.1.0", - "download_url": "https://example.com/download/v1.1.0", - "release_notes": "Bug fixes and performance improvements" -} -``` - -#### GET /api/version/latest -Get latest version information. - -**Headers:** -``` -X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -``` - -**Response:** -```json -{ - "version": "1.1.0", - "release_date": "2025-06-20", - "download_url": "https://example.com/download/v1.1.0", - "release_notes": "Bug fixes and performance improvements" -} -``` - -## Admin Panel API - -**Base URL:** `https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com` - -### Customer API Endpoints - -#### GET /api/customers -Search customers for Select2 dropdown. - -**Query Parameters:** -- `q`: Search query -- `page`: Page number (default: 1) - -**Response:** -```json -{ - "results": [ - { - "id": 123, - "text": "ACME Corp - admin@acme.com" - } - ], - "pagination": { - "more": false - } -} -``` - -- `GET /api/customer/{id}/licenses` - List customer's licenses -- `GET /api/customer/{id}/quick-stats` - License and activation counts - -### License Management API - -- `POST /api/license/{id}/toggle` - Toggle active status -- `POST /api/licenses/bulk-activate` - Activate multiple (license_ids array) -- `POST /api/licenses/bulk-deactivate` - Deactivate multiple -- `POST /api/licenses/bulk-delete` - Delete multiple -- `POST /api/license/{id}/quick-edit` - Update validity/limits -- `GET /api/license/{id}/devices` - List registered devices - -#### POST /api/license/{license_id}/quick-edit -Quick edit license properties. - -**Request:** -```json -{ - "valid_until": "2027-01-01", - "max_activations": 10, - "max_users": 50 -} -``` - -**Response:** -```json -{ - "success": true, - "message": "License updated successfully" -} -``` - -#### POST /api/generate-license-key -Generate a new license key. - -**Response:** -```json -{ - "license_key": "NEW1-NEW2-NEW3-NEW4" -} -``` - -### Device Management API - -#### GET /api/license/{license_id}/devices -Get devices for a license. - -**Response:** -```json -{ - "devices": [ - { - "id": 123, - "hardware_hash": "unique-hardware-identifier", - "machine_name": "DESKTOP-ABC123", - "activated_at": "2025-01-01T10:00:00Z", - "last_heartbeat": "2025-06-19T14:30:00Z", - "is_active": true, - "app_version": "1.0.0" - } - ] -} -``` - -#### POST /api/license/{license_id}/register-device -Register a new device. - -**Request:** -```json -{ - "hardware_hash": "unique-hardware-identifier", - "machine_name": "DESKTOP-XYZ789", - "app_version": "1.0.0" -} -``` - -**Response:** -```json -{ - "success": true, - "device_id": 456, - "message": "Device registered successfully" -} -``` - -#### POST /api/license/{license_id}/deactivate-device/{device_id} -Deactivate a device. - -**Response:** -```json -{ - "success": true, - "message": "Device deactivated successfully" -} -``` - -### Resource Management API - -#### GET /api/license/{license_id}/resources -Get resources for a license. - -**Response:** -```json -{ - "resources": [ - { - "id": 789, - "type": "server", - "identifier": "SRV-001", - "status": "allocated", - "allocated_at": "2025-06-01T10:00:00Z" - } - ] -} -``` - -#### POST /api/resources/allocate -Allocate resources to a license. - -**Request:** -```json -{ - "license_id": 123, - "resource_ids": [789, 790] -} -``` - -**Response:** -```json -{ - "success": true, - "allocated": 2, - "message": "2 resources allocated successfully" -} -``` - -#### GET /api/resources/check-availability -Check resource availability. - -**Query Parameters:** -- `type`: Resource type -- `count`: Number of resources needed - -**Response:** -```json -{ - "available": true, - "count": 5, - "resources": [ - { - "id": 791, - "type": "server", - "identifier": "SRV-002" - } - ] -} -``` - -#### GET /api/resources/stats -Get resource statistics. - -**Response:** -```json -{ - "total": 100, - "allocated": 75, - "available": 25, - "by_type": { - "server": { - "total": 50, - "allocated": 40, - "available": 10 - }, - "workstation": { - "total": 50, - "allocated": 35, - "available": 15 - } - } -} -``` - -### Search API - -#### GET /api/global-search -Global search across all entities. - -**Query Parameters:** -- `q`: Search query -- `type`: Entity type filter (customer, license, device) -- `limit`: Maximum results (default: 20) - -**Response:** -```json -{ - "results": [ - { - "type": "customer", - "id": 123, - "title": "ACME Corp", - "subtitle": "admin@acme.com", - "url": "/customer/edit/123" - }, - { - "type": "license", - "id": 456, - "title": "XXXX-XXXX-XXXX-XXXX", - "subtitle": "ACME Corp - Active", - "url": "/license/edit/456" - } - ], - "total": 15 -} -``` - -### Lead Management API - -#### GET /leads/api/institutions -Get all institutions with pagination. - -**Query Parameters:** -- `page`: Page number (default: 1) -- `per_page`: Items per page (default: 20) -- `search`: Search query - -**Response:** -```json -{ - "institutions": [ - { - "id": 1, - "name": "Tech University", - "contact_count": 5, - "created_at": "2025-06-19T10:00:00Z" - } - ], - "total": 100, - "page": 1, - "per_page": 20 -} -``` - -#### POST /leads/api/institutions -Create a new institution. - -**Request:** -```json -{ - "name": "New University" -} -``` - -**Response:** -```json -{ - "id": 101, - "name": "New University", - "created_at": "2025-06-19T15:00:00Z" -} -``` - -#### GET /leads/api/contacts/{contact_id} -Get contact details. - -**Response:** -```json -{ - "id": 1, - "first_name": "John", - "last_name": "Doe", - "position": "IT Manager", - "institution_id": 1, - "details": [ - { - "id": 1, - "type": "email", - "value": "john.doe@example.com", - "label": "Work" - }, - { - "id": 2, - "type": "phone", - "value": "+49 123 456789", - "label": "Mobile" - } - ], - "notes": [ - { - "id": 1, - "content": "Initial contact", - "version": 1, - "created_at": "2025-06-19T10:00:00Z", - "created_by": "admin" - } - ] -} -``` - -#### POST /leads/api/contacts/{contact_id}/details -Add contact detail (phone/email). - -**Request:** -```json -{ - "type": "email", - "value": "secondary@example.com", - "label": "Secondary" -} -``` - -**Response:** -```json -{ - "id": 3, - "type": "email", - "value": "secondary@example.com", - "label": "Secondary" -} -``` - -### Resource Management API - -#### GET /api/resources/availability -Get resource availability for license allocation. - -**Response:** -```json -{ - "domains": { - "available": 150, - "total": 200, - "status": "ok" - }, - "ipv4": { - "available": 45, - "total": 100, - "status": "low" - }, - "phone_numbers": { - "available": 5, - "total": 50, - "status": "critical" - } -} -``` - -#### POST /api/resources/allocate -Allocate resources to a license. - -**Request:** -```json -{ - "license_id": 123, - "resource_type": "domain", - "resource_ids": [45, 46, 47] -} -``` - -**Response:** -```json -{ - "success": true, - "allocated": 3, - "message": "3 resources allocated successfully" -} -``` - -### Monitoring API - -#### GET /api/monitoring/dashboard -Get monitoring dashboard data. - -**Response:** -```json -{ - "metrics": { - "total_licenses": 1500, - "active_licenses": 1200, - "total_customers": 250, - "active_sessions": 890 - }, - "alerts": [ - { - "level": "warning", - "message": "High CPU usage on license server", - "timestamp": "2025-06-19T14:00:00Z" - } - ] -} -``` - -#### GET /api/sessions/active-count -Get count of active sessions. - -**Response:** -```json -{ - "count": 42 -} -``` - -### Monitoring API - -#### GET /api/monitoring/live-stats -Get live statistics for monitoring. - -**Response:** -```json -{ - "timestamp": "2025-06-19T14:30:00Z", - "metrics": { - "active_licenses": 850, - "total_activations": 2500, - "active_sessions": 1200, - "heartbeats_per_minute": 450 - }, - "alerts": [ - { - "type": "warning", - "message": "High CPU usage detected", - "timestamp": "2025-06-19T14:25:00Z" - } - ] -} -``` - -#### GET /api/monitoring/anomaly-stats -Get anomaly statistics. - -**Response:** -```json -{ - "total_anomalies": 15, - "unresolved": 3, - "by_type": { - "unusual_activation_pattern": 5, - "excessive_heartbeats": 3, - "license_hopping": 7 - } -} -``` - -#### GET /api/admin/license/auth-token -Get JWT token for analytics access. - -**Response:** -```json -{ - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "expires_at": "2025-06-19T15:30:00Z" -} -``` - -## Lead Management API - -### GET /leads/api/stats -Get lead statistics. - -**Response:** -```json -{ - "total_institutions": 150, - "total_contacts": 450, - "recent_activities": 25, - "conversion_rate": 12.5, - "by_type": { - "university": 50, - "company": 75, - "government": 25 - } -} -``` - -### Lead Routes (HTML Pages) -- `GET /leads/` - Lead overview page -- `GET /leads/create` - Create lead form -- `POST /leads/create` - Save new lead -- `GET /leads/edit/{lead_id}` - Edit lead form -- `POST /leads/update/{lead_id}` - Update lead -- `POST /leads/delete/{lead_id}` - Delete lead -- `GET /leads/export` - Export leads -- `POST /leads/import` - Import leads - -## Common Response Codes - -- `200 OK`: Successful request -- `201 Created`: Resource created -- `400 Bad Request`: Invalid request data -- `401 Unauthorized`: Missing or invalid authentication -- `403 Forbidden`: Insufficient permissions -- `404 Not Found`: Resource not found -- `409 Conflict`: Resource conflict (e.g., duplicate) -- `429 Too Many Requests`: Rate limit exceeded -- `500 Internal Server Error`: Server error - -## Rate Limiting -- API endpoints: 100 requests/minute -- Login attempts: 5 per minute -- Configurable via Admin Panel - -## Error Response Format -All errors return JSON with `error`, `code`, and `status` fields. - -## Client Integration - -Example request with required headers: -```bash -curl -X POST https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com/api/license/activate \ - -H "X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \ - -H "Content-Type: application/json" \ - -d '{ - "license_key": "XXXX-XXXX-XXXX-XXXX", - "hardware_hash": "unique-hardware-id", - "machine_name": "DESKTOP-123", - "app_version": "1.0.0" - }' -``` - -## Testing - -### Test Credentials -- Admin Users: - - Username: `rac00n` / Password: `1248163264` - - Username: `w@rh@mm3r` / Password: `Warhammer123!` -- API Key: Generated in Admin Panel under "Lizenzserver Administration" - -### Getting the Initial API Key -If you need to retrieve the API key directly from the database: -```bash -docker exec -it v2_postgres psql -U postgres -d v2_db -c "SELECT api_key FROM system_api_key WHERE id = 1;" -``` - -### Test Endpoints -- Admin Panel: `https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com/` -- License Server API: `https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com/` -- Monitoring: See OPERATIONS_GUIDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b1a1c0f..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,154 +0,0 @@ -# CLAUDE.md - AI Coding Assistant Guidelines - -## Core Principles -- **Structured Code First**: Write code that is well-organized from the start to avoid future refactoring -- **YAGNI (You Aren't Gonna Need It)**: Only implement what is currently needed, not what might be needed - -## Project Structure -``` -v2_adminpanel/ -├── routes/ # Blueprint route handlers -├── templates/ # Jinja2 templates -├── utils/ # Utilities -├── leads/ # CRM module (service/repository pattern) -├── core/ # Error handling, logging, monitoring -└── middleware/ # Request processing -``` - -## Database Schema Reference - -### Key Database Tables - -Refer to `v2_adminpanel/init.sql` for complete schema. Important tables: -- `license_heartbeats` - Partitioned by month, NO response_time column -- `license_sessions` - Active sessions (UNIQUE per license_id) -- `session_history` - Audit trail with end_reason -- `client_configs` - API configuration for Account Forger -- `system_api_key` - Global API key management - -Additional tables: customers, licenses, users, audit_log, lead_*, resource_pools, activations, feature_flags, rate_limits - -## Template Parameter Contracts - -### error.html -```python -render_template('error.html', - error='Error message', # NOT error_message! - details='Optional details', # Optional - error_code=404, # Optional - request_id='uuid' # Optional -) -``` - -### Common Template Parameters -- All templates expect `current_user` in session context -- Use `error` not `error_message` for error displays -- Flash messages use categories: 'success', 'error', 'warning', 'info' - -## Pre-Implementation Checklist - -### Pre-Implementation Checklist -- Check existing routes: `grep -r "route_name" .` -- Verify template parameters match expectations -- Confirm table/column exists in init.sql -- Use RealDictCursor and handle cleanup in finally blocks -- Check leads/ for existing repository methods - -### Before Modifying Templates -- [ ] Check which routes use this template -- [ ] Verify all passed parameters are used -- [ ] Maintain consistent styling with existing templates - -## Common Patterns - -### Error Handling -```python -try: - # operation -except Exception as e: - logger.error(f"Error in operation: {str(e)}") - return render_template('error.html', - error='Specific error message', - details=str(e)) -``` - -### Database Connections -```python -conn = get_db_connection() -cur = conn.cursor(cursor_factory=RealDictCursor) -try: - # queries - conn.commit() -finally: - cur.close() - conn.close() -``` - -### API Authentication -```python -# Check API key -api_key = request.headers.get('X-API-Key') -if not api_key or not verify_api_key(api_key): - return jsonify({'error': 'Invalid API key'}), 401 -``` - -### Session Management -```python -# For user sessions -if 'user_id' not in session: - return redirect(url_for('auth.login')) - -# For 2FA -if session.get('requires_2fa'): - return redirect(url_for('auth.verify_2fa')) -``` - -## Testing & Verification - -### Check Logs -```bash -docker-compose logs admin-panel | tail -50 -``` - -### Verify Container Status -```bash -docker-compose ps -``` - -### Common Issues to Avoid -1. **Parameter Mismatches**: Verify template expectations (use `error` not `error_message`) -2. **Missing Columns**: Check schema before queries -3. **Creating Unnecessary Files**: Check if functionality exists first -4. **Missing Audit Logs**: Add audit_log entries for important actions -5. **Hardcoded Values**: Use config.py or environment variables - -## Docker Environment -Container names: v2_admin_panel, v2_license_server, v2_postgres, v2_redis, v2_rabbitmq, v2_nginx -Public access: Port 80 via Nginx - -## Code Style Rules -- NO comments unless explicitly requested -- Follow existing patterns in the codebase -- Use existing utilities before creating new ones -- Maintain consistent error handling -- Always use absolute paths for file operations - -## YAGNI Reminders -- Don't add features "for the future" -- Don't create generic solutions for single use cases -- Don't add configuration options that aren't needed now -- Don't abstract code that's only used once -- Implement exactly what's requested, nothing more - -## Recent Updates - -### June 22, 2025 - 20:26 -- Added Lead Management to main navigation (above Ressourcen Pool) -- Created Lead Management dashboard with: - - Overview statistics (institutions, contacts, user attribution) - - Recent activity feed showing who added/edited what - - Quick actions (add institution, view all, export) - - Shared information view between users rac00n and w@rh@mm3r -- Route: `/leads/management` accessible via navbar "Lead Management" - -## Last Updated: June 22, 2025 \ No newline at end of file diff --git a/JOURNAL.md b/JOURNAL.md deleted file mode 100644 index 36d3091..0000000 --- a/JOURNAL.md +++ /dev/null @@ -1,3217 +0,0 @@ -# v2-Docker Projekt Journal - -## Letzte Änderungen (22.06.2025 - 18:30 Uhr) - -### Export-Funktionen komplett repariert ✅ - -**Probleme gefunden und behoben:** - -1. **Parameter-Mismatch**: Templates übergaben `include_test`, Routes erwarteten `show_fake` -2. **Bootstrap Dropdowns funktionierten nicht**: JavaScript-Konflikt verhinderte Dropdown-Öffnung -3. **Excel Timezone-Fehler**: "Excel does not support datetimes with timezones" - -**Implementierte Lösungen:** - -1. **Nur echte Daten beim Export:** - - Alle Export-Queries filtern jetzt mit `WHERE is_fake = false` - - Keine Test/Fake-Daten mehr in Exporten - -2. **Dropdown durch direkte Buttons ersetzt:** - - Statt problematischer Dropdowns: Einzelne Export-Buttons - - Funktioniert ohne JavaScript, zuverlässiger - - Übersichtlicher: alle Optionen sofort sichtbar - -3. **Datetime-Formatierung für Excel-Kompatibilität:** - - `format_datetime_for_export()` entfernt Zeitzonen-Info - - Alle Datetime-Felder werden vor Export formatiert - - Einheitliches Format: `YYYY-MM-DD HH:MM:SS` - -**Geänderte Dateien:** -- `templates/customers_licenses.html` - Export-Buttons statt Dropdown -- `templates/audit_log.html` - Export-Buttons statt Dropdown -- `templates/sessions.html` - Export-Buttons statt Dropdown -- `routes/export_routes.py` - Datetime-Formatierung für alle Exports -- `utils/export.py` - Timezone-Entfernung in format_datetime_for_export() - -**Status:** Alle Export-Funktionen (Excel & CSV) funktionieren einwandfrei! ✅ - ---- - -## Letzte Änderungen (22.06.2025 - 16:49 Uhr) - -### Export-Funktionen Analyse und Lösungsplan ✅ - -**Problem:** -- CSV Export Buttons vorhanden, aber Backend liefert immer Excel-Dateien -- Monitoring Export zeigt nur Platzhalter-Alerts ("Export-Funktion wird implementiert") -- Leads/CRM Module hat keine Export-Funktionalität -- Format-Parameter wird in Export-Routes ignoriert - -**Analyse-Ergebnisse:** -1. Excel-Exporte funktionieren für: Lizenzen, Kunden, Sessions, Audit Logs, Ressourcen -2. Export-Routes in `export_routes.py` prüfen nie den `format=csv` Parameter -3. Nur `create_excel_export()` existiert, keine CSV-Generierung implementiert -4. Monitoring-Exporte haben nur JavaScript-Platzhalter ohne Backend -5. Lead Management hat keine Export-Funktionalität - -**Lösungsplan (YAGNI & Strukturiert):** - -1. **CSV Export Fix (Priorität 1)** - - Format-Parameter in bestehenden Export-Routes prüfen - - CSV als Alternative zu Excel hinzufügen (Excel bleibt Default) - - Python's eingebautes csv-Modul nutzen, keine neuen Dependencies - - Minimale Änderung: ~10 Zeilen pro Route - -2. **Monitoring Export (Priorität 2)** - - Neue Route `/export/monitoring` nach bestehendem Muster - - Daten von existierenden Monitoring-Endpoints nutzen - - Excel und CSV Format unterstützen - -3. **Lead Export (Priorität 3)** - - Route `/leads/export` zum Lead Blueprint hinzufügen - - Institutionen mit Kontakt-Anzahl exportieren - - Gleiches Muster wie andere Exporte verwenden - -**Vorteile dieser Lösung:** -- Keine Refaktorierung nötig -- Bestehende Excel-Exporte bleiben unverändert -- Konsistentes URL-Muster mit format-Parameter -- Rückwärtskompatibel (Excel als Standard) -- Einfach erweiterbar für zukünftige Formate - -**Implementierung abgeschlossen:** - -1. **CSV Export Support hinzugefügt:** - - Neue Funktion `create_csv_export()` in `utils/export.py` - - Alle Export-Routes prüfen jetzt den `format` Parameter - - CSV-Dateien mit UTF-8 BOM für Excel-Kompatibilität - -2. **Monitoring Export implementiert:** - - Neue Route `/export/monitoring` in `export_routes.py` - - Exportiert Heartbeats und optional Anomalien - - JavaScript-Funktionen in Templates aktualisiert - -3. **Lead Export hinzugefügt:** - - Neue Route `/leads/export` in `leads/routes.py` - - Exportiert Institutionen mit Kontakt-Statistiken - - Export-Buttons zu Institutions-Template hinzugefügt - -**Geänderte Dateien:** -- `utils/export.py` - CSV-Export-Funktion hinzugefügt -- `routes/export_routes.py` - Format-Parameter-Prüfung für alle Routes -- `routes/export_routes.py` - Monitoring-Export hinzugefügt -- `leads/routes.py` - Lead-Export-Route hinzugefügt -- `templates/monitoring/analytics.html` - Export-Funktionen aktualisiert -- `templates/monitoring/live_dashboard.html` - Export-Funktionen aktualisiert -- `leads/templates/leads/institutions.html` - Export-Buttons hinzugefügt - -**Testing abgeschlossen:** -- Alle Export-Routes sind verfügbar und funktionieren -- CSV-Export generiert korrekte CSV-Dateien mit UTF-8 BOM -- Excel bleibt der Standard wenn kein format-Parameter angegeben -- Container wurde neu gebaut und deployed -- Alle 7 Export-Endpoints unterstützen beide Formate: - - `/export/licenses` - - `/export/customers` - - `/export/sessions` - - `/export/audit` - - `/export/resources` - - `/export/monitoring` - - `/leads/export` - ---- - -## Letzte Änderungen (22.06.2025 - 16:35 Uhr) - -### Lizenzfilter System komplett überarbeitet ✅ - -**Problem:** -- Checkbox-basiertes Filtersystem war unübersichtlich und fummelig -- "Fake-Daten anzeigen" Checkbox funktionierte nicht richtig -- "Läuft bald ab" Status machte keinen Sinn (inaktive Lizenzen können nicht ablaufen) - -**Lösung 1 - Neues Dropdown-System:** -- Checkbox-Filter ersetzt durch 3 klare Dropdowns: - - Datenquelle: Echte Lizenzen / 🧪 Fake-Daten / Alle Daten - - Lizenztyp: Alle Typen / Vollversion / Testversion - - Status: Alle Status / ✅ Aktiv / ⚠️ Abgelaufen / ❌ Deaktiviert -- Auto-Submit bei Änderung -- Übersichtlicher "Filter zurücksetzen" Button - -**Lösung 2 - API Bug Fix:** -- SQLAlchemy Fehler behoben: `text()` Wrapper für Raw SQL Queries hinzugefügt -- License Server API funktioniert jetzt korrekt - -**Lösung 3 - Status-Logik korrigiert:** -- "Läuft bald ab" komplett entfernt (gehört nur ins Dashboard als Hinweis) -- Klare Trennung der 3 Status: - - Aktiv = `is_active=true` (egal ob abgelaufen) - - Abgelaufen = `valid_until <= heute` (läuft aber weiter bis manuell deaktiviert) - - Deaktiviert = `is_active=false` (manuell gestoppt) -- Lizenzen laufen nach Ablauf weiter bis zur manuellen Deaktivierung - -**Geänderte Dateien:** -- `templates/licenses.html` - Komplettes Filter-UI überarbeitet -- `routes/license_routes.py` - Filter-Logik angepasst -- `v2_lizenzserver/app/core/api_key_auth.py` - SQL Bug behoben - ---- - -## Letzte Änderungen (22.06.2025 - 13:27 Uhr) - -### Bug Fix: API Key Anzeige in Administration - -**Problem:** -- "Kein System API Key gefunden!" wurde angezeigt obwohl Key existierte -- Query versuchte noch die gelöschte `api_key` Spalte aus `client_configs` zu lesen - -**Lösung:** -- SELECT Statement in `admin_routes.py` korrigiert (api_key entfernt) -- Template Indizes angepasst (current_version: [5]→[4], minimum_version: [6]→[5]) -- Admin Panel neu gestartet - -**Status:** ✅ API Key wird jetzt korrekt angezeigt - ---- - -## Letzte Änderungen (22.06.2025 - 13:07 Uhr) - -### Doppeltes API Key System entfernt ✅ - -**Problem:** -- Zwei verschiedene API Keys wurden angezeigt: - - `system_api_key` Tabelle: Globaler System API Key - - `client_configs` Tabelle: Account Forger spezifischer API Key -- Verwirrung welcher Key verwendet werden soll - -**Lösung:** -- Da Admin Panel exklusiv für Account Forger ist, nur noch ein API Key System -- `api_key` Spalte aus `client_configs` entfernt -- UI zeigt nur noch den System API Key als "API Key für Account Forger" -- License Server validiert bereits gegen `system_api_key` - -**Geänderte Dateien:** -- `templates/license_config.html` - Entfernt doppelte API Key Anzeige -- `migrations/remove_duplicate_api_key.sql` - Migration erstellt -- Datenbank aktualisiert - -### Orphaned API Tabellen entfernt ✅ - -**Entfernte Tabellen:** -- `api_keys` - Ungenutzte API Key Tabelle (war leer) -- `api_clients` - Alternative API Client Verwaltung (war leer) -- `rate_limits` - Abhängige Tabelle (war leer) -- `license_events` - Abhängige Tabelle (war leer) - -**Resultat:** -- Nur noch `system_api_key` Tabelle existiert -- Keine verwirrenden Duplikate mehr -- Saubere, eindeutige API Key Verwaltung - ---- - -## Letzte Änderungen (22.06.2025 - 12:18 Uhr) - -### Lizenzserver Session Management - Vollständig implementiert ✅ - -**Implementierte Features:** -1. **Single-Session Enforcement**: - - Nur eine aktive Sitzung pro Lizenz erlaubt - - Deutsche Fehlermeldung bei Mehrfach-Login-Versuch - - Session-Token basiertes System mit UUID - -2. **Heartbeat System**: - - 30-Sekunden Heartbeat-Intervall - - Automatische Session-Bereinigung nach 60 Sekunden Inaktivität - - Background Job für Session-Cleanup - -3. **Session Management Endpoints**: - - `POST /api/license/session/start` - Session initialisierung - - `POST /api/license/session/heartbeat` - Keep-alive - - `POST /api/license/session/end` - Sauberes Session-Ende - - Vollständige Session-Historie in `session_history` Tabelle - -4. **Admin Panel Integration**: - - Lizenzserver Administration mit API-Key Management - - Live Session Monitor mit Auto-Refresh - - Session-Terminierung durch Admins - - Version Management (Current/Minimum) - -5. **Datenbank-Schema**: - - `client_configs` - Zentrale Konfiguration - - `license_sessions` - Aktive Sessions (UNIQUE per license_id) - - `session_history` - Audit Trail mit end_reason - - `system_api_key` - Globaler API Key - -**Status**: ✅ Vollständig implementiert und produktionsbereit - -### Dokumentation vollständig aktualisiert - -**Aktualisierte Dateien:** -1. **OPERATIONS_GUIDE.md**: - - Korrekte Container-Namen (v2_*) - - Aktuelle Service-Konfigurationen - - Neue Features dokumentiert (Leads, Resources, Monitoring) - - Health-Check Befehle aktualisiert - -2. **CLAUDE.md**: - - Vollständige Projektstruktur mit allen Modulen - - Alle Datenbank-Tabellen dokumentiert - - Session Management Patterns - - Erweiterte Common Issues Liste - -3. **TODO_LIZENZSERVER_CONFIG.md**: - - Als abgeschlossen markiert - - Kann archiviert/gelöscht werden - -**Alle Dokumentationen aktualisiert:** -- SYSTEM_DOCUMENTATION.md ✅ Vollständig aktualisiert -- API_REFERENCE.md ✅ Alle Endpoints dokumentiert -- TODO_LIZENZSERVER_CONFIG.md ✅ Gelöscht (da abgeschlossen) - -### Dokumentation bereinigt und komprimiert - -**Reduzierte Dateigröße:** -- OPERATIONS_GUIDE.md: Von 501 auf 409 Zeilen (-18%) -- CLAUDE.md: Von ~250 auf 142 Zeilen (-43%) -- SYSTEM_DOCUMENTATION.md: Von ~350 auf 243 Zeilen (-31%) -- API_REFERENCE.md: Von ~1057 auf 815 Zeilen (-23%) - -**Entfernt:** -- Redundante YAML-Konfigurationen (verweisen auf docker-compose.yaml) -- Verbose Code-Beispiele (durch kompakte Referenzen ersetzt) -- Zukünftige/nicht implementierte Features -- Duplizierte Informationen zwischen Dateien -- Übermäßig detaillierte JSON-Beispiele - -**Fokus auf:** -- Tatsächlich implementierte Features -- Praktische Operational-Informationen -- Kompakte API-Referenzen -- Verweise auf Source-Dateien statt Duplikation - ---- - -## Letzte Änderungen (19.06.2025 - 20:30 Uhr) - -### Dokumentation aktualisiert und mit Realität abgeglichen -- **API_REFERENCE.md komplett überarbeitet**: - - Tatsächliche Lizenzserver-Endpunkte dokumentiert (nicht mehr v1) - - Korrekte Ports und URLs eingetragen - - Admin Panel API vollständig dokumentiert - - Nicht implementierte Endpunkte entfernt - -- **SYSTEM_DOCUMENTATION.md aktualisiert**: - - Microservices-Status korrigiert (nur License Server & Admin Panel aktiv) - - Analytics, Admin API und Auth Service als "geplant" markiert - - Implementierungsstatus auf aktuellen Stand gebracht - - Lead Management als "vollständig implementiert" dokumentiert - -- **OPERATIONS_GUIDE.md korrigiert**: - - Echte Docker-Container-Namen verwendet - - Korrekte Ports und Netzwerk-Konfiguration - - Aktuelle Monitoring-Stack-Services dokumentiert - - Troubleshooting-Befehle an echte Container angepasst - -### Status: -✅ Dokumentation spiegelt nun den tatsächlichen Projektzustand wider -✅ Keine falschen oder veralteten Informationen mehr -✅ Alle drei Haupt-Dokumentationen sind aktuell - ---- - -## Letzte Änderungen (19.06.2025 - 19:20 Uhr) - -### Bugfix: Kunden & Lizenzen API-Fehler behoben -- **Problem**: 500 Fehler beim Klicken auf Kunden in der "Kunden & Lizenzen" Ansicht -- **Ursache**: SQL-Abfrage versuchte auf nicht-existierende Tabellen und Spalten zuzugreifen: - - `license_heartbeats` Tabelle existiert noch nicht (wird mit License Server implementiert) - - `anomaly_detections` Tabelle existiert noch nicht - - Zu komplexe Subqueries führten zu Datenbankfehlern - -- **Lösung implementiert**: - - SQL-Abfrage in `api_customer_licenses` vereinfacht - - Entfernt: Alle Referenzen zu noch nicht existierenden Tabellen - - Platzhalter-Werte (0) für License Server Statistiken eingefügt - - Bessere Fehlerbehandlung mit detaillierten Fehlermeldungen - -- **Geänderte Dateien**: - - `v2_adminpanel/routes/customer_routes.py` - Vereinfachte SQL-Abfrage ohne Subqueries - -### Status: -✅ Kunden & Lizenzen Ansicht funktioniert wieder einwandfrei -✅ API gibt korrekte Daten zurück -✅ Keine Abhängigkeit von noch nicht implementierten Tabellen - ---- - -## Letzte Änderungen (19.06.2025 - 15:07 Uhr) - -### Lead-Management System implementiert -- **Komplett neues CRM-Modul für potentielle Kunden**: - - Separates `leads` Modul ohne Navbar-Eintrag - - Zugang über "Leads" Button auf Kunden & Lizenzen Seite - - Vollständig getrennt vom bestehenden Kundensystem - -- **Refactoring-freie Architektur von Anfang an**: - - Service Layer Pattern für Business Logic - - Repository Pattern für Datenbankzugriffe - - RESTful API Design - - JSONB Felder für zukünftige Erweiterungen ohne Schema-Änderungen - - Event-System vorbereitet für spätere Integrationen - -- **Datenmodell (vereinfacht aber erweiterbar)**: - - `lead_institutions`: Nur Name erforderlich - - `lead_contacts`: Kontaktpersonen mit Institution - - `lead_contact_details`: Flexible Telefon/E-Mail Verwaltung (beliebig viele) - - `lead_notes`: Versionierte Notizen mit vollständiger Historie - -- **Features**: - - Institutionen-Verwaltung mit Kontakt-Zähler - - Kontaktpersonen mit Position (Freitext) - - Mehrere Telefonnummern/E-Mails pro Person mit Labels - - Notiz-Historie mit Zeitstempel und Benutzer-Tracking - - Notizen können bearbeitet werden (neue Version wird erstellt) - - Vollständige Audit-Trail Integration - -- **Migration bereitgestellt**: - - SQL-Script: `migrations/create_lead_tables.sql` - - Python-Script: `apply_lead_migration.py` - - Anwendung: `docker exec -it v2_adminpanel python apply_lead_migration.py` - -### Status: -✅ Lead-Management vollständig implementiert -✅ Refactoring-freie Architektur umgesetzt -✅ Keine Breaking Changes möglich durch Design -✅ Bereit für produktiven Einsatz - ---- - -## Letzte Änderungen (19.06.2025 - 13:15 Uhr) - -### License Heartbeats Tabelle und Dashboard-Konsolidierung -- **Fehlende `license_heartbeats` Tabelle erstellt**: - - Migration-Script für partitionierte Tabellenstruktur (monatliche Partitionen) - - Automatische Partition-Erstellung für aktuellen und nächsten Monat - - Performance-optimierte Indizes und Foreign Keys - - Anwendbar via: `docker exec -it v2_adminpanel python apply_license_heartbeats_migration.py` - -- **Live Dashboard & Analytics zusammengeführt**: - - Alle Analytics-Funktionen ins Live Dashboard integriert - - 3-Tab-Struktur: Übersicht, Sessions, Analytics - - Echtzeit-Monitoring + historische Daten an einem Ort - - Export-Funktionen beibehalten - -- **Navigation weiter optimiert**: - - "Analytics" aus Navigation entfernt (in Live Dashboard integriert) - - Redundanten "Live Dashboard & Analytics" Menüpunkt entfernt - - Monitoring führt direkt zum kombinierten Dashboard - -### Status: -✅ License Heartbeats Infrastruktur implementiert -✅ Dashboard-Konsolidierung abgeschlossen -✅ Navigation maximal vereinfacht -✅ Alle Monitoring-Features an einem zentralen Ort - ---- - -## Letzte Änderungen (19.06.2025 - 12:46 Uhr) - -### Navigation komplett überarbeitet -- **500 Fehler auf /live-dashboard behoben**: - - Datenbankabfrage korrigiert (falsche Spaltenreferenz `c.contact_person` entfernt) - - Alle Referenzen nutzen jetzt korrekt `c.name as company_name` - -- **Navigation aufgeräumt und reorganisiert**: - - "Admin Sessions" komplett entfernt (nicht benötigt) - - "Alerts & Anomalien" entfernt (redundant zu "Lizenz-Anomalien") - - "Lizenzserver Status" und "Analytics" zu einer Seite zusammengeführt - - "Live Dashboard" aus Submenu entfernt (Monitoring führt direkt dorthin) - - Neuer "Administration" Bereich erstellt (führt zu Lizenzserver Config) - -- **Neue Navigationsstruktur**: - ``` - 📊 Monitoring → (Live Dashboard) - ├── System Status - ├── Lizenz-Anomalien - └── Analytics (kombiniert) - - 🔧 Administration → (Lizenzserver Config) - ├── Audit-Log - ├── Backups - └── Gesperrte IPs - ``` - -- **Weitere Verbesserungen**: - - Grafana-Link aus System Status entfernt - - Session-Route-Fehler behoben (`admin.sessions` → `sessions.sessions`) - - Klarere Trennung zwischen operativem Monitoring und Admin-Tools - -### Status: -✅ Navigation ist jetzt intuitiv und aufgeräumt -✅ Alle 500 Fehler behoben -✅ Redundante Menüpunkte eliminiert -✅ Admin-Tools klar von Monitoring getrennt - ---- - -## Letzte Änderungen (19.06.2025) - -### Monitoring vereinfacht und optimiert -- **Prometheus/Grafana/Alertmanager entfernt**: - - Monitoring Stack aus docker-compose.yaml entfernt (spart ~3GB RAM) - - Vereinfacht das Setup für PoC-Phase erheblich - - Alle wichtigen Monitoring-Features bleiben über Admin Panel verfügbar - -- **Analytics-Seite überarbeitet**: - - Demo-Daten und statische Charts entfernt - - Revenue/Pricing-Metriken entfernt (Preismodell noch in Entwicklung) - - Zeigt jetzt echte Live-Statistiken aus der Datenbank - - Automatische Aktualisierung alle 30 Sekunden - - Verweis auf Live Dashboard für Echtzeit-Daten - -- **Integriertes Monitoring bleibt funktional**: - - Live Dashboard mit aktiven Sessions und Heartbeats - - System Status mit Service Health Checks - - Alerts aus anomaly_detections Tabelle - - Alle Daten direkt aus PostgreSQL ohne externe Dependencies - -### Status: -✅ Monitoring für PoC optimiert -✅ Analytics zeigt echte Daten statt Demo-Werte -✅ System ~3GB schlanker ohne externe Monitoring-Tools -✅ Alle wichtigen Features weiterhin verfügbar - ---- - -## Letzte Änderungen (18.06.2025) - -### Große Refaktorisierung erfolgreich abgeschlossen -- **Datenbankfeld-Inkonsistenzen behoben**: - - 91 falsche Feldnamen korrigiert (83 automatisch + 8 manuell) - - Hauptproblem: `active` → `is_active`, `device_id` → `hardware_id` - - Sessions-Tabelle: Alle Zeit-Felder vereinheitlicht (`login_time` → `started_at`, etc.) - - Status-Toggle-Bug behoben - funktioniert jetzt korrekt - -- **Code-Bereinigung**: - - 15 obsolete Dateien gelöscht (Backups, Migrations-Scripts, Dokumentation) - - 5 überflüssige .md Dateien entfernt - - Saubere Verzeichnisstruktur ohne temporäre Dateien - -- **Funktionale Verbesserungen**: - - Testkunden-Erstellung gefixt (is_test Flag wird jetzt korrekt verarbeitet) - - Audit-Log Dropdown erweitert: von 18 auf 37 Aktionen - - Neue Gruppierung im Audit-Log für bessere Übersicht - - Alle Route-Referenzen korrigiert (`customers.customers` → `customers.customers_licenses`) - -- **Technische Details**: - - Alle Python-Abhängigkeiten funktionieren korrekt - - Datenbank-Foreign Keys alle intakt - - Blueprint-Registrierung erfolgreich - - Keine zirkulären Imports mehr - -### Status: -✅ Anwendung vollständig funktionsfähig -✅ Alle bekannten Bugs behoben -✅ Code-Qualität deutlich verbessert -✅ Wartbarkeit erhöht durch konsistente Namensgebung - ---- - -## Vorherige Änderungen (06.01.2025) - -### Gerätelimit-Feature implementiert -- **Datenbank-Schema erweitert**: - - Neue Spalte `device_limit` in `licenses` Tabelle (Standard: 3, Range: 1-10) - - Neue Tabelle `device_registrations` für Hardware-ID Tracking - - Indizes für Performance-Optimierung hinzugefügt - -- **UI-Anpassungen**: - - Einzellizenz-Formular: Dropdown für Gerätelimit (1-10 Geräte) - - Batch-Formular: Gerätelimit pro Lizenz auswählbar - - Lizenz-Bearbeitung: Gerätelimit änderbar - - Lizenz-Anzeige: Zeigt aktive Geräte (z.B. "💻 2/3") - -- **Backend-Änderungen**: - - Lizenz-Erstellung speichert device_limit - - Batch-Erstellung berücksichtigt device_limit - - Lizenz-Update kann device_limit ändern - - API-Endpoints liefern Geräteinformationen - -- **Migration**: - - Device-Limit wird automatisch bei neuen Lizenzen gesetzt - - Standard device_limit = 3 für alle Lizenzen - -### Vollständig implementiert: -✅ Device Management UI (Geräte pro Lizenz anzeigen/verwalten) -✅ Device Validation Logic (Prüfung bei Geräte-Registrierung) -✅ API-Endpoints für Geräte-Registrierung/Deregistrierung - -### API-Endpoints: -- `GET /api/license//devices` - Listet alle Geräte einer Lizenz -- `POST /api/license//register-device` - Registriert ein neues Gerät -- `POST /api/license//deactivate-device/` - Deaktiviert ein Gerät - -### Features: -- Geräte-Registrierung mit Hardware-ID Validierung -- Automatische Prüfung des Gerätelimits -- Reaktivierung deaktivierter Geräte möglich -- Geräte-Verwaltung UI mit Modal-Dialog -- Anzeige von Gerätename, OS, IP, Registrierungsdatum -- Admin kann Geräte manuell deaktivieren - ---- - -## Projektübersicht -Lizenzmanagement-System für Social Media Account-Erstellungssoftware mit Docker-basierter Architektur. - -### Technische Anforderungen -- **Lokaler Betrieb**: Docker mit 4GB RAM und 40GB Speicher -- **Internet-Zugriff**: - - Admin Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com - - API Server: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com -- **Datenbank**: PostgreSQL mit 2 Admin-Usern -- **Ziel**: PoC für spätere VPS-Migration - ---- - -## Best Practices für Produktiv-Migration - -### Passwort-Management -Für die Migration auf Hetzner/VPS müssen die Credentials sicher verwaltet werden: - -1. **Environment Variables erstellen:** - ```bash - # .env.example (ins Git Repository) - POSTGRES_USER=changeme - POSTGRES_PASSWORD=changeme - POSTGRES_DB=changeme - SECRET_KEY=generate-a-secure-key - ADMIN_USER_1=changeme - ADMIN_PASS_1=changeme - ADMIN_USER_2=changeme - ADMIN_PASS_2=changeme - - # .env (NICHT ins Git, auf Server erstellen) - POSTGRES_USER=produktiv_user - POSTGRES_PASSWORD=sicheres_passwort_min_20_zeichen - POSTGRES_DB=v2docker_prod - SECRET_KEY=generierter_64_zeichen_key - # etc. - ``` - -2. **Sichere Passwörter generieren:** - - Mindestens 20 Zeichen - - Mix aus Groß-/Kleinbuchstaben, Zahlen, Sonderzeichen - - Verschiedene Passwörter für Dev/Staging/Prod - - Password-Generator verwenden (z.B. `openssl rand -base64 32`) - -3. **Erweiterte Sicherheit (Optional):** - - HashiCorp Vault für zentrale Secret-Verwaltung - - Docker Secrets (für Docker Swarm) - - Cloud-Lösungen: AWS Secrets Manager, Azure Key Vault - -4. **Wichtige Checkliste:** - - [ ] `.env` in `.gitignore` aufnehmen - - [ ] Neue Credentials für Produktion generieren - - [ ] Backup der Credentials an sicherem Ort - - [ ] Regelmäßige Passwort-Rotation planen - - [ ] Keine Default-Passwörter verwenden - ---- - -## Änderungsprotokoll - -### 2025-01-06 - Journal erstellt -- Initialer Projektstand dokumentiert -- Aufgabenliste priorisiert -- Technische Anforderungen festgehalten - -### 2025-01-06 - UTF-8 Support implementiert -- Flask App Konfiguration für UTF-8 hinzugefügt (JSON_AS_ASCII=False) -- PostgreSQL Verbindung mit UTF-8 client_encoding -- HTML Forms mit accept-charset="UTF-8" -- Dockerfile mit deutschen Locale-Einstellungen (de_DE.UTF-8) -- PostgreSQL Container mit UTF-8 Initialisierung -- init.sql mit SET client_encoding = 'UTF8' - -**Geänderte Dateien:** -- v2_adminpanel/app.py -- v2_adminpanel/templates/index.html -- v2_adminpanel/init.sql -- v2_adminpanel/Dockerfile -- v2/docker-compose.yaml - -**Nächster Test:** -- Container neu bauen und starten -- Kundennamen mit Umlauten testen (z.B. "Müller GmbH", "Björn Schäfer") -- Email mit Umlauten testen - -### 2025-01-06 - Lizenzübersicht implementiert -- Neue Route `/licenses` für Lizenzübersicht -- SQL-Query mit JOIN zwischen licenses und customers -- Status-Berechnung (aktiv, läuft bald ab, abgelaufen) -- Farbcodierung für verschiedene Status -- Navigation zwischen Lizenz erstellen und Übersicht - -**Neue Features:** -- Anzeige aller Lizenzen mit Kundeninformationen -- Status-Anzeige basierend auf Ablaufdatum -- Unterscheidung zwischen Voll- und Testversion -- Responsive Tabelle mit Bootstrap -- Link von Dashboard zur Übersicht und zurück - -**Geänderte/Neue Dateien:** -- v2_adminpanel/app.py (neue Route hinzugefügt) -- v2_adminpanel/templates/licenses.html (neu erstellt) -- v2_adminpanel/templates/index.html (Navigation ergänzt) - -**Nächster Test:** -- Container neu starten -- Mehrere Lizenzen mit verschiedenen Ablaufdaten erstellen -- Lizenzübersicht unter /licenses aufrufen - -### 2025-01-06 - Lizenz bearbeiten/löschen implementiert -- Neue Routen für Bearbeiten und Löschen von Lizenzen -- Bearbeitungsformular mit vorausgefüllten Werten -- Aktiv/Inaktiv-Status kann geändert werden -- Lösch-Bestätigung per JavaScript confirm() -- Kunde kann nicht geändert werden (nur Lizenzdetails) - -**Neue Features:** -- `/license/edit/` - Bearbeitungsformular -- `/license/delete/` - Lizenz löschen (POST) -- Aktionen-Spalte in der Lizenzübersicht -- Buttons für Bearbeiten und Löschen -- Checkbox für Aktiv-Status - -**Geänderte/Neue Dateien:** -- v2_adminpanel/app.py (edit_license und delete_license Routen) -- v2_adminpanel/templates/licenses.html (Aktionen-Spalte hinzugefügt) -- v2_adminpanel/templates/edit_license.html (neu erstellt) - -**Sicherheit:** -- Login-Required für alle Aktionen -- POST-only für Löschvorgänge -- Bestätigungsdialog vor dem Löschen - -### 2025-01-06 - Kundenverwaltung implementiert -- Komplette CRUD-Funktionalität für Kunden -- Übersicht zeigt Anzahl aktiver/gesamter Lizenzen pro Kunde -- Kunden können nur gelöscht werden, wenn sie keine Lizenzen haben -- Bearbeitungsseite zeigt alle Lizenzen des Kunden - -**Neue Features:** -- `/customers` - Kundenübersicht mit Statistiken -- `/customer/edit/` - Kunde bearbeiten (Name, E-Mail) -- `/customer/delete/` - Kunde löschen (nur ohne Lizenzen) -- Navigation zwischen allen drei Hauptbereichen -- Anzeige der Kundenlizenzen beim Bearbeiten - -**Geänderte/Neue Dateien:** -- v2_adminpanel/app.py (customers, edit_customer, delete_customer Routen) -- v2_adminpanel/templates/customers.html (neu erstellt) -- v2_adminpanel/templates/edit_customer.html (neu erstellt) -- v2_adminpanel/templates/index.html (Navigation erweitert) -- v2_adminpanel/templates/licenses.html (Navigation erweitert) - -**Besonderheiten:** -- Lösch-Button ist deaktiviert, wenn Kunde Lizenzen hat -- Aktive Lizenzen werden separat gezählt (nicht abgelaufen + aktiv) -- UTF-8 Support für Kundennamen mit Umlauten - -### 2025-01-06 - Dashboard mit Statistiken implementiert -- Übersichtliches Dashboard als neue Startseite -- Statistik-Karten mit wichtigen Kennzahlen -- Listen für bald ablaufende und zuletzt erstellte Lizenzen -- Routing angepasst: Dashboard (/) und Lizenz erstellen (/create) - -**Neue Features:** -- Statistik-Karten: Kunden, Lizenzen gesamt, Aktive, Ablaufende -- Aufteilung nach Lizenztypen (Vollversion/Testversion) -- Aufteilung nach Status (Aktiv/Abgelaufen) -- Top 10 bald ablaufende Lizenzen mit Restlaufzeit -- Letzte 5 erstellte Lizenzen mit Status -- Hover-Effekt auf Statistik-Karten -- Einheitliche Navigation mit Dashboard-Link - -**Geänderte/Neue Dateien:** -- v2_adminpanel/app.py (dashboard() komplett überarbeitet, create_license() Route) -- v2_adminpanel/templates/dashboard.html (neu erstellt) -- v2_adminpanel/templates/index.html (Navigation erweitert) -- v2_adminpanel/templates/licenses.html (Navigation angepasst) -- v2_adminpanel/templates/customers.html (Navigation angepasst) - -**Dashboard-Inhalte:** -- 4 Hauptstatistiken als Karten -- Lizenztyp-Verteilung -- Status-Verteilung -- Warnung für bald ablaufende Lizenzen -- Übersicht der neuesten Aktivitäten - -### 2025-01-06 - Suchfunktion implementiert -- Volltextsuche für Lizenzen und Kunden -- Case-insensitive Suche mit LIKE-Operator -- Suchergebnisse mit Hervorhebung des Suchbegriffs -- Suche zurücksetzen Button - -**Neue Features:** -- **Lizenzsuche**: Sucht in Lizenzschlüssel, Kundenname und E-Mail -- **Kundensuche**: Sucht in Kundenname und E-Mail -- Suchformular mit autofocus für schnelle Eingabe -- Anzeige des aktiven Suchbegriffs -- Unterschiedliche Meldungen für leere Ergebnisse - -**Geänderte Dateien:** -- v2_adminpanel/app.py (licenses() und customers() mit Suchlogik erweitert) -- v2_adminpanel/templates/licenses.html (Suchformular hinzugefügt) -- v2_adminpanel/templates/customers.html (Suchformular hinzugefügt) - -**Technische Details:** -- GET-Parameter für Suche -- SQL LIKE mit LOWER() für Case-Insensitive Suche -- Wildcards (%) für Teilstring-Suche -- UTF-8 kompatibel für deutsche Umlaute - -### 2025-01-06 - Filter und Pagination implementiert -- Erweiterte Filteroptionen für Lizenzübersicht -- Pagination für große Datenmengen (20 Einträge pro Seite) -- Filter bleiben bei Seitenwechsel erhalten - -**Neue Features für Lizenzen:** -- **Filter nach Typ**: Alle, Vollversion, Testversion -- **Filter nach Status**: Alle, Aktiv, Läuft bald ab, Abgelaufen, Deaktiviert -- **Kombinierbar mit Suche**: Filter und Suche funktionieren zusammen -- **Pagination**: Navigation durch mehrere Seiten -- **Ergebnisanzeige**: Zeigt Anzahl gefilterter Ergebnisse - -**Neue Features für Kunden:** -- **Pagination**: 20 Kunden pro Seite -- **Seitennavigation**: Erste, Letzte, Vor, Zurück -- **Kombiniert mit Suche**: Suchparameter bleiben erhalten - -**Geänderte Dateien:** -- v2_adminpanel/app.py (licenses() und customers() mit Filter/Pagination erweitert) -- v2_adminpanel/templates/licenses.html (Filter-Formular und Pagination hinzugefügt) -- v2_adminpanel/templates/customers.html (Pagination hinzugefügt) - -**Technische Details:** -- SQL WHERE-Klauseln für Filter -- LIMIT/OFFSET für Pagination -- URL-Parameter bleiben bei Navigation erhalten -- Responsive Bootstrap-Komponenten - -### 2025-01-06 - Session-Tracking implementiert -- Neue Tabelle für Session-Verwaltung -- Anzeige aktiver und beendeter Sessions -- Manuelles Beenden von Sessions möglich -- Dashboard zeigt Anzahl aktiver Sessions - -**Neue Features:** -- **Sessions-Tabelle**: Speichert Session-ID, IP, User-Agent, Zeitstempel -- **Aktive Sessions**: Zeigt alle laufenden Sessions mit Inaktivitätszeit -- **Session-Historie**: Letzte 24 Stunden beendeter Sessions -- **Session beenden**: Admins können Sessions manuell beenden -- **Farbcodierung**: Grün (aktiv), Gelb (>5 Min inaktiv), Rot (lange inaktiv) - -**Geänderte/Neue Dateien:** -- v2_adminpanel/init.sql (sessions Tabelle hinzugefügt) -- v2_adminpanel/app.py (sessions() und end_session() Routen) -- v2_adminpanel/templates/sessions.html (neu erstellt) -- v2_adminpanel/templates/dashboard.html (Session-Statistik) -- Alle Templates (Session-Navigation hinzugefügt) - -**Technische Details:** -- Heartbeat-basiertes Tracking (last_heartbeat) -- Automatische Inaktivitätsberechnung -- Session-Dauer Berechnung -- Responsive Tabellen mit Bootstrap - -**Hinweis:** -Die Session-Daten werden erst gefüllt, wenn der License Server API implementiert ist und Clients sich verbinden. - -### 2025-01-06 - Export-Funktion implementiert -- CSV und Excel Export für Lizenzen und Kunden -- Formatierte Ausgabe mit deutschen Datumsformaten -- UTF-8 Unterstützung für Sonderzeichen - -**Neue Features:** -- **Lizenz-Export**: Alle Lizenzen mit Kundeninformationen -- **Kunden-Export**: Alle Kunden mit Lizenzstatistiken -- **Format-Optionen**: Excel (.xlsx) und CSV (.csv) -- **Deutsche Formatierung**: Datum als dd.mm.yyyy, Status auf Deutsch -- **UTF-8 Export**: Korrekte Kodierung für Umlaute -- **Export-Buttons**: Dropdown-Menüs in Lizenz- und Kundenübersicht - -**Geänderte Dateien:** -- v2_adminpanel/app.py (export_licenses() und export_customers() Routen) -- v2_adminpanel/requirements.txt (pandas und openpyxl hinzugefügt) -- v2_adminpanel/templates/licenses.html (Export-Dropdown hinzugefügt) -- v2_adminpanel/templates/customers.html (Export-Dropdown hinzugefügt) - -**Technische Details:** -- Pandas für Datenverarbeitung -- OpenPyXL für Excel-Export -- CSV mit Semikolon-Trennung für deutsche Excel-Kompatibilität -- Automatische Spaltenbreite in Excel -- BOM für UTF-8 CSV (Excel-Kompatibilität) - -### 2025-01-06 - Audit-Log implementiert -- Vollständiges Änderungsprotokoll für alle Aktionen -- Filterbare Übersicht mit Pagination -- Detaillierte Anzeige von Änderungen - -**Neue Features:** -- **Audit-Log-Tabelle**: Speichert alle Änderungen mit Zeitstempel, Benutzer, IP -- **Protokollierte Aktionen**: CREATE, UPDATE, DELETE, LOGIN, LOGOUT, EXPORT -- **JSON-Speicherung**: Alte und neue Werte als JSONB für flexible Abfragen -- **Filter-Optionen**: Nach Benutzer, Aktion und Entität -- **Detail-Anzeige**: Aufklappbare Änderungsdetails -- **Navigation**: Audit-Link in allen Templates - -**Geänderte/Neue Dateien:** -- v2_adminpanel/init.sql (audit_log Tabelle mit Indizes) -- v2_adminpanel/app.py (log_audit() Funktion und audit_log() Route) -- v2_adminpanel/templates/audit_log.html (neu erstellt) -- Alle Templates (Audit-Navigation hinzugefügt) - -**Technische Details:** -- JSONB für strukturierte Datenspeicherung -- Performance-Indizes auf timestamp, username und entity -- Farbcodierung für verschiedene Aktionen -- 50 Einträge pro Seite mit Pagination -- IP-Adresse und User-Agent Tracking - -### 2025-01-06 - PostgreSQL UTF-8 Locale konfiguriert -- Eigenes PostgreSQL Dockerfile für deutsche Locale -- Sicherstellung der UTF-8 Unterstützung auf Datenbankebene - -**Neue Features:** -- **PostgreSQL Dockerfile**: Installiert deutsche Locale (de_DE.UTF-8) -- **Locale-Umgebungsvariablen**: LANG, LANGUAGE, LC_ALL gesetzt -- **Docker Compose Update**: Verwendet jetzt eigenes PostgreSQL-Image - -**Neue Dateien:** -- v2_postgres/Dockerfile (neu erstellt) - -**Geänderte Dateien:** -- v2/docker-compose.yaml (postgres Service nutzt jetzt build statt image) - -**Technische Details:** -- Basis-Image: postgres:14 -- Locale-Installation über apt-get -- locale-gen für de_DE.UTF-8 -- Vollständige UTF-8 Unterstützung für deutsche Sonderzeichen - -### 2025-01-07 - Backup-Funktionalität implementiert -- Verschlüsselte Backups mit manueller und automatischer Ausführung -- Backup-Historie mit Download und Wiederherstellung -- Dashboard-Integration für Backup-Status - -**Neue Features:** -- **Backup-Erstellung**: Manuell und automatisch (täglich 3:00 Uhr) -- **Verschlüsselung**: AES-256 mit Fernet, Key aus ENV oder automatisch generiert -- **Komprimierung**: GZIP-Komprimierung vor Verschlüsselung -- **Backup-Historie**: Vollständige Übersicht aller Backups -- **Wiederherstellung**: Mit optionalem Verschlüsselungs-Passwort -- **Download-Funktion**: Backups können heruntergeladen werden -- **Dashboard-Widget**: Zeigt letztes Backup-Status -- **E-Mail-Vorbereitung**: Struktur für Benachrichtigungen (deaktiviert) - -**Neue/Geänderte Dateien:** -- v2_adminpanel/init.sql (backup_history Tabelle hinzugefügt) -- v2_adminpanel/requirements.txt (cryptography, apscheduler hinzugefügt) -- v2_adminpanel/app.py (Backup-Funktionen und Routen) -- v2_adminpanel/templates/backups.html (neu erstellt) -- v2_adminpanel/templates/dashboard.html (Backup-Status-Widget) -- v2_adminpanel/Dockerfile (PostgreSQL-Client installiert) -- v2/.env (EMAIL_ENABLED und BACKUP_ENCRYPTION_KEY) -- Alle Templates (Backup-Navigation hinzugefügt) - -**Technische Details:** -- Speicherort: C:\Users\Administrator\Documents\GitHub\v2-Docker\backups\ -- Dateiformat: backup_v2docker_YYYYMMDD_HHMMSS_encrypted.sql.gz.enc -- APScheduler für automatische Backups -- pg_dump/psql für Datenbank-Operationen -- Audit-Log für alle Backup-Aktionen -- Sicherheitsabfrage bei Wiederherstellung - -### 2025-01-07 - HTTPS/SSL und Internet-Zugriff implementiert -- Nginx Reverse Proxy für externe Erreichbarkeit eingerichtet -- SSL-Zertifikate von IONOS mit vollständiger Certificate Chain integriert -- Netzwerkkonfiguration für feste IP-Adresse -- DynDNS und Port-Forwarding konfiguriert - -**Neue Features:** -- **Nginx Reverse Proxy**: Leitet HTTPS-Anfragen an Container weiter -- **SSL-Zertifikate**: Wildcard-Zertifikat von IONOS für *.z5m7q9dk3ah2v1plx6ju.com -- **Certificate Chain**: Server-, Intermediate- und Root-Zertifikate kombiniert -- **Subdomain-Routing**: admin-panel-undso und api-software-undso -- **Port-Forwarding**: FRITZ!Box 443 → 192.168.178.88 -- **Feste IP**: Windows-PC auf 192.168.178.88 konfiguriert - -**Neue/Geänderte Dateien:** -- v2_nginx/nginx.conf (Reverse Proxy Konfiguration) -- v2_nginx/Dockerfile (Nginx Container mit SSL) -- v2_nginx/ssl/fullchain.pem (Certificate Chain) -- v2_nginx/ssl/privkey.pem (Private Key) -- v2/docker-compose.yaml (nginx Service hinzugefügt) -- set-static-ip.ps1 (PowerShell Script für feste IP) -- reset-to-dhcp.ps1 (PowerShell Script für DHCP) - -**Technische Details:** -- SSL-Termination am Nginx Reverse Proxy -- Backend-Kommunikation über Docker-internes Netzwerk -- Admin-Panel nur noch über Nginx erreichbar (Port 443 nicht mehr exposed) -- License-Server behält externen Port 8443 für direkte API-Zugriffe -- Intermediate Certificates aus ZIP extrahiert und korrekt verkettet - -**Zugangsdaten:** -- Admin-Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com -- Benutzer 1: rac00n -- Benutzer 2: w@rh@mm3r - -**Status:** -- ✅ Admin-Panel extern erreichbar ohne SSL-Warnungen -- ✅ Reverse Proxy funktioniert -- ✅ SSL-Zertifikate korrekt konfiguriert -- ✅ Netzwerk-Setup abgeschlossen - -### 2025-01-07 - Projekt-Cleanup durchgeführt -- Redundante und überflüssige Dateien entfernt -- Projektstruktur verbessert und organisiert - -**Durchgeführte Änderungen:** -1. **Entfernte Dateien:** - - v2_adminpanel/templates/.env (Duplikat der Haupt-.env) - - v2_postgreSQL/ (leeres Verzeichnis) - - SSL-Zertifikate aus Root-Verzeichnis (7 Dateien) - - Ungenutzer `json` Import aus app.py - -2. **Organisatorische Verbesserungen:** - - PowerShell-Scripts in neuen `scripts/` Ordner verschoben - - SSL-Zertifikate nur noch in v2_nginx/ssl/ - - Keine Konfigurationsdateien mehr in Template-Verzeichnissen - -**Technische Details:** -- Docker-Container wurden gestoppt und nach Cleanup neu gestartet -- Alle Services laufen wieder normal -- Keine funktionalen Änderungen, nur Struktur-Verbesserungen - -**Ergebnis:** -- Verbesserte Projektstruktur -- Erhöhte Sicherheit (keine SSL-Zertifikate im Root) -- Klarere Dateiorganisation - -### 2025-01-07 - SSL "Nicht sicher" Problem behoben -- Chrome-Warnung trotz gültigem Zertifikat analysiert und behoben -- Ursache: Selbstsigniertes Zertifikat in der Admin Panel Flask-App - -**Durchgeführte Änderungen:** -1. **Admin Panel Konfiguration (app.py):** - - Von HTTPS mit selbstsigniertem Zertifikat auf HTTP Port 5000 umgestellt - - `ssl_context='adhoc'` entfernt - - Flask läuft jetzt auf `0.0.0.0:5000` statt HTTPS - -2. **Dockerfile Anpassung (v2_adminpanel/Dockerfile):** - - EXPOSE Port von 443 auf 5000 geändert - - Container exponiert jetzt HTTP statt HTTPS - -3. **Nginx Konfiguration (nginx.conf):** - - proxy_pass von `https://admin-panel:443` auf `http://admin-panel:5000` geändert - - `proxy_ssl_verify off` entfernt (nicht mehr benötigt) - - Sicherheits-Header für beide Domains hinzugefügt: - - Strict-Transport-Security (HSTS) - erzwingt HTTPS für 1 Jahr - - X-Content-Type-Options - verhindert MIME-Type Sniffing - - X-Frame-Options - Schutz vor Clickjacking - - X-XSS-Protection - aktiviert XSS-Filter - - Referrer-Policy - kontrolliert Referrer-Informationen - -**Technische Details:** -- Externer Traffic nutzt weiterhin HTTPS mit gültigen IONOS-Zertifikaten -- Interne Kommunikation zwischen Nginx und Admin Panel läuft über HTTP (sicher im Docker-Netzwerk) -- Kein selbstsigniertes Zertifikat mehr in der Zertifikatskette -- SSL-Termination erfolgt ausschließlich am Nginx Reverse Proxy - -**Docker Neustart:** -- Container gestoppt (`docker-compose down`) -- Images neu gebaut (`docker-compose build`) -- Container neu gestartet (`docker-compose up -d`) -- Alle Services laufen normal - -**Ergebnis:** -- ✅ "Nicht sicher" Warnung in Chrome behoben -- ✅ Saubere SSL-Konfiguration ohne Mixed Content -- ✅ Verbesserte Sicherheits-Header implementiert -- ✅ Admin Panel zeigt jetzt grünes Schloss-Symbol - -### 2025-01-07 - Sicherheitslücke geschlossen: License Server Port -- Direkter Zugriff auf License Server Port 8443 entfernt -- Sicherheitsanalyse der exponierten Ports durchgeführt - -**Identifiziertes Problem:** -- License Server war direkt auf Port 8443 von außen erreichbar -- Umging damit die Nginx-Sicherheitsschicht und Security Headers -- Besonders kritisch, da nur Platzhalter ohne echte Sicherheit - -**Durchgeführte Änderung:** -- Port-Mapping für License Server in docker-compose.yaml entfernt -- Service ist jetzt nur noch über Nginx Reverse Proxy erreichbar -- Gleiche Sicherheitskonfiguration wie Admin Panel - -**Aktuelle Port-Exposition:** -- ✅ Nginx: Port 80/443 (benötigt für externen Zugriff) -- ✅ PostgreSQL: Keine Ports exponiert (gut) -- ✅ Admin Panel: Nur über Nginx erreichbar -- ✅ License Server: Nur über Nginx erreichbar (vorher direkt auf 8443) - -**Weitere identifizierte Sicherheitsthemen:** -1. Credentials im Klartext in .env Datei -2. SSL-Zertifikate im Repository gespeichert -3. License Server noch nicht implementiert - -**Empfehlung:** Docker-Container neu starten für Änderungsübernahme - -### 2025-01-07 - License Server Port 8443 wieder aktiviert -- Port 8443 für direkten Zugriff auf License Server wieder geöffnet -- Notwendig für Client-Software Lizenzprüfung - -**Begründung:** -- Client-Software benötigt direkten Zugriff für Lizenzprüfung -- Umgehung von möglichen Firewall-Blockaden auf Port 443 -- Weniger Latenz ohne Nginx-Proxy -- Flexibilität für verschiedene Client-Implementierungen - -**Konfiguration:** -- License Server erreichbar über: - - Direkt: Port 8443 (für Client-Software) - - Via Nginx: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com (für Browser/Tests) - -**Sicherheitshinweis:** -- Port 8443 ist wieder direkt exponiert -- License Server muss vor Produktivbetrieb implementiert werden mit: - - Eigener SSL-Konfiguration - - API-Key Authentifizierung - - Rate Limiting - - Input-Validierung - -**Status:** -- Port-Mapping in docker-compose.yaml wiederhergestellt -- Änderung erfordert Docker-Neustart - -### 2025-01-07 - Rate-Limiting und Brute-Force-Schutz implementiert -- Umfassender Schutz vor Login-Angriffen mit IP-Sperre -- Dashboard-Integration für Sicherheitsüberwachung - -**Implementierte Features:** -1. **Rate-Limiting System:** - - 5 Login-Versuche erlaubt, danach 24h IP-Sperre - - Progressive Fehlermeldungen (zufällig aus 5 lustigen Varianten) - - CAPTCHA nach 2 Fehlversuchen (Google reCAPTCHA v2 vorbereitet) - - E-Mail-Benachrichtigung bei Sperrung (vorbereitet, deaktiviert für PoC) - -2. **Timing-Attack Schutz:** - - Mindestens 1 Sekunde Antwortzeit bei allen Login-Versuchen - - Gleiche Antwortzeit bei richtigem/falschem Username - - Verhindert Username-Enumeration - -3. **Lustige Fehlermeldungen (zufällig):** - - "NOPE!" - - "ACCESS DENIED, TRY HARDER" - - "WRONG! 🚫" - - "COMPUTER SAYS NO" - - "YOU FAILED" - -4. **Dashboard-Sicherheitswidget:** - - Sicherheitslevel-Anzeige (NORMAL/ERHÖHT/KRITISCH) - - Anzahl gesperrter IPs - - Fehlversuche heute - - Letzte 5 Sicherheitsereignisse mit Details - -5. **IP-Verwaltung:** - - Übersicht aller gesperrten IPs - - Manuelles Entsperren möglich - - Login-Versuche zurücksetzen - - Detaillierte Informationen pro IP - -6. **Audit-Log Erweiterungen:** - - LOGIN_SUCCESS - Erfolgreiche Anmeldung - - LOGIN_FAILED - Fehlgeschlagener Versuch - - LOGIN_BLOCKED - IP wurde gesperrt - - UNBLOCK_IP - IP manuell entsperrt - - CLEAR_ATTEMPTS - Versuche zurückgesetzt - -**Neue/Geänderte Dateien:** -- v2_adminpanel/init.sql (login_attempts Tabelle) -- v2_adminpanel/app.py (Rate-Limiting Logik, neue Routen) -- v2_adminpanel/templates/login.html (Fehlermeldungs-Styling, CAPTCHA) -- v2_adminpanel/templates/dashboard.html (Sicherheitswidget) -- v2_adminpanel/templates/blocked_ips.html (neu - IP-Verwaltung) - -**Technische Details:** -- IP-Ermittlung berücksichtigt Proxy-Header (X-Forwarded-For) -- Fehlermeldungen mit Animation (shake-effect) -- Farbcodierung: Rot für Fehler, Lila für Sperre, Orange für CAPTCHA -- Automatische Bereinigung alter Einträge möglich - -**Sicherheitsverbesserungen:** -- Schutz vor Brute-Force-Angriffen -- Timing-Attack-Schutz implementiert -- IP-basierte Sperrung für 24 Stunden -- Audit-Trail für alle Sicherheitsereignisse - -**Hinweis für Produktion:** -- CAPTCHA-Keys müssen in .env konfiguriert werden -- E-Mail-Server für Benachrichtigungen einrichten -- Rate-Limits können über Konstanten angepasst werden - -### 2025-01-07 - Session-Timeout mit Live-Timer implementiert -- 5 Minuten Inaktivitäts-Timeout mit visueller Countdown-Anzeige -- Automatische Session-Verlängerung bei Benutzeraktivität - -**Implementierte Features:** -1. **Session-Timeout Backend:** - - Flask Session-Timeout auf 5 Minuten konfiguriert - - Heartbeat-Endpoint für Keep-Alive - - Automatisches Session-Update bei jeder Aktion - -2. **Live-Timer in der Navbar:** - - Countdown von 5:00 bis 0:00 - - Position: Zwischen Logo und Username - - Farbwechsel nach verbleibender Zeit: - - Grün: > 2 Minuten - - Gelb: 1-2 Minuten - - Rot: < 1 Minute - - Blinkend: < 30 Sekunden - -3. **Benutzerinteraktion:** - - Timer wird bei jeder Aktivität zurückgesetzt - - Tracking von: Klicks, Tastatureingaben, Mausbewegungen - - Automatischer Heartbeat bei Aktivität - - Warnung bei < 1 Minute mit "Session verlängern" Button - -4. **Base-Template System:** - - Neue base.html als Basis für alle Admin-Seiten - - Alle Templates (außer login.html) nutzen jetzt base.html - - Einheitliches Layout und Timer auf allen Seiten - -**Neue/Geänderte Dateien:** -- v2_adminpanel/app.py (Session-Konfiguration, Heartbeat-Endpoint) -- v2_adminpanel/templates/base.html (neu - Base-Template mit Timer) -- Alle anderen Templates aktualisiert für Template-Vererbung - -**Technische Details:** -- JavaScript-basierter Countdown-Timer -- AJAX-Heartbeat alle 5 Sekunden bei Aktivität -- LocalStorage für Tab-Synchronisation möglich -- Automatischer Logout bei 0:00 -- Fetch-Interceptor für automatische Session-Verlängerung - -**Sicherheitsverbesserung:** -- Automatischer Logout nach 5 Minuten Inaktivität -- Verhindert vergessene Sessions -- Visuelles Feedback für Session-Status - -### 2025-01-07 - Session-Timeout Bug behoben -- Problem: Session-Timeout funktionierte nicht korrekt - Session blieb länger als 5 Minuten aktiv -- Ursache: login_required Decorator aktualisierte last_activity bei JEDEM Request - -**Durchgeführte Änderungen:** -1. **login_required Decorator (app.py):** - - Prüft jetzt ob Session abgelaufen ist (5 Minuten seit last_activity) - - Aktualisiert last_activity NICHT mehr automatisch - - Führt AUTO_LOGOUT mit Audit-Log bei Timeout durch - - Speichert Username vor session.clear() für korrektes Logging - -2. **Heartbeat-Endpoint (app.py):** - - Geändert zu POST-only Endpoint - - Aktualisiert explizit last_activity wenn aufgerufen - - Wird nur bei aktiver Benutzerinteraktion aufgerufen - -3. **Frontend Timer (base.html):** - - Heartbeat wird als POST Request gesendet - - trackActivity() ruft extendSession() ohne vorheriges resetTimer() auf - - Timer wird erst nach erfolgreichem Heartbeat zurückgesetzt - - AJAX Interceptor ignoriert Heartbeat-Requests - -4. **Audit-Log Erweiterung:** - - Neue Aktion AUTO_LOGOUT hinzugefügt - - Orange Farbcodierung (#fd7e14) - - Zeigt Grund des Timeouts im Audit-Log - -**Ergebnis:** -- ✅ Session läuft nach exakt 5 Minuten Inaktivität ab -- ✅ Benutzeraktivität verlängert Session korrekt -- ✅ AUTO_LOGOUT wird im Audit-Log protokolliert -- ✅ Visueller Timer zeigt verbleibende Zeit - -### 2025-01-07 - Session-Timeout weitere Verbesserungen -- Zusätzliche Fixes nach Test-Feedback implementiert - -**Weitere durchgeführte Änderungen:** -1. **Fehlender Import behoben:** - - `flash` zu Flask-Imports hinzugefügt für Timeout-Warnmeldungen - -2. **Session-Cookie-Konfiguration erweitert (app.py):** - - SESSION_COOKIE_HTTPONLY = True (Sicherheit gegen XSS) - - SESSION_COOKIE_SECURE = False (intern HTTP, extern HTTPS via Nginx) - - SESSION_COOKIE_SAMESITE = 'Lax' (CSRF-Schutz) - - SESSION_COOKIE_NAME = 'admin_session' (eindeutiger Name) - - SESSION_REFRESH_EACH_REQUEST = False (verhindert automatische Verlängerung) - -3. **Session-Handling verbessert:** - - Entfernt: session.permanent = True aus login_required decorator - - Hinzugefügt: session.modified = True im Heartbeat für explizites Speichern - - Debug-Logging für Session-Timeout-Prüfung hinzugefügt - -4. **Nginx-Konfiguration:** - - Bereits korrekt konfiguriert für Heartbeat-Weiterleitung - - Proxy-Headers für korrekte IP-Weitergabe - -**Technische Details:** -- Flask-Session mit Filesystem-Backend nutzt jetzt korrekte Cookie-Einstellungen -- Session-Cookie wird nicht mehr automatisch bei jedem Request verlängert -- Explizite Session-Modifikation nur bei Heartbeat-Requests -- Debug-Logs zeigen Zeit seit letzter Aktivität für Troubleshooting - -**Status:** -- ✅ Session-Timeout-Mechanismus vollständig implementiert -- ✅ Debug-Logging für Session-Überwachung aktiv -- ✅ Cookie-Sicherheitseinstellungen optimiert - -### 2025-01-07 - CAPTCHA Backend-Validierung implementiert -- Google reCAPTCHA v2 Backend-Verifizierung hinzugefügt - -**Implementierte Features:** -1. **verify_recaptcha() Funktion (app.py):** - - Validiert CAPTCHA-Response mit Google API - - Fallback: Wenn RECAPTCHA_SECRET_KEY nicht konfiguriert, wird CAPTCHA übersprungen (für PoC) - - Timeout von 5 Sekunden für API-Request - - Error-Handling für Netzwerkfehler - - Logging für Debugging und Fehleranalyse - -2. **Login-Route Erweiterungen:** - - CAPTCHA wird nach 2 Fehlversuchen angezeigt - - Prüfung ob CAPTCHA-Response vorhanden - - Validierung der CAPTCHA-Response gegen Google API - - Unterschiedliche Fehlermeldungen für fehlende/ungültige CAPTCHA - - Site Key wird aus Environment-Variable an Template übergeben - -3. **Environment-Konfiguration (.env):** - - RECAPTCHA_SITE_KEY (für Frontend) - - RECAPTCHA_SECRET_KEY (für Backend-Validierung) - - Beide auskommentiert für PoC-Phase - -4. **Dependencies:** - - requests Library zu requirements.txt hinzugefügt - -**Sicherheitsaspekte:** -- CAPTCHA verhindert automatisierte Brute-Force-Angriffe -- Timing-Attack-Schutz bleibt auch bei CAPTCHA-Prüfung aktiv -- Bei Netzwerkfehlern wird CAPTCHA als bestanden gewertet (Verfügbarkeit vor Sicherheit) -- Secret Key wird niemals im Frontend exponiert - -**Verwendung:** -1. Google reCAPTCHA v2 Keys erstellen: https://www.google.com/recaptcha/admin -2. Keys in .env eintragen: - ``` - RECAPTCHA_SITE_KEY=your-site-key - RECAPTCHA_SECRET_KEY=your-secret-key - ``` -3. Container neu starten - -**Status:** -- ✅ CAPTCHA-Frontend bereits vorhanden (login.html) -- ✅ Backend-Validierung vollständig implementiert -- ✅ Fallback für PoC-Betrieb ohne Google-Keys -- ✅ Integration in Rate-Limiting-System -- ⚠️ CAPTCHA-Keys noch nicht konfiguriert (für PoC deaktiviert) - -**Anleitung für Google reCAPTCHA Keys:** - -1. **Registrierung bei Google reCAPTCHA:** - - Gehe zu: https://www.google.com/recaptcha/admin/create - - Melde dich mit Google-Konto an - - Label eingeben: "v2-Docker Admin Panel" - - Typ wählen: "reCAPTCHA v2" → "Ich bin kein Roboter"-Kästchen - - Domains hinzufügen: - ``` - admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com - localhost - ``` - - Nutzungsbedingungen akzeptieren - - Senden klicken - -2. **Keys erhalten:** - - Site Key (öffentlich für Frontend) - - Secret Key (geheim für Backend-Validierung) - -3. **Keys in .env eintragen:** - ```bash - RECAPTCHA_SITE_KEY=6Ld... - RECAPTCHA_SECRET_KEY=6Ld... - ``` - -4. **Container neu starten:** - ```bash - docker-compose down - docker-compose up -d - ``` - -**Kosten:** -- Kostenlos bis 1 Million Anfragen pro Monat -- Danach: $1.00 pro 1000 zusätzliche Anfragen -- Für dieses Projekt reicht die kostenlose Version vollkommen aus - -**Test-Keys für Entwicklung:** -- Site Key: `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI` -- Secret Key: `6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe` -- ⚠️ Diese Keys nur für lokale Tests verwenden, niemals produktiv! - -**Aktueller Status:** -- Code ist vollständig implementiert und getestet -- CAPTCHA wird nach 2 Fehlversuchen angezeigt -- Ohne konfigurierte Keys wird CAPTCHA-Prüfung übersprungen -- Für Produktion müssen nur die Keys in .env eingetragen werden - -### 2025-01-07 - License Key Generator implementiert -- Automatische Generierung von Lizenzschlüsseln mit definiertem Format - -**Implementiertes Format:** -`AF-YYYYMMFT-XXXX-YYYY-ZZZZ` -- **AF** = Account Factory (feste Produktkennung) -- **YYYY** = Jahr (z.B. 2025) -- **MM** = Monat (z.B. 06) -- **FT** = Lizenztyp (F=Fullversion, T=Testversion) -- **XXXX-YYYY-ZZZZ** = Zufällige alphanumerische Zeichen (ohne verwirrende wie 0/O, 1/I/l) - -**Beispiele:** -- Vollversion: `AF-202506F-A7K9-M3P2-X8R4` -- Testversion: `AF-202512T-B2N5-K8L3-Q9W7` - -**Implementierte Features:** - -1. **Backend-Funktionen (app.py):** - - `generate_license_key()` - Generiert Keys mit kryptografisch sicherem Zufallsgenerator - - `validate_license_key()` - Validiert das Key-Format mit Regex - - Verwendet `secrets` statt `random` für Sicherheit - - Erlaubte Zeichen: ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (ohne verwirrende) - -2. **API-Endpoint:** - - POST `/api/generate-license-key` - JSON API für Key-Generierung - - Prüft auf Duplikate in der Datenbank (max. 10 Versuche) - - Audit-Log-Eintrag bei jeder Generierung - - Login-Required geschützt - -3. **Frontend-Verbesserungen (index.html):** - - Generate-Button neben License Key Input - - Placeholder und Pattern-Attribut für Format-Hinweis - - Auto-Uppercase bei manueller Eingabe - - Visuelles Feedback bei erfolgreicher Generierung - - Format-Hinweis unter dem Eingabefeld - -4. **JavaScript-Features:** - - AJAX-basierte Key-Generierung ohne Seiten-Reload - - Automatische Prüfung bei Lizenztyp-Änderung - - Ladeindikator während der Generierung - - Fehlerbehandlung mit Benutzer-Feedback - - Standard-Datum-Einstellungen (heute + 1 Jahr) - -5. **Validierung:** - - Server-seitige Format-Validierung beim Speichern - - Flash-Message bei ungültigem Format - - Automatische Großschreibung des Keys - - Pattern-Validierung im HTML-Formular - -6. **Weitere Fixes:** - - Form Action von "/" auf "/create" korrigiert - - Flash-Messages mit Bootstrap Toasts implementiert - - GENERATE_KEY Aktion zum Audit-Log hinzugefügt (Farbe: #20c997) - -**Technische Details:** -- Keine vorhersagbaren Muster durch `secrets.choice()` -- Datum im Key zeigt Erstellungszeitpunkt -- Lizenztyp direkt im Key erkennbar -- Kollisionsprüfung gegen Datenbank - -**Status:** -- ✅ Backend-Generierung vollständig implementiert -- ✅ Frontend mit Generate-Button und JavaScript -- ✅ Validierung und Fehlerbehandlung -- ✅ Audit-Log-Integration -- ✅ Form-Action-Bug behoben - -### 2025-01-07 - Batch-Lizenzgenerierung implementiert -- Mehrere Lizenzen auf einmal für einen Kunden erstellen - -**Implementierte Features:** - -1. **Batch-Formular (/batch):** - - Kunde und E-Mail eingeben - - Anzahl der Lizenzen (1-100) - - Lizenztyp (Vollversion/Testversion) - - Gültigkeitszeitraum für alle Lizenzen - - Vorschau-Modal zeigt Key-Format - - Standard-Datum-Einstellungen (heute + 1 Jahr) - -2. **Backend-Verarbeitung:** - - Route `/batch` für GET (Formular) und POST (Generierung) - - Generiert die angegebene Anzahl eindeutiger Keys - - Speichert alle in einer Transaktion - - Kunde wird automatisch angelegt (falls nicht vorhanden) - - ON CONFLICT für existierende Kunden - - Audit-Log-Eintrag mit CREATE_BATCH Aktion - -3. **Ergebnis-Seite:** - - Zeigt alle generierten Lizenzen in Tabellenform - - Kundeninformationen und Gültigkeitszeitraum - - Einzelne Keys können kopiert werden (📋 Button) - - Alle Keys auf einmal kopieren - - Druckfunktion für physische Ausgabe - - Link zur Lizenzübersicht mit Kundenfilter - -4. **Export-Funktionalität:** - - Route `/batch/export` für CSV-Download - - Speichert Batch-Daten in Session für Export - - CSV mit UTF-8 BOM für Excel-Kompatibilität - - Enthält Kundeninfo, Generierungsdatum und alle Keys - - Format: Nr;Lizenzschlüssel;Typ - - Dateiname: batch_licenses_KUNDE_TIMESTAMP.csv - -5. **Integration:** - - Batch-Button in Navigation (Dashboard, Einzellizenz-Seite) - - CREATE_BATCH Aktion im Audit-Log (Farbe: #6610f2) - - Session-basierte Export-Daten - - Flash-Messages für Feedback - -**Sicherheit:** -- Limit von 100 Lizenzen pro Batch -- Login-Required für alle Routen -- Transaktionale Datenbank-Operationen -- Validierung der Eingaben - -**Beispiel-Workflow:** -1. Admin geht zu `/batch` -2. Gibt Kunde "Firma GmbH", Anzahl "25", Typ "Vollversion" ein -3. System generiert 25 eindeutige Keys -4. Ergebnis-Seite zeigt alle Keys -5. Admin kann CSV exportieren oder Keys kopieren -6. Kunde erhält die Lizenzen - -**Status:** -- ✅ Batch-Formular vollständig implementiert -- ✅ Backend-Generierung mit Transaktionen -- ✅ Export als CSV -- ✅ Copy-to-Clipboard Funktionalität -- ✅ Audit-Log-Integration -- ✅ Navigation aktualisiert - -## 2025-06-06: Implementierung Searchable Dropdown für Kundenauswahl - -**Problem:** -- Bei der Lizenzerstellung wurde immer ein neuer Kunde angelegt -- Keine Möglichkeit, Lizenzen für bestehende Kunden zu erstellen -- Bei vielen Kunden wäre ein normales Dropdown unübersichtlich - -**Lösung:** -1. **Select2 Library** für searchable Dropdown integriert -2. **API-Endpoint `/api/customers`** für die Kundensuche erstellt -3. **Frontend angepasst:** - - Searchable Dropdown mit Live-Suche - - Option "Neuer Kunde" im Dropdown - - Eingabefelder erscheinen nur bei "Neuer Kunde" -4. **Backend-Logik verbessert:** - - Prüfung ob neuer oder bestehender Kunde - - E-Mail-Duplikatsprüfung vor Kundenerstellung - - Separate Audit-Logs für Kunde und Lizenz -5. **Datenbank:** - - UNIQUE Constraint auf E-Mail-Spalte hinzugefügt - -**Änderungen:** -- `app.py`: Neuer API-Endpoint `/api/customers`, angepasste Routes `/create` und `/batch` -- `base.html`: Select2 CSS und JS eingebunden -- `index.html`: Kundenauswahl mit Select2 implementiert -- `batch_form.html`: Kundenauswahl mit Select2 implementiert -- `init.sql`: UNIQUE Constraint für E-Mail - -**Status:** -- ✅ API-Endpoint funktioniert mit Pagination -- ✅ Select2 Dropdown mit Suchfunktion -- ✅ Neue/bestehende Kunden können ausgewählt werden -- ✅ E-Mail-Duplikate werden verhindert -- ✅ Sowohl Einzellizenz als auch Batch unterstützt - -## 2025-06-06: Automatische Ablaufdatum-Berechnung - -**Problem:** -- Manuelles Eingeben von Start- und Enddatum war umständlich -- Fehleranfällig bei der Datumseingabe -- Nicht intuitiv für Standard-Laufzeiten - -**Lösung:** -1. **Frontend-Änderungen:** - - Startdatum + Laufzeit (Zahl) + Einheit (Tage/Monate/Jahre) - - Ablaufdatum wird automatisch berechnet und angezeigt (read-only) - - Standard: 1 Jahr Laufzeit voreingestellt -2. **Backend-Validierung:** - - Server-seitige Berechnung zur Sicherheit - - Verwendung von `python-dateutil` für korrekte Monats-/Jahresberechnungen -3. **Benutzerfreundlichkeit:** - - Sofortige Neuberechnung bei Änderungen - - Visuelle Hervorhebung des berechneten Datums - -**Änderungen:** -- `index.html`: Laufzeit-Eingabe statt Ablaufdatum -- `batch_form.html`: Laufzeit-Eingabe statt Ablaufdatum -- `app.py`: Datum-Berechnung in `/create` und `/batch` Routes -- `requirements.txt`: `python-dateutil` hinzugefügt - -**Status:** -- ✅ Automatische Berechnung funktioniert -- ✅ Frontend zeigt berechnetes Datum sofort an -- ✅ Backend validiert die Berechnung -- ✅ Standardwert (1 Jahr) voreingestellt - -## 2025-06-06: Bugfix - created_at für licenses Tabelle - -**Problem:** -- Batch-Generierung schlug fehl mit "Fehler bei der Batch-Generierung!" -- INSERT Statement versuchte `created_at` zu setzen, aber Spalte existierte nicht -- Inkonsistenz: Einzellizenzen hatten kein created_at, Batch-Lizenzen versuchten es zu setzen - -**Lösung:** -1. **Datenbank-Schema erweitert:** - - `created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` zur licenses Tabelle hinzugefügt - - Migration für bestehende Datenbanken implementiert - - Konsistent mit customers Tabelle -2. **Code bereinigt:** - - Explizites `created_at` aus Batch-INSERT entfernt - - Datenbank setzt nun automatisch den Zeitstempel bei ALLEN Lizenzen - -**Änderungen:** -- `init.sql`: created_at Spalte zur licenses Tabelle mit DEFAULT-Wert -- `init.sql`: Migration für bestehende Datenbanken -- `app.py`: Entfernt explizites created_at aus batch_licenses() - -**Status:** -- ✅ Alle Lizenzen haben nun automatisch einen Erstellungszeitstempel -- ✅ Batch-Generierung funktioniert wieder -- ✅ Konsistente Zeitstempel für Audit-Zwecke - -## 2025-06-06: Status "Deaktiviert" für manuell abgeschaltete Lizenzen - -**Problem:** -- Dashboard zeigte nur "aktiv" und "abgelaufen" als Status -- Manuell deaktivierte Lizenzen (is_active = FALSE) wurden nicht korrekt angezeigt -- Filter für "inactive" existierte, aber Status wurde nicht richtig berechnet - -**Lösung:** -1. **Status-Berechnung erweitert:** - - CASE-Statement prüft zuerst `is_active = FALSE` - - Status "deaktiviert" wird vor anderen Status geprüft - - Reihenfolge: deaktiviert → abgelaufen → läuft bald ab → aktiv -2. **Dashboard-Statistik erweitert:** - - Neue Zählung für deaktivierte Lizenzen - - Variable `inactive_licenses` im stats Dictionary - -**Änderungen:** -- `app.py`: Dashboard - Status-Berechnung für letzte 5 Lizenzen -- `app.py`: Lizenzübersicht - Status-Berechnung in der Hauptabfrage -- `app.py`: Export - Status-Berechnung für CSV/Excel Export -- `app.py`: Dashboard - Neue Statistik für deaktivierte Lizenzen - -**Status:** -- ✅ "Deaktiviert" wird korrekt als Status angezeigt -- ✅ Dashboard zeigt Anzahl deaktivierter Lizenzen -- ✅ Export enthält korrekten Status -- ✅ Konsistente Status-Anzeige überall - -## 2025-06-08: SSL-Sicherheit verbessert - Chrome Warnung behoben - -**Problem:** -- Chrome zeigte Warnung "Die Verbindung zu dieser Website ist nicht sicher" -- Nginx erlaubte schwache Cipher Suites (WEAK) ohne Perfect Forward Secrecy -- Veraltete SSL-Konfiguration mit `ssl_ciphers HIGH:!aNULL:!MD5;` - -**Lösung:** -1. **Moderne Cipher Suite Konfiguration:** - - Nur sichere ECDHE und DHE Cipher Suites - - Entfernung aller RSA-only Cipher Suites - - Perfect Forward Secrecy für alle Verbindungen -2. **SSL-Optimierungen:** - - Session Cache aktiviert (1 Tag Timeout) - - OCSP Stapling für bessere Performance - - DH Parameters (2048 bit) für zusätzliche Sicherheit -3. **Resolver-Konfiguration:** - - Google DNS Server für OCSP Stapling - -**Änderungen:** -- `v2_nginx/nginx.conf`: Komplett überarbeitete SSL-Konfiguration -- `v2_nginx/ssl/dhparam.pem`: Neue 2048-bit DH Parameters generiert -- `v2_nginx/Dockerfile`: COPY Befehl für dhparam.pem hinzugefügt - -**Status:** -- ✅ Nur noch sichere Cipher Suites aktiv -- ✅ Perfect Forward Secrecy gewährleistet -- ✅ OCSP Stapling aktiviert -- ✅ Chrome Sicherheitswarnung behoben - -**Hinweis:** Nach dem Rebuild des nginx Containers wird die Verbindung als sicher angezeigt. - -## 2025-06-08: CAPTCHA-Login-Bug behoben - -**Problem:** -- Nach 2 fehlgeschlagenen Login-Versuchen wurde CAPTCHA angezeigt -- Da keine CAPTCHA-Keys konfiguriert waren (für PoC), konnte man sich nicht mehr einloggen -- Selbst mit korrektem Passwort war Login blockiert -- Fehlermeldung "CAPTCHA ERFORDERLICH!" erschien immer - -**Lösung:** -1. **CAPTCHA-Prüfung nur wenn Keys vorhanden:** - - `recaptcha_site_key` wird vor CAPTCHA-Prüfung geprüft - - Wenn keine Keys konfiguriert → kein CAPTCHA-Check - - CAPTCHA wird nur angezeigt wenn Keys existieren -2. **Template-Anpassungen:** - - login.html zeigt CAPTCHA nur wenn `recaptcha_site_key` vorhanden - - Kein Test-Key mehr als Fallback -3. **Konsistente Logik:** - - show_captcha prüft jetzt auch ob Keys vorhanden sind - - Bei GET und POST Requests gleiche Logik - -**Änderungen:** -- `v2_adminpanel/app.py`: CAPTCHA-Check nur wenn `RECAPTCHA_SITE_KEY` existiert -- `v2_adminpanel/templates/login.html`: CAPTCHA nur anzeigen wenn Keys vorhanden - -**Status:** -- ✅ Login funktioniert wieder nach 2+ Fehlversuchen -- ✅ CAPTCHA wird nur angezeigt wenn Keys konfiguriert sind -- ✅ Für PoC-Phase ohne CAPTCHA nutzbar -- ✅ Produktiv-ready wenn CAPTCHA-Keys eingetragen werden - -### 2025-06-08: Zeitzone auf Europe/Berlin umgestellt - -**Problem:** -- Alle Zeitstempel wurden in UTC gespeichert und angezeigt -- Backup-Dateinamen zeigten UTC-Zeit statt deutsche Zeit -- Verwirrung bei Zeitangaben im Admin Panel und Logs - -**Lösung:** -1. **Docker Container Zeitzone konfiguriert:** - - Alle Dockerfiles mit `TZ=Europe/Berlin` und tzdata Installation - - PostgreSQL mit `PGTZ=Europe/Berlin` für Datenbank-Zeitzone - - Explizite Zeitzone-Dateien in /etc/localtime und /etc/timezone - -2. **Python Code angepasst:** - - Import von `zoneinfo.ZoneInfo` für Zeitzonenunterstützung - - Alle `datetime.now()` Aufrufe mit `ZoneInfo("Europe/Berlin")` - - `.replace(tzinfo=None)` für Kompatibilität mit timezone-unaware Timestamps - -3. **PostgreSQL Konfiguration:** - - `SET timezone = 'Europe/Berlin';` in init.sql - - Umgebungsvariablen TZ und PGTZ in docker-compose.yaml - -4. **docker-compose.yaml erweitert:** - - `TZ: Europe/Berlin` für alle Services - -**Geänderte Dateien:** -- `v2_adminpanel/Dockerfile`: Zeitzone und tzdata hinzugefügt -- `v2_postgres/Dockerfile`: Zeitzone und tzdata hinzugefügt -- `v2_nginx/Dockerfile`: Zeitzone und tzdata hinzugefügt -- `v2_lizenzserver/Dockerfile`: Zeitzone und tzdata hinzugefügt -- `v2_adminpanel/app.py`: 14 datetime.now() Aufrufe mit Zeitzone versehen -- `v2_adminpanel/init.sql`: PostgreSQL Zeitzone gesetzt -- `v2/docker-compose.yaml`: TZ Environment-Variable für alle Services - -**Ergebnis:** -- ✅ Alle neuen Zeitstempel werden in deutscher Zeit (Europe/Berlin) gespeichert -- ✅ Backup-Dateinamen zeigen korrekte deutsche Zeit -- ✅ Admin Panel zeigt alle Zeiten in deutscher Zeitzone -- ✅ Automatische Anpassung bei Sommer-/Winterzeit -- ✅ Konsistente Zeitangaben über alle Komponenten - -**Hinweis:** Nach diesen Änderungen müssen die Docker Container neu gebaut werden: -```bash -docker-compose down -docker-compose build -docker-compose up -d -``` - -### 2025-06-08: Zeitzone-Fix - PostgreSQL Timestamps - -**Problem nach erster Implementierung:** -- Trotz Zeitzoneneinstellung wurden Zeiten immer noch in UTC angezeigt -- Grund: PostgreSQL Tabellen verwendeten `TIMESTAMP WITHOUT TIME ZONE` - -**Zusätzliche Lösung:** -1. **Datenbankschema angepasst:** - - Alle `TIMESTAMP` Spalten auf `TIMESTAMP WITH TIME ZONE` geändert - - Betrifft: created_at, timestamp, started_at, ended_at, last_heartbeat, etc. - - Migration für bestehende Datenbanken berücksichtigt - -2. **SQL-Abfragen vereinfacht:** - - `AT TIME ZONE 'Europe/Berlin'` nicht mehr nötig - - PostgreSQL handhabt Zeitzonenkonvertierung automatisch - -**Geänderte Datei:** -- `v2_adminpanel/init.sql`: Alle TIMESTAMP Felder mit WITH TIME ZONE - -**Wichtig:** Bei bestehenden Installationen muss die Datenbank neu initialisiert oder manuell migriert werden: -```sql -ALTER TABLE customers ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE licenses ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE sessions ALTER COLUMN started_at TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE sessions ALTER COLUMN last_heartbeat TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE sessions ALTER COLUMN ended_at TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE audit_log ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE backup_history ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE login_attempts ALTER COLUMN first_attempt TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE login_attempts ALTER COLUMN last_attempt TYPE TIMESTAMP WITH TIME ZONE; -ALTER TABLE login_attempts ALTER COLUMN blocked_until TYPE TIMESTAMP WITH TIME ZONE; -``` - -### 2025-06-08: UI/UX Überarbeitung - Phase 1 (Navigation) - -**Problem:** -- Inkonsistente Navigation zwischen verschiedenen Seiten -- Zu viele Navigationspunkte im Dashboard -- Verwirrende Benutzerführung - -**Lösung:** -1. **Dashboard vereinfacht:** - - Nur noch 3 Buttons: Neue Lizenz, Batch-Lizenzen, Log - - Statistik-Karten wurden klickbar gemacht (verlinken zu jeweiligen Seiten) - - "Audit" wurde zu "Log" umbenannt - -2. **Navigation konsistent gemacht:** - - Navbar-Brand "AccountForger - Admin Panel" ist jetzt klickbar und führt zum Dashboard - - Keine Log-Links mehr in Unterseiten - - Konsistente "Dashboard" Buttons in allen Unterseiten - -**Geänderte Dateien:** -- `v2_adminpanel/templates/base.html`: Navbar-Brand klickbar gemacht -- `v2_adminpanel/templates/dashboard.html`: Navigation reduziert, Karten klickbar -- `v2_adminpanel/templates/*.html`: Konsistente Dashboard-Links - -### 2025-06-08: UI/UX Überarbeitung - Phase 2 (Visuelle Verbesserungen) - -**Implementierte Verbesserungen:** -1. **Größere Icons in Statistik-Karten:** - - Icon-Größe auf 3rem erhöht - - Bessere visuelle Hierarchie - -2. **Donut-Chart für Lizenzen:** - - Chart.js Integration für Lizenzstatistik - - Zeigt Verhältnis Aktiv/Abgelaufen - - UPDATE: Später wieder entfernt auf Benutzerwunsch - -3. **Pulse-Effekt für aktive Sessions:** - - CSS-Animation für aktive Sessions - - Visueller Indikator für Live-Aktivität - -4. **Progress-Bar für Backup-Status:** - - Zeigt visuell den Erfolg des letzten Backups - - Inkl. Dateigröße und Dauer - -5. **Konsistente Farbcodierung:** - - CSS-Variablen für Statusfarben - - Globale Klassen für konsistente Darstellung - -**Geänderte Dateien:** -- `v2_adminpanel/templates/base.html`: Globale CSS-Variablen und Statusklassen -- `v2_adminpanel/templates/dashboard.html`: Visuelle Verbesserungen implementiert - -### 2025-06-08: UI/UX Überarbeitung - Phase 3 (Tabellen-Optimierungen) - -**Problem:** -- Tabellen waren schwer zu navigieren bei vielen Einträgen -- Keine Möglichkeit für Bulk-Operationen -- Umständliches Kopieren von Lizenzschlüsseln - -**Lösung:** -1. **Sticky Headers:** - - Tabellenköpfe bleiben beim Scrollen sichtbar - - CSS-Klasse `.table-sticky` mit `position: sticky` - -2. **Inline-Actions:** - - Copy-Button direkt neben Lizenzschlüsseln - - Toggle-Switches für Aktiv/Inaktiv-Status - - Visuelles Feedback bei Aktionen - -3. **Bulk-Actions:** - - Checkboxen für Mehrfachauswahl - - "Select All" Funktionalität - - Bulk-Actions Bar mit Aktivieren/Deaktivieren/Löschen - - JavaScript für dynamische Anzeige - -4. **API-Endpoints hinzugefügt:** - - `/api/license//toggle` - Toggle einzelner Lizenzstatus - - `/api/licenses/bulk-activate` - Mehrere Lizenzen aktivieren - - `/api/licenses/bulk-deactivate` - Mehrere Lizenzen deaktivieren - - `/api/licenses/bulk-delete` - Mehrere Lizenzen löschen - -5. **Beispieldaten eingefügt:** - - 15 Testkunden - - 18 Lizenzen (verschiedene Status) - - Sessions, Audit-Logs, Login-Attempts - - Backup-Historie - -**Geänderte Dateien:** -- `v2_adminpanel/templates/base.html`: CSS für Sticky-Tables und Bulk-Actions -- `v2_adminpanel/templates/licenses.html`: Komplette Tabellen-Überarbeitung -- `v2_adminpanel/app.py`: 4 neue API-Endpoints für Toggle und Bulk-Operationen -- `v2_adminpanel/sample_data.sql`: Umfangreiche Testdaten erstellt - -**Bugfix:** -- API-Endpoints versuchten `updated_at` zu setzen, obwohl die Spalte nicht existiert -- Entfernt aus allen 3 betroffenen Endpoints - -**Status:** -- ✅ Sticky Headers funktionieren -- ✅ Copy-Buttons mit Clipboard-API -- ✅ Toggle-Switches ändern Lizenzstatus -- ✅ Bulk-Operationen vollständig implementiert -- ✅ Testdaten erfolgreich eingefügt - -### 2025-06-08: UI/UX Überarbeitung - Phase 4 (Sortierbare Tabellen) - -**Problem:** -- Keine Möglichkeit, Tabellen nach verschiedenen Spalten zu sortieren -- Besonders bei großen Datenmengen schwer zu navigieren - -**Lösung - Hybrid-Ansatz:** -1. **Client-seitige Sortierung für kleine Tabellen:** - - Dashboard (3 kleine Übersichtstabellen) - - Blocked IPs (typischerweise wenige Einträge) - - Backups (begrenzte Anzahl) - - JavaScript-basierte Sortierung ohne Reload - -2. **Server-seitige Sortierung für große Tabellen:** - - Licenses (potenziell tausende Einträge) - - Customers (viele Kunden möglich) - - Audit Log (wächst kontinuierlich) - - Sessions (viele aktive/beendete Sessions) - - URL-Parameter für Sortierung mit SQL ORDER BY - -**Implementierung:** -1. **Client-seitige Sortierung:** - - Generische JavaScript-Funktion in base.html - - CSS-Klasse `.sortable-table` für betroffene Tabellen - - Sortier-Indikatoren (↑↓↕) bei Hover/Active - - Unterstützung für Text, Zahlen und deutsche Datumsformate - -2. **Server-seitige Sortierung:** - - Query-Parameter `sort` und `order` in Routes - - Whitelist für erlaubte Sortierfelder (SQL-Injection-Schutz) - - Makro-Funktionen für sortierbare Header - - Sortier-Parameter in Pagination-Links erhalten - -**Geänderte Dateien:** -- `v2_adminpanel/templates/base.html`: CSS und JavaScript für Sortierung -- `v2_adminpanel/templates/dashboard.html`: Client-seitige Sortierung -- `v2_adminpanel/templates/blocked_ips.html`: Client-seitige Sortierung -- `v2_adminpanel/templates/backups.html`: Client-seitige Sortierung -- `v2_adminpanel/templates/licenses.html`: Server-seitige Sortierung -- `v2_adminpanel/templates/customers.html`: Server-seitige Sortierung -- `v2_adminpanel/templates/audit_log.html`: Server-seitige Sortierung -- `v2_adminpanel/templates/sessions.html`: Server-seitige Sortierung (2 Tabellen) -- `v2_adminpanel/app.py`: 4 Routes erweitert für Sortierung - -**Besonderheiten:** -- Sessions-Seite hat zwei unabhängige Tabellen mit eigenen Sortierparametern -- Intelligente Datentyp-Erkennung (numeric, date) für korrekte Sortierung -- Visuelle Sortier-Indikatoren zeigen aktuelle Sortierung -- Alle anderen Filter und Suchparameter bleiben bei Sortierung erhalten - -**Status:** -- ✅ Client-seitige Sortierung für kleine Tabellen -- ✅ Server-seitige Sortierung für große Tabellen -- ✅ Sortier-Indikatoren und visuelle Rückmeldung -- ✅ SQL-Injection-Schutz durch Whitelisting -- ✅ Vollständige Integration mit bestehenden Features - -### 2025-06-08: Bugfix - Sortierlogik korrigiert - -**Problem:** -- Sortierung funktionierte nicht korrekt -- Beim Klick auf Spaltenköpfe wurde immer absteigend sortiert -- Toggle zwischen ASC/DESC funktionierte nicht - -**Ursachen:** -1. **Falsche Bedingungslogik**: Die ursprüngliche Implementierung verwendete eine fehlerhafte Ternär-Bedingung -2. **Berechnete Felder**: Das 'status' Feld in der Lizenztabelle konnte nicht direkt sortiert werden - -**Lösung:** -1. **Sortierlogik korrigiert:** - - Bei neuer Spalte: Immer aufsteigend (ASC) beginnen - - Bei gleicher Spalte: Toggle zwischen ASC und DESC - - Implementiert durch bedingte Links in den Makros - -2. **Spezialbehandlung für berechnete Felder:** - - Status-Feld verwendet CASE-Statement in ORDER BY - - Wiederholt die gleiche Logik wie im SELECT - -**Geänderte Dateien:** -- `v2_adminpanel/templates/licenses.html`: Sortierlogik korrigiert -- `v2_adminpanel/templates/customers.html`: Sortierlogik korrigiert -- `v2_adminpanel/templates/audit_log.html`: Sortierlogik korrigiert -- `v2_adminpanel/templates/sessions.html`: Sortierlogik für beide Tabellen korrigiert -- `v2_adminpanel/app.py`: Spezialbehandlung für Status-Feld in licenses Route - -**Verhalten nach Fix:** -- ✅ Erster Klick auf Spalte: Aufsteigend sortieren -- ✅ Zweiter Klick: Absteigend sortieren -- ✅ Weitere Klicks: Toggle zwischen ASC/DESC -- ✅ Sortierung funktioniert korrekt mit Pagination und Filtern - -### 2025-06-09: Port 8443 geschlossen - API nur noch über Nginx - -**Problem:** -- Doppelte Verfügbarkeit des License Servers (Port 8443 + Nginx) machte keinen Sinn -- Inkonsistente Sicherheitskonfiguration (Nginx hatte Security Headers, Port 8443 nicht) -- Doppelte SSL-Konfiguration nötig -- Verwirrung welcher Zugangsweg genutzt werden soll - -**Lösung:** -- Port-Mapping für License Server in docker-compose.yaml entfernt -- API nur noch über Nginx erreichbar: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com -- Interne Kommunikation zwischen Nginx und License Server bleibt bestehen - -**Vorteile:** -- ✅ Einheitliche Sicherheitskonfiguration (Security Headers, HSTS) -- ✅ Zentrale SSL-Verwaltung nur in Nginx -- ✅ Möglichkeit für Rate Limiting und zentrales Logging -- ✅ Keine zusätzlichen offenen Ports (nur 80/443) -- ✅ Professionellere API-URL ohne Port-Angabe - -**Geänderte Dateien:** -- `v2/docker-compose.yaml`: Port-Mapping "8443:8443" entfernt - -**Hinweis für Client-Software:** -- API-Endpunkte sind weiterhin unter https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com erreichbar -- Keine Änderung der API-URLs nötig, nur Port 8443 ist nicht mehr direkt zugänglich - -**Status:** -- ✅ Port 8443 geschlossen -- ✅ API nur noch über Nginx Reverse Proxy erreichbar -- ✅ Sicherheit erhöht durch zentrale Verwaltung - -### 2025-06-09: Live-Filtering implementiert - -**Problem:** -- Benutzer mussten immer auf "Filter anwenden" klicken -- Umständliche Bedienung, besonders bei mehreren Filterkriterien -- Nicht zeitgemäße User Experience - -**Lösung:** -- JavaScript Event-Listener für automatisches Filtern -- Text-Eingaben: 300ms Debouncing (verzögerte Suche nach Tipp-Pause) -- Dropdowns: Sofortiges Filtern bei Änderung -- "Filter anwenden" Button entfernt, nur "Zurücksetzen" bleibt - -**Implementierte Live-Filter:** -1. **Lizenzübersicht** (licenses.html): - - Suchfeld mit Debouncing - - Typ-Dropdown (Vollversion/Testversion) - - Status-Dropdown (Aktiv/Ablaufend/Abgelaufen/Deaktiviert) - -2. **Kundenübersicht** (customers.html): - - Suchfeld mit Debouncing - - "Suchen" Button entfernt - -3. **Audit-Log** (audit_log.html): - - Benutzer-Textfeld mit Debouncing - - Aktion-Dropdown - - Entität-Dropdown - -**Technische Details:** -- `addEventListener('input')` für Textfelder -- `addEventListener('change')` für Select-Elemente -- `setTimeout()` mit 300ms für Debouncing -- Automatisches `form.submit()` bei Änderungen - -**Vorteile:** -- ✅ Schnellere und intuitivere Bedienung -- ✅ Weniger Klicks erforderlich -- ✅ Moderne User Experience -- ✅ Besonders hilfreich bei komplexen Filterkriterien - -**Status:** -- ✅ Live-Filtering auf allen Hauptseiten implementiert -- ✅ Debouncing verhindert zu viele Server-Requests -- ✅ Zurücksetzen-Button bleibt für schnelles Löschen aller Filter - -### 2025-06-09: Resource Pool System implementiert (Phase 1 & 2) - -**Ziel:** -Ein Pool-System für Domains, IPv4-Adressen und Telefonnummern, wobei bei jeder Lizenzerstellung 1-10 Ressourcen pro Typ zugewiesen werden. Ressourcen haben 3 Status: available, allocated, quarantine. - -**Phase 1 - Datenbank-Schema (✅ Abgeschlossen):** -1. **Neue Tabellen erstellt:** - - `resource_pools` - Haupttabelle für alle Ressourcen - - `resource_history` - Vollständige Historie aller Aktionen - - `resource_metrics` - Performance-Tracking und ROI-Berechnung - - `license_resources` - Zuordnung zwischen Lizenzen und Ressourcen - -2. **Erweiterte licenses Tabelle:** - - `domain_count`, `ipv4_count`, `phone_count` Spalten hinzugefügt - - Constraints: 0-10 pro Resource-Typ - -3. **Indizes für Performance:** - - Status, Type, Allocated License, Quarantine Date - -**Phase 2 - Backend-Implementierung (✅ Abgeschlossen):** -1. **Resource Management Routes:** - - `/resources` - Hauptübersicht mit Statistiken - - `/resources/add` - Bulk-Import von Ressourcen - - `/resources/quarantine/` - Ressourcen sperren - - `/resources/release` - Quarantäne aufheben - - `/resources/history/` - Komplette Historie - - `/resources/metrics` - Performance Dashboard - - `/resources/report` - Report-Generator - -2. **API-Endpunkte:** - - `/api/resources/allocate` - Ressourcen-Zuweisung - - `/api/resources/check-availability` - Verfügbarkeit prüfen - -3. **Integration in Lizenzerstellung:** - - `create_license()` erweitert um Resource-Allocation - - `batch_licenses()` mit Ressourcen-Prüfung für gesamten Batch - - Transaktionale Sicherheit bei Zuweisung - -4. **Dashboard-Integration:** - - Resource-Statistiken in Dashboard eingebaut - - Warning-Level basierend auf Verfügbarkeit - -5. **Navigation erweitert:** - - Resources-Link in Navbar hinzugefügt - -**Was noch zu tun ist:** - -### Phase 3 - UI-Komponenten (🔄 Ausstehend): -1. **Templates erstellen:** - - `resources.html` - Hauptübersicht mit Drag&Drop - - `add_resources.html` - Formular für Bulk-Import - - `resource_history.html` - Historie-Anzeige - - `resource_metrics.html` - Performance Dashboard - -2. **Formulare erweitern:** - - `index.html` - Resource-Dropdowns hinzufügen - - `batch_form.html` - Resource-Dropdowns hinzufügen - -3. **Dashboard-Widget:** - - Resource Pool Statistik mit Ampelsystem - - Warnung bei niedrigem Bestand - -### Phase 4 - Erweiterte Features (🔄 Ausstehend): -1. **Quarantäne-Workflow:** - - Gründe: abuse, defect, maintenance, blacklisted, expired - - Automatische Tests vor Freigabe - - Genehmigungsprozess - -2. **Performance-Metrics:** - - Täglicher Cronjob für Metriken - - ROI-Berechnung - - Issue-Tracking - -3. **Report-Generator:** - - Auslastungsreport - - Performance-Report - - Compliance-Report - -### Phase 5 - Backup erweitern (🔄 Ausstehend): -- Neue Tabellen in Backup einbeziehen: - - resource_pools - - resource_history - - resource_metrics - - license_resources - -### Phase 6 - Testing & Migration (🔄 Ausstehend): -1. **Test-Daten generieren:** - - 500 Test-Domains - - 200 Test-IPs - - 100 Test-Telefonnummern - -2. **Migrations-Script:** - - Bestehende Lizenzen auf default resource_count setzen - -### Phase 7 - Dokumentation (🔄 Ausstehend): -- API-Dokumentation für License Server -- Admin-Handbuch für Resource Management - -**Technische Details:** -- 3-Status-System: available/allocated/quarantine -- Transaktionale Ressourcen-Zuweisung mit FOR UPDATE Lock -- Vollständige Historie mit IP-Tracking -- Drag&Drop UI für Resource-Management geplant -- Automatische Warnung bei < 50 verfügbaren Ressourcen - -**Status:** -- ✅ Datenbank-Schema komplett -- ✅ Backend-Routen implementiert -- ✅ Integration in Lizenzerstellung -- ❌ UI-Templates fehlen noch -- ❌ Erweiterte Features ausstehend -- ❌ Testing und Migration offen - -### 2025-06-09: Resource Pool System UI-Implementierung (Phase 3 & 4) - -**Phase 3 - UI-Komponenten (✅ Abgeschlossen):** - -1. **Neue Templates erstellt:** - - `resources.html` - Hauptübersicht mit Statistiken, Filter, Live-Suche, Pagination - - `add_resources.html` - Bulk-Import Formular mit Validierung - - `resource_history.html` - Timeline-Ansicht der Historie mit Details - - `resource_metrics.html` - Performance Dashboard mit Charts - - `resource_report.html` - Report-Generator UI - -2. **Erweiterte Formulare:** - - `index.html` - Resource-Count Dropdowns (0-10) mit Live-Verfügbarkeitsprüfung - - `batch_form.html` - Resource-Count mit Batch-Berechnung (zeigt Gesamtbedarf) - -3. **Dashboard-Widget:** - - Resource Pool Statistik mit Ampelsystem implementiert - - Zeigt verfügbare/zugeteilte/quarantäne Ressourcen - - Warnung bei niedrigem Bestand (<50) - - Fortschrittsbalken für visuelle Darstellung - -4. **Backend-Anpassungen:** - - `resource_history` Route korrigiert für Object-Style Template-Zugriff - - `resources_metrics` Route vollständig implementiert mit Charts-Daten - - `resources_report` Route erweitert für Template-Anzeige und Downloads - - Dashboard erweitert um Resource-Statistiken - -**Phase 4 - Erweiterte Features (✅ Teilweise):** -1. **Report-Generator:** - - Template für Report-Auswahl erstellt - - 4 Report-Typen: Usage, Performance, Compliance, Inventory - - Export als Excel, CSV oder PDF-Vorschau - - Zeitraum-Auswahl mit Validierung - -**Technische Details der Implementierung:** -- Live-Filtering ohne Reload durch JavaScript -- AJAX-basierte Verfügbarkeitsprüfung -- Bootstrap 5 für konsistentes Design -- Chart.js für Metriken-Visualisierung -- Responsives Design für alle Templates -- Copy-to-Clipboard für Resource-Werte -- Modal-Dialoge für Quarantäne-Aktionen - -**Was noch fehlt:** - -### Phase 5 - Backup erweitern (🔄 Ausstehend): -- Resource-Tabellen in pg_dump einbeziehen: - - resource_pools - - resource_history - - resource_metrics - - license_resources - -### Phase 6 - Testing & Migration (🔄 Ausstehend): -1. **Test-Daten generieren:** - - Script für 500 Test-Domains - - 200 Test-IPv4-Adressen - - 100 Test-Telefonnummern - - Realistische Verteilung über Status - -2. **Migrations-Script:** - - Bestehende Lizenzen auf Default resource_count setzen - - UPDATE licenses SET domain_count=1, ipv4_count=1, phone_count=1 WHERE ... - -### Phase 7 - Dokumentation (🔄 Ausstehend): -- API-Dokumentation für Resource-Endpunkte -- Admin-Handbuch für Resource Management -- Troubleshooting-Guide - -**Offene Punkte für Produktion:** -1. Drag&Drop für Resource-Verwaltung (Nice-to-have) -2. Automatische Quarantäne-Aufhebung nach Zeitablauf -3. E-Mail-Benachrichtigungen bei niedrigem Bestand -4. API für externe Resource-Prüfung -5. Bulk-Delete für Ressourcen -6. Resource-Import aus CSV/Excel - -### 2025-06-09: Resource Pool System finalisiert - -**Problem:** -- Resource Pool System war nur teilweise implementiert -- UI-Templates waren vorhanden, aber nicht dokumentiert -- Test-Daten und Migration fehlten -- Backup-Integration unklar - -**Analyse und Lösung:** -1. **Status-Überprüfung durchgeführt:** - - Alle 5 UI-Templates existierten bereits (resources.html, add_resources.html, etc.) - - Resource-Dropdowns waren bereits in index.html und batch_form.html integriert - - Dashboard-Widget war bereits implementiert - - Backup-System inkludiert bereits alle Tabellen (pg_dump ohne -t Parameter) - -2. **Fehlende Komponenten erstellt:** - - Test-Daten Script: `test_data_resources.sql` - - 500 Test-Domains (400 verfügbar, 50 zugeteilt, 50 in Quarantäne) - - 200 Test-IPv4-Adressen (150 verfügbar, 30 zugeteilt, 20 in Quarantäne) - - 100 Test-Telefonnummern (70 verfügbar, 20 zugeteilt, 10 in Quarantäne) - - Resource History und Metrics für realistische Daten - - - Migration Script: `migrate_existing_licenses.sql` - - Setzt Default Resource Counts (Vollversion: 2, Testversion: 1, Inaktiv: 0) - - Weist automatisch verfügbare Ressourcen zu - - Erstellt Audit-Log Einträge - - Gibt detaillierten Migrationsbericht aus - -**Hinweis:** -- Test-Daten und Migrations-Scripts wurden nach erfolgreicher Anwendung gelöscht - -**Status:** -- ✅ Resource Pool System vollständig implementiert und dokumentiert -- ✅ Alle UI-Komponenten vorhanden und funktionsfähig -- ✅ Integration in Lizenz-Formulare abgeschlossen -- ✅ Dashboard-Widget zeigt Resource-Statistiken -- ✅ Backup-System inkludiert Resource-Tabellen -- ✅ Test-Daten und Migration bereitgestellt - -**Nächste Schritte:** -1. License Server API implementieren (Hauptaufgabe) - -### 2025-06-09: Bugfix - Resource Pool Tabellen fehlten - -**Problem:** -- Admin Panel zeigte "Internal Server Error" -- Dashboard Route versuchte auf `resource_pools` Tabelle zuzugreifen -- Tabelle existierte nicht in der Datenbank - -**Ursache:** -- Bei bereits existierender Datenbank wird init.sql nicht erneut ausgeführt -- Resource Pool Tabellen wurden erst später zum init.sql hinzugefügt -- Docker Container verwendeten noch die alte Datenbankstruktur - -**Lösung:** -1. Separates Script erstellt und manuell in der Datenbank ausgeführt -2. Alle 4 Resource-Tabellen erfolgreich erstellt: - - resource_pools - - resource_history - - resource_metrics - - license_resources - -**Status:** -- ✅ Admin Panel funktioniert wieder -- ✅ Dashboard zeigt Resource Pool Statistiken -- ✅ Alle Resource-Funktionen verfügbar - -**Empfehlung für Neuinstallationen:** -- Bei frischer Installation funktioniert alles automatisch -- Bei bestehenden Installationen: Resource-Tabellen manuell hinzufügen - -### 2025-06-09: Navigation vereinfacht - -**Änderung:** -- Navigationspunkte aus der schwarzen Navbar entfernt -- Links zu Lizenzen, Kunden, Ressourcen, Sessions, Backups und Log entfernt - -**Grund:** -- Cleaner Look mit nur Logo, Timer und Logout -- Alle Funktionen sind weiterhin über das Dashboard erreichbar -- Bessere Übersichtlichkeit und weniger Ablenkung - -**Geänderte Datei:** -- `v2_adminpanel/templates/base.html` - Navbar-Links auskommentiert - -**Status:** -- ✅ Navbar zeigt nur noch Logo, Session-Timer und Logout -- ✅ Navigation erfolgt über Dashboard und Buttons auf den jeweiligen Seiten -- ✅ Alle Funktionen bleiben erreichbar - -### 2025-06-09: Bugfix - Resource Report Einrückungsfehler - -**Problem:** -- Resource Report Route zeigte "Internal Server Error" -- UnboundLocalError: `report_type` wurde verwendet bevor es definiert war - -**Ursache:** -- Fehlerhafte Einrückung in der `resources_report()` Funktion -- `elif` und `else` Blöcke waren falsch eingerückt -- Variablen wurden außerhalb ihres Gültigkeitsbereichs verwendet - -**Lösung:** -- Korrekte Einrückung für alle Conditional-Blöcke wiederhergestellt -- Alle Report-Typen (usage, performance, compliance, inventory) richtig strukturiert -- Excel und CSV Export-Code korrekt eingerückt - -**Geänderte Datei:** -- `v2_adminpanel/app.py` - resources_report() Funktion korrigiert - -**Status:** -- ✅ Resource Report funktioniert wieder -- ✅ Alle 4 Report-Typen verfügbar -- ✅ Export als Excel und CSV möglich - ---- - -## Zusammenfassung der heutigen Arbeiten (2025-06-09) - -### 1. Resource Pool System Finalisierung -- **Ausgangslage**: Resource Pool war nur teilweise dokumentiert -- **Überraschung**: UI-Templates waren bereits vorhanden (nicht dokumentiert) -- **Ergänzt**: - - Test-Daten Script (`test_data_resources.sql`) - - Migration Script (`migrate_existing_licenses.sql`) -- **Status**: ✅ Vollständig implementiert - -### 2. Database Migration Bug -- **Problem**: Admin Panel zeigte "Internal Server Error" -- **Ursache**: Resource Pool Tabellen fehlten in bestehender DB -- **Lösung**: Separates Script `create_resource_tables.sql` erstellt -- **Status**: ✅ Behoben - -### 3. UI Cleanup -- **Änderung**: Navigation aus Navbar entfernt -- **Effekt**: Cleaner Look, Navigation nur über Dashboard -- **Status**: ✅ Implementiert - -### 4. Resource Report Bug -- **Problem**: Einrückungsfehler in `resources_report()` Funktion -- **Lösung**: Korrekte Einrückung wiederhergestellt -- **Status**: ✅ Behoben - -### Hinweis: -- Test-Daten-Scripts wurden nach erfolgreicher Anwendung gelöscht - -### 2025-06-09: Bugfix - Resource Quarantäne Modal - -**Problem:** -- Quarantäne-Button funktionierte nicht -- Modal öffnete sich nicht beim Klick - -**Ursache:** -- Bootstrap 5 vs Bootstrap 4 API-Inkompatibilität -- Modal wurde mit Bootstrap 4 Syntax (`modal.modal('show')`) aufgerufen -- jQuery wurde nach Bootstrap geladen - -**Lösung:** -1. **JavaScript angepasst:** - - Von jQuery Modal-API zu nativer Bootstrap 5 Modal-API gewechselt - - `new bootstrap.Modal(element).show()` statt `$(element).modal('show')` - -2. **HTML-Struktur aktualisiert:** - - Modal-Close-Button: `data-bs-dismiss="modal"` statt `data-dismiss="modal"` - - `btn-close` Klasse statt custom close button - - Form-Klassen: `mb-3` statt `form-group`, `form-select` statt `form-control` für Select - -3. **Script-Reihenfolge korrigiert:** - - jQuery vor Bootstrap laden für korrekte Initialisierung - -**Geänderte Dateien:** -- `v2_adminpanel/templates/resources.html` -- `v2_adminpanel/templates/base.html` - -**Status:** ✅ Behoben - -### 2025-06-09: Resource Pool UI Redesign - -**Ziel:** -- Komplettes Redesign des Resource Pool Managements für bessere Benutzerfreundlichkeit -- Konsistentes Design mit dem Rest der Anwendung - -**Durchgeführte Änderungen:** - -1. **resources.html - Hauptübersicht:** - - Moderne Statistik-Karten mit Hover-Effekten - - Farbcodierte Progress-Bars mit Tooltips - - Verbesserte Tabelle mit Icons und Status-Badges - - Live-Filter mit sofortiger Suche - - Überarbeitete Quarantäne-Modal für Bootstrap 5 - - Responsive Design mit Grid-Layout - -2. **add_resources.html - Ressourcen hinzufügen:** - - 3-Schritt Wizard-ähnliches Interface - - Visueller Ressourcentyp-Selector mit Icons - - Live-Validierung mit Echtzeit-Feedback - - Statistik-Anzeige (Gültig/Duplikate/Ungültig) - - Formatierte Beispiele mit Erklärungen - - Verbesserte Fehlerbehandlung - -3. **resource_history.html - Historie:** - - Zentrierte Resource-Anzeige mit großen Icons - - Info-Grid Layout für Details - - Modernisierte Timeline mit Hover-Effekten - - Farbcodierte Action-Icons - - Verbesserte Darstellung von Details - -4. **resource_metrics.html - Metriken:** - - Dashboard-Style Metrik-Karten mit Icon-Badges - - Modernisierte Charts mit besseren Farben - - Performance-Tabellen mit Progress-Bars - - Trend-Indikatoren für Performance - - Responsives Grid-Layout - -**Design-Verbesserungen:** -- Konsistente Emoji-Icons für bessere visuelle Kommunikation -- Einheitliche Farbgebung (Blau/Lila/Grün für Ressourcentypen) -- Card-basiertes Layout mit Schatten und Hover-Effekten -- Bootstrap 5 kompatible Komponenten -- Verbesserte Typografie und Spacing - -**Technische Details:** -- Bootstrap 5 Modal-API statt jQuery -- CSS Grid für responsive Layouts -- Moderne Chart.js Konfiguration -- Optimierte JavaScript-Validierung - -**Geänderte Dateien:** -- `v2_adminpanel/templates/resources.html` -- `v2_adminpanel/templates/add_resources.html` -- `v2_adminpanel/templates/resource_history.html` -- `v2_adminpanel/templates/resource_metrics.html` - -**Status:** ✅ Abgeschlossen - -### 2025-06-09: Zusammenfassung der heutigen Arbeiten - -**Durchgeführte Aufgaben:** - -1. **Quarantäne-Funktion repariert:** - - Bootstrap 5 Modal-API implementiert - - data-bs-dismiss statt data-dismiss - - jQuery vor Bootstrap laden - -2. **Resource Pool UI komplett überarbeitet:** - - Alle 4 Templates modernisiert (resources, add_resources, resource_history, resource_metrics) - - Konsistentes Design mit Emoji-Icons - - Einheitliche Farbgebung (Blau/Lila/Grün) - - Bootstrap 5 kompatible Komponenten - - Responsive Grid-Layouts - -**Aktuelle Projekt-Status:** -- ✅ Admin Panel voll funktionsfähig -- ✅ Resource Pool Management mit modernem UI -- ✅ PostgreSQL mit allen Tabellen -- ✅ Nginx Reverse Proxy mit SSL -- ❌ Lizenzserver noch nicht implementiert (nur Platzhalter) - -**Nächste Schritte:** -- Lizenzserver implementieren -- API-Endpunkte für Lizenzvalidierung -- Heartbeat-System für Sessions -- Versionsprüfung implementieren -1. `v2_adminpanel/templates/base.html` - Navigation entfernt -2. `v2_adminpanel/app.py` - Resource Report Einrückung korrigiert -3. `JOURNAL.md` - Alle Änderungen dokumentiert - -### Offene Hauptaufgabe: -- **License Server API** - Noch komplett zu implementieren - - `/api/version` - Versionscheck - - `/api/validate` - Lizenzvalidierung - - `/api/heartbeat` - Session-Management - -### 2025-06-09: Resource Pool Internal Error behoben - -**Problem:** -- Internal Server Error beim Zugriff auf `/resources` -- NameError: name 'datetime' is not defined in Template - -**Ursache:** -- Fehlende `datetime` und `timedelta` Objekte im Template-Kontext -- Falsche Array-Indizes in resources.html für activity-Daten - -**Lösung:** -1. **app.py (Zeile 2797-2798):** - - `datetime=datetime` und `timedelta=timedelta` zu render_template hinzugefügt - -2. **resources.html (Zeile 484-490):** - - Array-Indizes korrigiert: - - activity[0] = action - - activity[1] = action_by - - activity[2] = action_at - - activity[3] = resource_type - - activity[4] = resource_value - - activity[5] = details - -**Geänderte Dateien:** -- `v2_adminpanel/app.py` -- `v2_adminpanel/templates/resources.html` - -**Status:** ✅ Behoben - Resource Pool funktioniert wieder einwandfrei - -### 2025-06-09: Passwort-Änderung und 2FA implementiert - -**Ziel:** -- Benutzer können ihr Passwort ändern -- Zwei-Faktor-Authentifizierung (2FA) mit TOTP -- Komplett kostenlose Lösung ohne externe Services - -**Implementierte Features:** - -1. **Datenbank-Erweiterung:** - - Neue `users` Tabelle mit Passwort-Hash und 2FA-Feldern - - Unterstützung für TOTP-Secrets und Backup-Codes - - Migration von Environment-Variablen zu Datenbank - -2. **Passwort-Management:** - - Sichere Passwort-Hashes mit bcrypt - - Passwort-Änderung mit Verifikation des alten Passworts - - Passwort-Stärke-Indikator im Frontend - -3. **2FA-Implementation:** - - TOTP-basierte 2FA (Google Authenticator, Authy kompatibel) - - QR-Code-Generierung für einfaches Setup - - 8 Backup-Codes für Notfallzugriff - - Backup-Codes als Textdatei downloadbar - -4. **Neue Routen:** - - `/profile` - Benutzerprofil mit Passwort und 2FA-Verwaltung - - `/verify-2fa` - 2FA-Verifizierung beim Login - - `/profile/setup-2fa` - 2FA-Einrichtung mit QR-Code - - `/profile/enable-2fa` - 2FA-Aktivierung - - `/profile/disable-2fa` - 2FA-Deaktivierung - - `/profile/change-password` - Passwort ändern - -5. **Sicherheits-Features:** - - Fallback zu Environment-Variablen für Rückwärtskompatibilität - - Session-Management für 2FA-Verifizierung - - Fehlgeschlagene 2FA-Versuche werden protokolliert - - Verwendete Backup-Codes werden entfernt - -**Verwendete Libraries (alle kostenlos):** -- `bcrypt` - Passwort-Hashing -- `pyotp` - TOTP-Generierung und Verifizierung -- `qrcode[pil]` - QR-Code-Generierung - -**Migration:** -- Migrations-Script für existierende Benutzer erstellt und angewendet -- Erhält bestehende Credentials aus Environment-Variablen -- Erstellt Datenbank-Einträge mit gehashten Passwörtern - -**Geänderte Dateien:** -- `v2_adminpanel/init.sql` - Users-Tabelle hinzugefügt -- `v2_adminpanel/requirements.txt` - Neue Dependencies -- `v2_adminpanel/app.py` - Auth-Funktionen und neue Routen -- `v2_adminpanel/templates/base.html` - Profil-Link hinzugefügt -- `v2_adminpanel/templates/profile.html` - Profil-Seite (neu) -- `v2_adminpanel/templates/verify_2fa.html` - 2FA-Verifizierung (neu) -- `v2_adminpanel/templates/setup_2fa.html` - 2FA-Setup (neu) -- `v2_adminpanel/templates/backup_codes.html` - Backup-Codes Anzeige (neu) - -**Status:** ✅ Vollständig implementiert - -### 2025-06-09: Internal Server Error behoben und UI-Design angepasst - -### 2025-06-09: Journal-Bereinigung und Projekt-Cleanup - -**Durchgeführte Aufgaben:** - -1. **Überflüssige SQL-Dateien gelöscht:** - - Migrations-Scripts - Waren nur für einmalige Anwendung nötig - - Test-Daten-Scripts - Nach Anwendung nicht mehr benötigt - -2. **Journal aktualisiert:** - - Veraltete Todo-Liste korrigiert (viele Features bereits implementiert) - - Passwörter aus Zugangsdaten entfernt (Sicherheit) - - "Bekannte Probleme" auf aktuellen Stand gebracht - - Neuer Abschnitt "Best Practices für Produktiv-Migration" hinzugefügt - -3. **Status-Klärungen:** - - Alle Daten sind Testdaten (PoC-Phase) - - 2FA ist implementiert und funktionsfähig - - Resource Pool System ist vollständig implementiert - - Port 8443 ist geschlossen (nur über Nginx erreichbar) - -**Noch zu erledigen:** -- Nginx Config anpassen (proxy_pass von https:// auf http://) -- License Server API implementieren (Hauptaufgabe) - -**Problem:** -- Internal Server Error nach Login wegen fehlender `users` Tabelle -- UI-Design der neuen 2FA-Seiten passte nicht zum Rest der Anwendung - -**Lösung:** - -1. **Datenbank-Fix:** - - Users-Tabelle wurde nicht automatisch erstellt - - Manuell mit SQL-Script nachgeholt - - Migration erfolgreich durchgeführt - - Beide Admin-User (rac00n, w@rh@mm3r) migriert - -2. **UI-Design Überarbeitung:** - - Profile-Seite im Dashboard-Stil mit Cards und Hover-Effekten - - 2FA-Setup mit nummerierten Schritten und modernem Card-Design - - Backup-Codes Seite mit Animation und verbessertem Layout - - Konsistente Farbgebung und Icons - - Verbesserte Benutzerführung mit visuellen Hinweisen - -**Design-Features:** -- Card-basiertes Layout mit Schatten-Effekten -- Hover-Animationen für bessere Interaktivität -- Farbcodierte Sicherheitsstatus-Anzeigen -- Passwort-Stärke-Indikator mit visueller Rückmeldung -- Responsive Design für alle Bildschirmgrößen -- Print-optimiertes Layout für Backup-Codes - -**Hinweis:** -- Users-Tabelle wurde manuell erstellt (Script danach gelöscht) - -### 2025-06-09: Journal-Bereinigung - -**Durchgeführte Änderungen:** -- Todo-Listen und Status-Informationen entfernt -- Fokus auf chronologische Dokumentation der Änderungen -- Veraltete Dateien und Scripts dokumentiert als gelöscht - -### 2025-06-09: Nginx Config angepasst - -**Änderung:** -- proxy_pass für License Server von `https://license-server:8443` auf `http://license-server:8443` geändert -- `proxy_ssl_verify off` entfernt (nicht mehr nötig bei HTTP) -- WebSocket-Support hinzugefügt (für zukünftige Features) - -**Grund:** -- License Server läuft intern auf HTTP (wie Admin Panel) -- SSL-Termination erfolgt nur am Nginx -- Vereinfachte Konfiguration ohne doppelte SSL-Verschlüsselung - -**Hinweis:** -Docker-Container müssen neu gestartet werden, damit die Änderung wirksam wird: -```bash -docker-compose down -docker-compose up -d -``` -- `v2_adminpanel/templates/profile.html` - Komplett überarbeitet -- `v2_adminpanel/templates/setup_2fa.html` - Neues Step-by-Step Design -- `v2_adminpanel/templates/backup_codes.html` - Modernisiertes Layout - -**Status:** ✅ Abgeschlossen - Login funktioniert, UI im konsistenten Design - -### 2025-06-09: Lizenzschlüssel-Format geändert - -**Änderung:** -- Altes Format: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` (z.B. AF-202506F-V55Y-9DWE-GL5G) -- Neues Format: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` (z.B. AF-F-202506-V55Y-9DWE-GL5G) - -**Vorteile:** -- Klarere Struktur mit separatem Typ-Indikator -- Einfacher zu lesen und zu verstehen -- Typ (F/T) sofort im zweiten Block erkennbar - -**Geänderte Dateien:** -- `v2_adminpanel/app.py`: - - `generate_license_key()` - Generiert Keys im neuen Format - - `validate_license_key()` - Validiert Keys mit neuem Regex-Pattern -- `v2_adminpanel/templates/index.html`: - - Placeholder und Pattern für Input-Feld angepasst - - JavaScript charAt() Position für Typ-Prüfung korrigiert -- `v2_adminpanel/templates/batch_form.html`: - - Vorschau-Format für Batch-Generierung angepasst - -**Hinweis:** Alte Keys im bisherigen Format bleiben ungültig. Bei Bedarf könnte eine Migration oder Dual-Support implementiert werden. - -**Status:** ✅ Implementiert - -### 2025-06-09: Datenbank-Migration der Lizenzschlüssel - -**Durchgeführt:** -- Alle bestehenden Lizenzschlüssel in der Datenbank auf das neue Format migriert -- 18 Lizenzschlüssel erfolgreich konvertiert (16 Full, 2 Test) - -**Migration:** -- Von: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` -- Nach: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` - -**Beispiele:** -- Alt: `AF-202506F-V55Y-9DWE-GL5G` -- Neu: `AF-F-202506-V55Y-9DWE-GL5G` - -**Hinweis:** -- Migrations-Scripts wurden nach erfolgreicher Anwendung gelöscht - -**Status:** ✅ Alle Lizenzschlüssel erfolgreich migriert - -### 2025-06-09: Kombinierte Kunden-Lizenz-Ansicht implementiert - -**Problem:** -- Umständliche Navigation zwischen Kunden- und Lizenzseiten -- Viel Hin-und-Her-Springen bei der Verwaltung -- Kontext-Verlust beim Wechseln zwischen Ansichten - -**Lösung:** -Master-Detail View mit 2-Spalten Layout implementiert - -**Phase 1-3 abgeschlossen:** -1. **Backend-Implementierung:** - - Neue Route `/customers-licenses` für kombinierte Ansicht - - API-Endpoints für AJAX: `/api/customer//licenses`, `/api/customer//quick-stats` - - API-Endpoint `/api/license//quick-edit` für Inline-Bearbeitung - - Optimierte SQL-Queries mit JOIN für Performance - -2. **Template-Erstellung:** - - Neues Template `customers_licenses.html` mit Master-Detail Layout - - Links: Kundenliste (30%) mit Suchfeld - - Rechts: Lizenzen des ausgewählten Kunden (70%) - - Responsive Design (Mobile: untereinander) - - JavaScript für dynamisches Laden ohne Seitenreload - - Keyboard-Navigation (↑↓ für Kundenwechsel) - -3. **Integration:** - - Dashboard: Neuer Button "Kunden & Lizenzen" - - Customers-Seite: Link zur kombinierten Ansicht - - Licenses-Seite: Link zur kombinierten Ansicht - - Lizenz-Erstellung: Unterstützung für vorausgewählten Kunden - - API /api/customers erweitert für Einzelabruf per ID - -**Features:** -- Live-Suche in Kundenliste -- Quick-Actions: Copy License Key, Toggle Status -- Modal für neue Lizenz direkt aus Kundenansicht -- URL-Update ohne Reload für Bookmarking -- Loading-States während AJAX-Calls -- Visuelles Feedback (aktiver Kunde hervorgehoben) - -**Noch ausstehend:** -- Phase 4: Inline-Edit für Lizenzdetails -- Phase 5: Erweiterte Error-Handling und Polish - -**Geänderte Dateien:** -- `v2_adminpanel/app.py` - Neue Routen und API-Endpoints -- `v2_adminpanel/templates/customers_licenses.html` - Neues Template -- `v2_adminpanel/templates/dashboard.html` - Neuer Button -- `v2_adminpanel/templates/customers.html` - Link zur kombinierten Ansicht -- `v2_adminpanel/templates/licenses.html` - Link zur kombinierten Ansicht -- `v2_adminpanel/templates/index.html` - Unterstützung für preselected_customer_id - -**Status:** ✅ Grundfunktionalität implementiert und funktionsfähig - -### 2025-06-09: Kombinierte Ansicht - Fertigstellung - -**Abgeschlossen:** -- Phase 1-3 der kombinierten Kunden-Lizenz-Ansicht vollständig implementiert -- Master-Detail Layout funktioniert einwandfrei -- AJAX-basiertes Laden ohne Seitenreload -- Keyboard-Navigation mit Pfeiltasten -- Quick-Actions für Copy und Toggle Status -- Integration in alle relevanten Seiten - -**Verbesserung gegenüber vorher:** -- Kein Hin-und-Her-Springen mehr zwischen Seiten -- Kontext bleibt erhalten beim Arbeiten mit Kunden -- Schnellere Navigation und bessere Übersicht -- Deutlich verbesserte User Experience - -**Optional für später (Phase 4-5):** -- Inline-Edit für weitere Felder -- Erweiterte Quick-Actions -- Session-basierte Filter-Persistenz - -Die Hauptproblematik der umständlichen Navigation ist damit gelöst! - -### 2025-06-09: Test-Flag für Lizenzen implementiert - -**Ziel:** -- Klare Trennung zwischen Testdaten und echten Produktivdaten -- Testdaten sollen von der Software ignoriert werden können -- Bessere Übersicht im Admin Panel - -**Durchgeführte Änderungen:** - -1. **Datenbank-Schema (init.sql):** - - Neue Spalte `is_test BOOLEAN DEFAULT FALSE` zur `licenses` Tabelle hinzugefügt - - Migration für bestehende Daten: Alle werden als `is_test = TRUE` markiert - - Index `idx_licenses_is_test` für bessere Performance - -2. **Backend (app.py):** - - Dashboard-Queries filtern Testdaten mit `WHERE is_test = FALSE` aus - - Lizenz-Erstellung: Neues Checkbox-Feld für Test-Markierung - - Lizenz-Bearbeitung: Test-Status kann geändert werden - - Export: Optional mit/ohne Testdaten (`?include_test=true`) - - Bulk-Operationen: Nur auf Live-Daten anwendbar - - Neue Filter in Lizenzliste: "🧪 Testdaten" und "🚀 Live-Daten" - -3. **Frontend Templates:** - - **index.html**: Checkbox "Als Testdaten markieren" bei Lizenzerstellung - - **edit_license.html**: Checkbox zum Ändern des Test-Status - - **licenses.html**: Badge 🧪 für Testdaten, neue Filteroptionen - - **dashboard.html**: Info-Box zeigt Anzahl der Testdaten - - **batch_form.html**: Option für Batch-Test-Lizenzen - -4. **Audit-Log Integration:** - - `is_test` Feld wird bei CREATE/UPDATE geloggt - - Nachvollziehbarkeit von Test/Live-Status-Änderungen - -**Technische Details:** -- Testdaten werden in allen Statistiken ausgefiltert -- License Server API wird Lizenzen mit `is_test = TRUE` ignorieren -- Resource Pool bleibt unverändert (kann Test- und Live-Ressourcen verwalten) - -**Migration der bestehenden Daten:** -```sql -UPDATE licenses SET is_test = TRUE; -- Alle aktuellen Daten sind Testdaten -``` - -**Status:** ✅ Implementiert - -### 2025-06-09: Test-Flag für Kunden und Resource Pools erweitert - -**Ziel:** -- Konsistentes Test-Daten-Management über alle Entitäten -- Kunden und Resource Pools können ebenfalls als Testdaten markiert werden -- Automatische Verknüpfung: Test-Kunde → Test-Lizenzen → Test-Ressourcen - -**Durchgeführte Änderungen:** - -1. **Datenbank-Schema erweitert:** - - `customers.is_test BOOLEAN DEFAULT FALSE` hinzugefügt - - `resource_pools.is_test BOOLEAN DEFAULT FALSE` hinzugefügt - - Indizes für bessere Performance erstellt - - Migrations in init.sql integriert - -2. **Backend (app.py) - Erweiterte Logik:** - - Dashboard: Separate Zähler für Test-Kunden und Test-Ressourcen - - Kunde-Erstellung: Erbt Test-Status von Lizenz - - Test-Kunde erzwingt Test-Lizenzen - - Resource-Zuweisung: Test-Lizenzen bekommen nur Test-Ressourcen - - Customer-Management mit is_test Filter - -3. **Frontend Updates:** - - **customers.html**: 🧪 Badge für Test-Kunden - - **edit_customer.html**: Checkbox für Test-Status - - **dashboard.html**: Erweiterte Test-Statistik (Lizenzen, Kunden, Ressourcen) - -4. **Geschäftslogik:** - - Wenn neuer Kunde bei Test-Lizenz erstellt wird → automatisch Test-Kunde - - Wenn Test-Kunde gewählt wird → Lizenz automatisch als Test markiert - - Resource Pool Allocation prüft Test-Status für korrekte Zuweisung - -**Migration der bestehenden Daten:** -```sql -UPDATE customers SET is_test = TRUE; -- 5 Kunden -UPDATE resource_pools SET is_test = TRUE; -- 20 Ressourcen -``` - -**Technische Details:** -- Konsistente Test/Live-Trennung über alle Ebenen -- Dashboard-Statistiken zeigen nur Live-Daten -- Test-Ressourcen werden nur Test-Lizenzen zugewiesen -- Alle bestehenden Daten sind jetzt als Test markiert - -**Status:** ✅ Vollständig implementiert - -### 2025-06-09 (17:20 - 18:13): Kunden-Lizenz-Verwaltung konsolidiert - -**Problem:** -- Kombinierte Ansicht `/customers-licenses` hatte Formatierungs- und Funktionsprobleme -- Kunden wurden nicht angezeigt -- Bootstrap Icons fehlten -- JavaScript-Fehler beim Modal -- Inkonsistentes Design im Vergleich zu anderen Seiten -- Testkunden-Filter wurde beim Navigieren nicht beibehalten - -**Durchgeführte Änderungen:** - -1. **Frontend-Fixes (base.html):** - - Bootstrap Icons CSS hinzugefügt: `bootstrap-icons@1.11.3/font/bootstrap-icons.min.css` - - Bootstrap JavaScript Bundle bereits vorhanden, Reihenfolge optimiert - -2. **customers_licenses.html komplett überarbeitet:** - - Container-Klasse von `container-fluid` auf `container py-5` geändert - - Emojis und Button-Styling vereinheitlicht (👥 Kunden & Lizenzen) - - Export-Dropdown wie in anderen Ansichten implementiert - - Card-Styling mit Schatten für einheitliches Design - - Checkbox "Testkunden anzeigen" mit Status-Beibehaltung - - JavaScript-Funktionen korrigiert: - - copyToClipboard mit event.currentTarget - - showNewLicenseModal mit Bootstrap Modal - - Header-Update beim AJAX-Kundenwechsel - - URL-Parameter `show_test` wird überall beibehalten - -3. **Backend-Anpassungen (app.py):** - - customers_licenses Route: Optional Testkunden anzeigen mit `show_test` Parameter - - Redirects von `/customers` und `/licenses` auf `/customers-licenses` implementiert - - Alte Route-Funktionen entfernt (kein toter Code mehr) - - edit_license und edit_customer: Redirects behalten show_test Parameter bei - - Dashboard-Links zeigen jetzt auf kombinierte Ansicht - -4. **Navigation optimiert:** - - Dashboard: Klick auf Kunden/Lizenzen-Statistik führt zur kombinierten Ansicht - - Alle Edit-Links behalten den show_test Parameter bei - - Konsistente User Experience beim Navigieren - -**Technische Details:** -- AJAX-Loading für dynamisches Laden der Lizenzen -- Keyboard-Navigation (↑↓) für Kundenliste -- Responsive Design mit Bootstrap Grid -- Modal-Dialoge für Bestätigungen -- Live-Suche in der Kundenliste - -**Resultat:** -- ✅ Einheitliches Design mit anderen Admin-Panel-Seiten -- ✅ Alle Funktionen arbeiten korrekt -- ✅ Testkunden-Filter bleibt erhalten -- ✅ Keine redundanten Views mehr -- ✅ Zentrale Verwaltung für Kunden und Lizenzen - -**Status:** ✅ Vollständig implementiert - -### 2025-06-09: Test-Daten Checkbox Persistenz implementiert - -**Problem:** -- Die "Testkunden anzeigen" Checkbox in `/customers-licenses` verlor ihren Status beim Navigieren zwischen Seiten -- Wenn Benutzer zu anderen Seiten (Resources, Audit Log, etc.) wechselten und zurückkehrten, war die Checkbox wieder deaktiviert -- Benutzer mussten die Checkbox jedes Mal neu aktivieren, was umständlich war - -**Lösung:** -- Globale JavaScript-Funktion `preserveShowTestParameter()` in base.html implementiert -- Die Funktion prüft beim Laden jeder Seite, ob `show_test=true` in der URL ist -- Wenn ja, wird dieser Parameter automatisch an alle internen Links angehängt -- Backend-Route `/create` wurde angepasst, um den Parameter bei Redirects beizubehalten - -**Technische Details:** -1. **base.html** - JavaScript-Funktion hinzugefügt: - - Läuft beim `DOMContentLoaded` Event - - Findet alle Links die mit "/" beginnen - - Fügt `show_test=true` Parameter hinzu wenn nicht bereits vorhanden - - Überspringt Fragment-Links (#) und Links die bereits den Parameter haben - -2. **app.py** - Route-Anpassung: - - `/create` Route behält jetzt `show_test` Parameter bei Redirects bei - - Andere Routen (edit_license, edit_customer) behalten Parameter bereits bei - -**Vorteile:** -- ✅ Konsistente User Experience beim Navigieren -- ✅ Keine manuelle Anpassung aller Links nötig -- ✅ Funktioniert automatisch für alle zukünftigen Links -- ✅ Minimaler Code-Overhead - -**Geänderte Dateien:** -- `v2_adminpanel/templates/base.html` -- `v2_adminpanel/app.py` - -**Status:** ✅ Vollständig implementiert - -### 2025-06-09: Bearbeiten-Button Fehler behoben - -**Problem:** -- Der "Bearbeiten" Button neben dem Kundennamen in der `/customers-licenses` Ansicht verursachte einen Internal Server Error -- Die URL-Konstruktion war fehlerhaft wenn kein `show_test` Parameter vorhanden war -- Die edit_customer.html Template hatte falsche Array-Indizes und veraltete Links - -**Ursache:** -1. Die href-Attribute wurden falsch konstruiert: - - Alt: `/customer/edit/ID{% if show_test %}?ref=customers-licenses&show_test=true{% endif %}` - - Problem: Ohne show_test fehlte das `?ref=customers-licenses` komplett - -2. Die SQL-Abfrage in edit_customer() holte nur 4 Felder, aber das Template erwartete 5: - - Query: `SELECT id, name, email, is_test` - - Template erwartete: `customer[3]` = created_at und `customer[4]` = is_test - -3. Veraltete Links zu `/customers` statt `/customers-licenses` - -**Lösung:** -1. URL-Konstruktion korrigiert in beiden Fällen: - - Neu: `/customer/edit/ID?ref=customers-licenses{% if show_test %}&show_test=true{% endif %}` - -2. SQL-Query erweitert um created_at: - - Neu: `SELECT id, name, email, created_at, is_test` - -3. Template-Indizes korrigiert: - - is_test Checkbox nutzt jetzt `customer[4]` - -4. Navigation-Links aktualisiert: - - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter - -**Geänderte Dateien:** -- `v2_adminpanel/templates/customers_licenses.html` (Zeilen 103 und 295) -- `v2_adminpanel/app.py` (edit_customer Route) -- `v2_adminpanel/templates/edit_customer.html` - -**Status:** ✅ Behoben - -### 2025-06-09: Unnötigen Lizenz-Erstellungs-Popup entfernt - -**Änderung:** -- Der Bestätigungs-Popup "Möchten Sie eine neue Lizenz für KUNDENNAME erstellen?" wurde entfernt -- Klick auf "Neue Lizenz" Button führt jetzt direkt zur Lizenzerstellung - -**Technische Details:** -- Modal-HTML komplett entfernt -- `showNewLicenseModal()` Funktion vereinfacht - navigiert jetzt direkt zu `/create?customer_id=X` -- URL-Parameter (wie `show_test`) werden dabei beibehalten - -**Vorteile:** -- ✅ Ein Klick weniger für Benutzer -- ✅ Schnellerer Workflow -- ✅ Weniger Code zu warten - -**Geänderte Dateien:** -- `v2_adminpanel/templates/customers_licenses.html` - -**Status:** ✅ Implementiert - -### 2025-06-09: Testkunden-Checkbox bleibt jetzt bei Lizenz/Kunden-Bearbeitung erhalten - -**Problem:** -- Bei "Lizenz bearbeiten" ging der "Testkunden anzeigen" Haken verloren beim Zurückkehren -- Die Navigation-Links in edit_license.html zeigten auf `/licenses` statt `/customers-licenses` -- Der show_test Parameter wurde nur über den unsicheren Referrer übertragen - -**Lösung:** -1. **Navigation-Links korrigiert**: - - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter - - Betrifft: "Zurück zur Übersicht" und "Abbrechen" Buttons - -2. **Hidden Form Field hinzugefügt**: - - Sowohl in edit_license.html als auch edit_customer.html - - Überträgt den show_test Parameter sicher beim POST - -3. **Route-Logik verbessert**: - - Parameter wird aus Form-Daten ODER GET-Parametern gelesen - - Nicht mehr auf unsicheren Referrer angewiesen - - Funktioniert sowohl bei Speichern als auch Abbrechen - -**Technische Details:** -- Templates prüfen `request.args.get('show_test')` für Navigation -- Hidden Input: `` -- Routes: `show_test = request.form.get('show_test') or request.args.get('show_test')` - -**Geänderte Dateien:** -- `v2_adminpanel/templates/edit_license.html` -- `v2_adminpanel/templates/edit_customer.html` -- `v2_adminpanel/app.py` (edit_license und edit_customer Routen) - -**Status:** ✅ Vollständig implementiert - -### 2025-06-09 22:02: Konsistente Sortierung bei Status-Toggle - -**Problem:** -- Beim Klicken auf den An/Aus-Knopf (Status-Toggle) in der Kunden & Lizenzen Ansicht änderte sich die Reihenfolge der Lizenzen -- Dies war verwirrend für Benutzer, da die Position der gerade bearbeiteten Lizenz springen konnte - -**Ursache:** -- Die Sortierung `ORDER BY l.created_at DESC` war nicht stabil genug -- Bei gleichem Erstellungszeitpunkt konnte die Datenbank die Reihenfolge inkonsistent zurückgeben - -**Lösung:** -- Sekundäres Sortierkriterium hinzugefügt: `ORDER BY l.created_at DESC, l.id DESC` -- Dies stellt sicher, dass bei gleichem Erstellungsdatum nach ID sortiert wird -- Die Reihenfolge bleibt jetzt konsistent, auch nach Status-Änderungen - -**Geänderte Dateien:** -- `v2_adminpanel/app.py`: - - Zeile 2278: `/customers-licenses` Route - - Zeile 2319: `/api/customer//licenses` API-Route - -### 2025-06-10 00:01: Verbesserte Integration zwischen Kunden & Lizenzen und Resource Pool - -**Problem:** -- Umständliche Navigation zwischen Kunden & Lizenzen und Resource Pool Bereichen -- Keine direkte Verbindung zwischen beiden Ansichten -- Benutzer mussten ständig zwischen verschiedenen Seiten hin- und herspringen - -**Implementierte Lösung - 5 Phasen:** - -1. **Phase 1: Ressourcen-Details in Kunden & Lizenzen Ansicht** - - API `/api/customer/{id}/licenses` erweitert um konkrete Ressourcen-Informationen - - Neue API `/api/license/{id}/resources` für detaillierte Ressourcen einer Lizenz - - Anzeige der zugewiesenen Ressourcen mit Info-Buttons und Modal-Dialogen - - Klickbare Links zu Ressourcen-Details im Resource Pool - -2. **Phase 2: Quick-Actions für Ressourcenverwaltung** - - "Ressourcen verwalten" Button (Zahnrad-Icon) bei jeder Lizenz - - Modal mit Übersicht aller zugewiesenen Ressourcen - - Vorbereitung für Quarantäne-Funktionen und Ressourcen-Austausch - -3. **Phase 3: Ressourcen-Preview bei Lizenzerstellung** - - Live-Anzeige verfügbarer Ressourcen beim Ändern der Anzahl - - Erweiterte Verfügbarkeitsanzeige mit Badges (OK/Niedrig/Kritisch) - - Warnungen bei niedrigem Bestand mit visuellen Hinweisen - - Fortschrittsbalken zur Visualisierung der Verfügbarkeit - -4. **Phase 4: Dashboard-Integration** - - Resource Pool Widget mit erweiterten Links - - Kritische Warnungen bei < 50 Ressourcen mit "Auffüllen" Button - - Direkte Navigation zu gefilterten Ansichten (nach Typ/Status) - - Verbesserte visuelle Darstellung mit Tooltips - -5. **Phase 5: Bidirektionale Navigation** - - Von Resource Pool: Links zu Kunden/Lizenzen bei zugewiesenen Ressourcen - - "Zurück zu Kunden" Button wenn von Kunden & Lizenzen kommend - - Navigation-Links im Dashboard für schnellen Zugriff - - SQL-Query erweitert um customer_id für direkte Verlinkung - -**Technische Details:** -- JavaScript-Funktionen für Modal-Dialoge und Ressourcen-Details -- Erweiterte SQL-Queries mit JOINs für Ressourcen-Informationen -- Bootstrap 5 Tooltips und Modals für bessere UX -- Globale Variable `currentLicenses` für Caching der Lizenzdaten - -**Geänderte Dateien:** -- `v2_adminpanel/app.py` - Neue APIs und erweiterte Queries -- `v2_adminpanel/templates/customers_licenses.html` - Ressourcen-Details und Modals -- `v2_adminpanel/templates/index.html` - Erweiterte Verfügbarkeitsanzeige -- `v2_adminpanel/templates/dashboard.html` - Verbesserte Resource Pool Integration -- `v2_adminpanel/templates/resources.html` - Bidirektionale Navigation - -**Status:** ✅ Alle 5 Phasen erfolgreich implementiert - -### 2025-06-10 00:15: IP-Adressen-Erfassung hinter Reverse Proxy korrigiert - -**Problem:** -- Flask-App erfasste nur die Docker-interne IP-Adresse von Nginx (172.19.0.5) -- Echte Client-IPs wurden nicht in Audit-Logs und Login-Attempts gespeichert -- Nginx setzte die Header korrekt, aber Flask las sie nicht aus - -**Ursache:** -- Flask verwendet standardmäßig nur `request.remote_addr` -- Dies gibt bei einem Reverse Proxy nur die Proxy-IP zurück -- Die Header `X-Real-IP` und `X-Forwarded-For` wurden ignoriert - -**Lösung:** -1. **ProxyFix Middleware** hinzugefügt für korrekte Header-Verarbeitung -2. **get_client_ip() Funktion** angepasst: - - Prüft zuerst `X-Real-IP` Header - - Dann `X-Forwarded-For` Header (nimmt erste IP bei mehreren) - - Fallback auf `request.remote_addr` -3. **Debug-Logging** für IP-Erfassung hinzugefügt -4. **Alle `request.remote_addr` Aufrufe** durch `get_client_ip()` ersetzt - -**Technische Details:** -```python -# ProxyFix für korrekte IP-Adressen -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) - -# Verbesserte IP-Erfassung -def get_client_ip(): - if request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - elif request.headers.get('X-Forwarded-For'): - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - else: - return request.remote_addr -``` - -**Geänderte Dateien:** -- `v2_adminpanel/app.py` - ProxyFix und verbesserte IP-Erfassung - -**Status:** ✅ Implementiert - Neue Aktionen erfassen jetzt echte Client-IPs - -### 2025-06-10 00:30: Docker ENV Legacy-Format Warnungen behoben - -**Problem:** -- Docker Build zeigte Warnungen: "LegacyKeyValueFormat: ENV key=value should be used" -- Veraltetes Format `ENV KEY VALUE` wurde in Dockerfiles verwendet - -**Lösung:** -- Alle ENV-Anweisungen auf neues Format `ENV KEY=VALUE` umgestellt -- Betraf hauptsächlich v2_postgres/Dockerfile mit 3 ENV-Zeilen - -**Geänderte Dateien:** -- `v2_postgres/Dockerfile` - ENV-Format modernisiert - -**Beispiel der Änderung:** -```dockerfile -# Alt (Legacy): -ENV LANG de_DE.UTF-8 -ENV LANGUAGE de_DE:de - -# Neu (Modern): -ENV LANG=de_DE.UTF-8 -ENV LANGUAGE=de_DE:de -``` - -**Status:** ✅ Alle Dockerfiles verwenden jetzt das moderne ENV-Format - -**Status:** ✅ Behoben \ No newline at end of file diff --git a/OPERATIONS_GUIDE.md b/OPERATIONS_GUIDE.md deleted file mode 100644 index 5c11f05..0000000 --- a/OPERATIONS_GUIDE.md +++ /dev/null @@ -1,376 +0,0 @@ -# V2-Docker Operations Guide - -## WICHTIGER HINWEIS - -**NICHT VERWENDEN (für <100 Kunden nicht benötigt):** -- ❌ Redis - System verwendet direkte DB-Verbindungen -- ❌ RabbitMQ - System verwendet synchrone Verarbeitung -- ❌ Prometheus/Grafana/Alertmanager - Integrierte Überwachung ist ausreichend -- ❌ Externe Monitoring-Tools - Admin Panel hat alle benötigten Metriken - -**NUR DIESE SERVICES VERWENDEN:** -- ✅ PostgreSQL (db) -- ✅ License Server (license-server) -- ✅ Admin Panel (admin-panel) -- ✅ Nginx Proxy (nginx-proxy) - -## Deployment - -### Prerequisites -- Docker and Docker Compose -- 4GB RAM, 20GB disk - -### Initial Setup -```bash -cd v2-Docker -docker-compose up -d -``` -Database initializes automatically via init.sql. - -### Standard-Zugangsdaten - -#### Admin Panel -- URL: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com/ -- User 1: `rac00n` / `1248163264` -- User 2: `w@rh@mm3r` / `Warhammer123!` - -#### License Server API -- URL: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com/ -- API Key: Wird im Admin Panel unter "Lizenzserver Administration" verwaltet -- Header: `X-API-Key: ` - -### Service Configuration - -#### License Server -```yaml -license-server: - build: ./v2_lizenzserver - container_name: license-server - environment: - - DATABASE_URL=postgresql://adminuser:supergeheimespasswort@db:5432/meinedatenbank - - JWT_SECRET=your-secret-jwt-key-here-minimum-32-chars - # NICHT VERWENDEN: - # - REDIS_HOST=redis # NICHT BENÖTIGT - # - RABBITMQ_HOST=rabbitmq # NICHT BENÖTIGT - expose: - - "8443" - networks: - - backend - depends_on: - - db # Nur PostgreSQL wird benötigt -``` - -#### Admin Panel -```yaml -admin-panel: - build: ./v2_adminpanel - container_name: admin-panel - environment: - - DATABASE_URL=postgresql://adminuser:supergeheimespasswort@db:5432/meinedatenbank - - SECRET_KEY=supersecretkey - - JWT_SECRET=your-secret-jwt-key-here-minimum-32-chars - # NICHT VERWENDEN: - # - REDIS_HOST=redis # NICHT BENÖTIGT - expose: - - "5000" - networks: - - backend - depends_on: - - db # Nur PostgreSQL wird benötigt - volumes: - - ./backups:/app/backups -``` - -#### Nginx Reverse Proxy -```yaml -nginx: - build: ./v2_nginx - container_name: nginx-proxy - ports: - - "80:80" - networks: - - backend - depends_on: - - admin-panel - - license-server - volumes: - - ./v2_nginx/nginx.conf:/etc/nginx/nginx.conf:ro - # Routing: - # / → admin-panel:5000 (Admin Panel) - # /api → license-server:8000 (API Endpoints) -``` - -## Monitoring - -**WICHTIG**: Externe Monitoring-Tools werden NICHT verwendet! Die folgenden Konfigurationen sind VERALTET und sollten IGNORIERT werden. - -### Integrierte Überwachung (Admin Panel) - -**HINWEIS**: Externe Monitoring-Tools (Grafana, Prometheus, etc.) werden NICHT verwendet! - -Das Admin Panel bietet alle benötigten Überwachungsfunktionen: - -1. **Dashboard** (Startseite) - - Aktive Lizenzen - - Aktive Sessions - - Heartbeat-Statistiken - - System-Metriken - -2. **Log-Seite** - - Vollständiges Audit-Log aller Aktionen - - Filterbar nach Benutzer, Aktion, Entität - - Export in Excel/CSV - -3. **Lizenz-Übersicht** - - Aktive/Inaktive Lizenzen - - Session-Status in Echtzeit - - Letzte Heartbeats - -4. **Metriken-Endpoint** - - `/metrics` im License Server für basic monitoring - - Zeigt aktuelle Anfragen, Fehler, etc. - -## Features Overview - -### Lead Management System -- **UPDATE 22.06.2025**: Jetzt direkt über Navbar "Lead Management" erreichbar -- Lead Management Dashboard unter `/leads/management` -- Gemeinsame Kontaktdatenbank zwischen rac00n und w@rh@mm3r -- Features: - - Dashboard mit Statistiken und Aktivitätsfeed - - Institution management - - Contact persons with multiple phones/emails - - Versioned notes system - - Full audit trail - - Benutzer-Attribution (wer hat was hinzugefügt) - -### Resource Pool Management -- Domain allocation system -- IPv4 address management -- Phone number allocation -- Features: - - Resource assignment to licenses - - Quarantine management - - Resource history tracking - - Availability monitoring - -### Batch Operations -- Bulk license creation -- Mass updates -- Accessible from Customers & Licenses page - -### Monitoring Integration -- Unified monitoring dashboard at `/monitoring` -- Live analytics and metrics -- Alert management interface -- Integrated with Prometheus/Grafana stack - -### API Key Management -- Single system-wide API key -- Managed in "Lizenzserver Administration" -- Used for all API authentication - -### Session Management -- Single-session enforcement per license -- 30-second heartbeat system -- Automatic session cleanup after 60 seconds -- Session history tracking - -## Maintenance - -### Database Maintenance - -#### Partition Management -```sql --- Check existing partitions -SELECT tablename FROM pg_tables -WHERE tablename LIKE 'license_heartbeats_%' -ORDER BY tablename; - --- Create future partitions manually -CALL create_monthly_partitions('license_heartbeats', 3); - --- Drop old partitions -DROP TABLE IF EXISTS license_heartbeats_2024_01; -``` - -#### Backup Procedures -```bash -# Backup -docker exec db pg_dump -U adminuser meinedatenbank | gzip > backup_$(date +%Y%m%d).sql.gz - -# Restore -gunzip -c backup_20250619.sql.gz | docker exec -i db psql -U adminuser meinedatenbank -``` - -##### Integriertes Backup-System -Das Admin Panel bietet ein eingebautes Backup-System: -1. Login ins Admin Panel -2. Navigiere zu "Backups" -3. Klicke "Create Backup" -4. Backups werden verschlüsselt im Verzeichnis `/backups` gespeichert -5. Download oder Restore direkt über die UI - -### Log Management - -#### Log Locations - -##### Logs -- Container logs: `docker logs ` -- Nginx logs: `./v2_nginx/logs/` -- Audit logs: Database table `audit_log` - -#### Log Rotation -```bash -# Configure logrotate -/var/log/license-server/*.log { - daily - rotate 7 - compress - delaycompress - notifempty - create 0640 www-data www-data -} -``` - -### Performance Optimization - -#### Database Tuning -- Run `ANALYZE` periodically -- `VACUUM ANALYZE` on large tables -- Maintain partitions: `CALL create_monthly_partitions('license_heartbeats', 3)` - -#### Resource Limits - -Alle Services haben konfigurierte Resource Limits: - -```yaml -# License Server -license-server: - deploy: - resources: - limits: - cpus: '1.0' - memory: 1G - reservations: - cpus: '0.5' - memory: 512M - -# Admin Panel -admin-panel: - deploy: - resources: - limits: - cpus: '1.0' - memory: 1G - reservations: - cpus: '0.5' - memory: 512M - -# PostgreSQL -db: - deploy: - resources: - limits: - cpus: '2.0' - memory: 2G - reservations: - cpus: '1.0' - memory: 1G -``` - -## Troubleshooting - -### Common Issues - -#### License Server Not Responding -- Check status: `docker ps | grep license` -- View logs: `docker logs license-server --tail 100` -- Test health: `docker exec nginx-proxy curl http://license-server:8443/health` - -#### Database Connection Issues -- Check status: `docker exec db pg_isready` -- Test connection: Use psql from admin panel container -- Check logs: `docker logs db --tail 50` - -#### High Memory Usage -1. Check container stats: `docker stats` -2. Review memory limits in docker-compose.yml -3. Analyze database queries for optimization -4. Consider scaling horizontally - -### Health Checks - -Quick health check script: -```bash -# All services -docker ps --format "table {{.Names}}\t{{.Status}}" - -# Key endpoints -curl -s https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com/health -curl -s http://localhost:9090/-/healthy -``` - -## Security Considerations -- Strong JWT_SECRET (32+ chars) -- Rotate API keys regularly -- Rate limiting enabled -- Use HTTPS in production -- Strong database passwords -- Keep Docker and images updated - -## Scaling Strategies - -### Horizontal Scaling - -#### Scaling License Server -```bash -# Scale license server instances -docker-compose -f v2/docker-compose.yaml up -d --scale license-server=3 -``` - -#### Nginx Load Balancing Configuration -```nginx -# In nginx.conf -upstream license_servers { - least_conn; - server license-server_1:8443 max_fails=3 fail_timeout=30s; - server license-server_2:8443 max_fails=3 fail_timeout=30s; - server license-server_3:8443 max_fails=3 fail_timeout=30s; - - # Health checks - keepalive 32; -} - -server { - server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com; - - location / { - proxy_pass http://license_servers; - proxy_http_version 1.1; - proxy_set_header Connection ""; - } -} -``` - -#### Scaling Considerations (für >100 Kunden) -**HINWEIS**: Für <100 Kunden ist keine Skalierung notwendig! -- Direkter DB-Zugriff ist ausreichend (kein Redis benötigt) -- Synchrone Verarbeitung ist schnell genug (kein RabbitMQ benötigt) -- Single Instance ist völlig ausreichend - -### Database Scaling -- Read replicas for reporting -- Connection pooling -- Query optimization -- Partitioning for large tables - -## Disaster Recovery -- Daily automated backups via Admin Panel -- Test restore procedures regularly -- Consider database replication for HA - -## Monitoring Best Practices -- Configure alerts in Alertmanager -- Review Grafana dashboards regularly -- Monitor resource trends for capacity planning \ No newline at end of file diff --git a/PRODUCTION_DEPLOYMENT.md b/PRODUCTION_DEPLOYMENT.md deleted file mode 100644 index 00eeb15..0000000 --- a/PRODUCTION_DEPLOYMENT.md +++ /dev/null @@ -1,121 +0,0 @@ -# Production Deployment Guide for intelsight.de - -## Pre-Deployment Checklist - -### 1. Generate Secure Secrets -```bash -python3 generate-secrets.py -``` -Save the output securely - you'll need these passwords! - -**Note**: The admin panel users (rac00n and w@rh@mm3r) keep their existing passwords as configured in the .env file. - -### 2. Configure Environment Files - -#### v2/.env -1. Copy the template: - ```bash - cp v2/.env.production.template v2/.env - ``` -2. Replace all `CHANGE_THIS_` placeholders with generated secrets -3. Ensure `PRODUCTION=true` is set - -#### v2_lizenzserver/.env -1. Copy the template: - ```bash - cp v2_lizenzserver/.env.production.template v2_lizenzserver/.env - ``` -2. Use the same database password as in v2/.env -3. Set a unique SECRET_KEY from generate-secrets.py - -### 3. SSL Certificates -```bash -# Copy your SSL certificates -cp /SSL/fullchain.pem v2_nginx/ssl/ -cp /SSL/privkey.pem v2_nginx/ssl/ -chmod 644 v2_nginx/ssl/fullchain.pem -chmod 600 v2_nginx/ssl/privkey.pem - -# Generate dhparam.pem (this takes a few minutes) -openssl dhparam -out v2_nginx/ssl/dhparam.pem 2048 -``` - -### 4. Verify Configuration -```bash -./verify-deployment.sh -``` - -## Deployment on Hetzner Server - -### 1. Update Deploy Script -On your Hetzner server: -```bash -nano /root/deploy.sh -``` -Replace `YOUR_GITHUB_TOKEN` with your actual GitHub token. - -### 2. Run Deployment -```bash -cd /root -./deploy.sh -``` - -### 3. Start Services -```bash -cd /opt/v2-Docker/v2 -docker compose up -d -``` - -### 4. Check Status -```bash -docker compose ps -docker compose logs -f -``` - -## Post-Deployment - -### 1. Create Admin Panel API Key -1. Access https://admin-panel-undso.intelsight.de -2. Login with your admin credentials -3. Go to "Lizenzserver Administration" -4. Generate a new API key for production use - -### 2. Test Endpoints -- Admin Panel: https://admin-panel-undso.intelsight.de -- API Server: https://api-software-undso.intelsight.de - -### 3. Monitor Logs -```bash -docker compose logs -f admin-panel -docker compose logs -f license-server -``` - -## Security Notes - -1. **Never commit .env files** with real passwords to git -2. **Backup your passwords** securely -3. **Rotate API keys** regularly -4. **Monitor access logs** for suspicious activity -5. **Keep SSL certificates** up to date (expires every 90 days) - -## Troubleshooting - -### Services won't start -```bash -docker compose down -docker compose up -d -docker compose logs -``` - -### Database connection issues -- Verify POSTGRES_PASSWORD matches in both .env files -- Check if postgres container is running: `docker compose ps db` - -### SSL issues -- Ensure certificates are in v2_nginx/ssl/ -- Check nginx logs: `docker compose logs nginx-proxy` - -### Cannot access website -- Verify DNS points to your server IP -- Check if ports 80/443 are open: `ss -tlnp | grep -E '(:80|:443)'` -- Check nginx is running: `docker compose ps nginx-proxy` \ No newline at end of file diff --git a/SSL/.claude/settings.local.json b/SSL/.claude/settings.local.json deleted file mode 100644 index 5affa95..0000000 --- a/SSL/.claude/settings.local.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(sudo apt:*)", - "Bash(sudo apt install:*)", - "Bash(apt list:*)", - "Bash(pip install:*)", - "Bash(pip3 install:*)", - "Bash(chmod:*)", - "Bash(sudo cp:*)" - ], - "deny": [] - } -} \ No newline at end of file diff --git a/SSL/SSL_Wichtig.md b/SSL/SSL_Wichtig.md deleted file mode 100644 index 4b13fe5..0000000 --- a/SSL/SSL_Wichtig.md +++ /dev/null @@ -1,130 +0,0 @@ -# SSL Zertifikat für intelsight.de - Wichtige Informationen - -## Erfolgreich erstelltes Zertifikat - -**Erstellungsdatum**: 26. Juni 2025 -**Ablaufdatum**: 24. September 2025 (90 Tage) -**E-Mail für Benachrichtigungen**: momohomma@googlemail.com - -**Abgedeckte Domains**: -- intelsight.de -- www.intelsight.de -- admin-panel-undso.intelsight.de -- api-software-undso.intelsight.de - -## Zertifikatsdateien (in WSL) - -- **Zertifikat (Full Chain)**: `/etc/letsencrypt/live/intelsight.de/fullchain.pem` -- **Privater Schlüssel**: `/etc/letsencrypt/live/intelsight.de/privkey.pem` -- **Nur Zertifikat**: `/etc/letsencrypt/live/intelsight.de/cert.pem` -- **Zwischenzertifikat**: `/etc/letsencrypt/live/intelsight.de/chain.pem` - -## Komplette Anleitung - So wurde es gemacht - -### 1. WSL Installation und Setup -```bash -# In Windows PowerShell WSL starten -wsl - -# System aktualisieren -sudo apt update - -# Certbot installieren -sudo apt install certbot - -# Version prüfen -certbot --version -# Ausgabe: certbot 2.9.0 -``` - -### 2. Certbot DNS Challenge starten -```bash -sudo certbot certonly --manual --preferred-challenges dns --email momohomma@googlemail.com --agree-tos --no-eff-email -d intelsight.de -d www.intelsight.de -d admin-panel-undso.intelsight.de -d api-software-undso.intelsight.de -``` - -### 3. DNS Challenge Werte sammeln -Certbot zeigt nacheinander 4 DNS Challenges an. **Nach jedem Wert Enter drücken** um den nächsten zu sehen: - -1. Enter → Erster Wert erscheint -2. Enter → Zweiter Wert erscheint -3. Enter → Dritter Wert erscheint -4. Enter → Vierter Wert erscheint -5. **STOPP! Noch nicht Enter drücken!** - -### 4. DNS Einträge bei IONOS hinzufügen - -Bei IONOS anmelden und unter DNS-Einstellungen diese TXT-Einträge hinzufügen: - -| Typ | Hostname | Wert | TTL | -|-----|----------|------|-----| -| TXT | `_acme-challenge.admin-panel-undso` | [Wert von Certbot] | 5 Min | -| TXT | `_acme-challenge.api-software-undso` | [Wert von Certbot] | 5 Min | -| TXT | `_acme-challenge` | [Wert von Certbot] | 5 Min | -| TXT | `_acme-challenge.www` | [Wert von Certbot] | 5 Min | - -### 5. DNS Einträge verifizieren - -**In einem neuen WSL Terminal** prüfen ob die Einträge aktiv sind: - -```bash -nslookup -type=TXT _acme-challenge.admin-panel-undso.intelsight.de -nslookup -type=TXT _acme-challenge.api-software-undso.intelsight.de -nslookup -type=TXT _acme-challenge.intelsight.de -nslookup -type=TXT _acme-challenge.www.intelsight.de -``` - -Wenn alle 4 Einträge die richtigen Werte zeigen, fortfahren. - -### 6. Zertifikat generieren -Im Certbot Terminal (wo es wartet) **Enter drücken** zur Verifizierung. - -Erfolgreiche Ausgabe: -``` -Successfully received certificate. -Certificate is saved at: /etc/letsencrypt/live/intelsight.de/fullchain.pem -Key is saved at: /etc/letsencrypt/live/intelsight.de/privkey.pem -This certificate expires on 2025-09-24. -``` - -## Zertifikate für späteren Server-Upload kopieren - -```bash -# Zertifikate ins Home-Verzeichnis kopieren -sudo cp /etc/letsencrypt/live/intelsight.de/fullchain.pem ~/ -sudo cp /etc/letsencrypt/live/intelsight.de/privkey.pem ~/ - -# Berechtigungen setzen -sudo chmod 644 ~/*.pem - -# Dateien anzeigen -ls -la ~/*.pem -``` - -Die Dateien sind dann unter: -- Windows Pfad: `\\wsl$\Ubuntu\home\[dein-username]\fullchain.pem` -- Windows Pfad: `\\wsl$\Ubuntu\home\[dein-username]\privkey.pem` - -## Wichtige Hinweise - -1. **Erneuerung**: Das Zertifikat muss alle 90 Tage erneuert werden -2. **Manuelle Erneuerung**: Gleicher Prozess mit DNS Challenge wiederholen -3. **Automatische Erneuerung**: Erst möglich wenn Server läuft -4. **DNS Einträge**: Nach erfolgreicher Zertifikatserstellung können die `_acme-challenge` TXT-Einträge bei IONOS gelöscht werden - -## Für den zukünftigen Server - -Wenn der Server bereit ist, diese Dateien verwenden: -- `fullchain.pem` - Komplette Zertifikatskette -- `privkey.pem` - Privater Schlüssel (GEHEIM HALTEN!) - -### Beispiel Nginx Konfiguration: -```nginx -ssl_certificate /etc/ssl/certs/fullchain.pem; -ssl_certificate_key /etc/ssl/private/privkey.pem; -``` - -### Beispiel Apache Konfiguration: -```apache -SSLCertificateFile /etc/ssl/certs/fullchain.pem -SSLCertificateKeyFile /etc/ssl/private/privkey.pem -``` \ No newline at end of file diff --git a/SSL/cert.pem b/SSL/cert.pem deleted file mode 100644 index bfb08d2..0000000 --- a/SSL/cert.pem +++ /dev/null @@ -1,23 +0,0 @@ ------BEGIN CERTIFICATE----- -MIID3TCCA2OgAwIBAgISBimcX2wwj3Z1U/Qlfu5y5keoMAoGCCqGSM49BAMDMDIx -CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF -NjAeFw0yNTA2MjYxNjAwMjBaFw0yNTA5MjQxNjAwMTlaMBgxFjAUBgNVBAMTDWlu -dGVsc2lnaHQuZGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATEQD6vfDoXM7Yz -iT75OmB/kvxoEebMFRBCzpTOdUZpThlFmLijjCsYnxc8DeWDn8/eLltrBWhuM4Yx -gX8tseO0o4ICcTCCAm0wDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUF -BwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBSM5CYyn//CSmLp -JADwjccRtsnZFDAfBgNVHSMEGDAWgBSTJ0aYA6lRaI6Y1sRCSNsjv1iU0jAyBggr -BgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly9lNi5pLmxlbmNyLm9yZy8w -bgYDVR0RBGcwZYIfYWRtaW4tcGFuZWwtdW5kc28uaW50ZWxzaWdodC5kZYIgYXBp -LXNvZnR3YXJlLXVuZHNvLmludGVsc2lnaHQuZGWCDWludGVsc2lnaHQuZGWCEXd3 -dy5pbnRlbHNpZ2h0LmRlMBMGA1UdIAQMMAowCAYGZ4EMAQIBMC0GA1UdHwQmMCQw -IqAgoB6GHGh0dHA6Ly9lNi5jLmxlbmNyLm9yZy80MS5jcmwwggEEBgorBgEEAdZ5 -AgQCBIH1BIHyAPAAdgDM+w9qhXEJZf6Vm1PO6bJ8IumFXA2XjbapflTA/kwNsAAA -AZetLYOmAAAEAwBHMEUCIB8bQYn7h64sSmHZavNbIM6ScHDBxmMWN6WqjyaTz75I -AiEArz5mC+TaVMsofIIFkEj+dOMD1/oj6w10zgVunTPb01wAdgCkQsUGSWBhVI8P -1Oqc+3otJkVNh6l/L99FWfYnTzqEVAAAAZetLYRWAAAEAwBHMEUCIFVulS2bEmSQ -HYcE2UbsHhn7WJl8MeWZJSKGG1LbtnvyAiEAsLHL/VyIfXVhOmcMf1gmPL/eu7xj -W/2JuPHVWgjUDhQwCgYIKoZIzj0EAwMDaAAwZQIxANaSy/SOYXq9+oQJNhpXIlMJ -i0HBvwebvhNVkNGJN2QodV5gE2yi4s4q19XkpFO+fQIwCCqLSQvaC+AcOTFT9XL5 -6hk8bFapLf/b2EFv3DE06qKIrDVPWhtYwyEYBRT4Ii4p ------END CERTIFICATE----- diff --git a/SSL/chain.pem b/SSL/chain.pem deleted file mode 100644 index 65797c8..0000000 --- a/SSL/chain.pem +++ /dev/null @@ -1,26 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEVzCCAj+gAwIBAgIRALBXPpFzlydw27SHyzpFKzgwDQYJKoZIhvcNAQELBQAw -TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh -cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw -WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg -RW5jcnlwdDELMAkGA1UEAxMCRTYwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATZ8Z5G -h/ghcWCoJuuj+rnq2h25EqfUJtlRFLFhfHWWvyILOR/VvtEKRqotPEoJhC6+QJVV -6RlAN2Z17TJOdwRJ+HB7wxjnzvdxEP6sdNgA1O1tHHMWMxCcOrLqbGL0vbijgfgw -gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD -ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSTJ0aYA6lRaI6Y1sRCSNsj -v1iU0jAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB -AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g -BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu -Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAfYt7SiA1sgWGCIpunk46r4AExIRc -MxkKgUhNlrrv1B21hOaXN/5miE+LOTbrcmU/M9yvC6MVY730GNFoL8IhJ8j8vrOL -pMY22OP6baS1k9YMrtDTlwJHoGby04ThTUeBDksS9RiuHvicZqBedQdIF65pZuhp -eDcGBcLiYasQr/EO5gxxtLyTmgsHSOVSBcFOn9lgv7LECPq9i7mfH3mpxgrRKSxH -pOoZ0KXMcB+hHuvlklHntvcI0mMMQ0mhYj6qtMFStkF1RpCG3IPdIwpVCQqu8GV7 -s8ubknRzs+3C/Bm19RFOoiPpDkwvyNfvmQ14XkyqqKK5oZ8zhD32kFRQkxa8uZSu -h4aTImFxknu39waBxIRXE4jKxlAmQc4QjFZoq1KmQqQg0J/1JF8RlFvJas1VcjLv -YlvUB2t6npO6oQjB3l+PNf0DpQH7iUx3Wz5AjQCi6L25FjyE06q6BZ/QlmtYdl/8 -ZYao4SRqPEs/6cAiF+Qf5zg2UkaWtDphl1LKMuTNLotvsX99HP69V2faNyegodQ0 -LyTApr/vT01YPE46vNsDLgK+4cL6TrzC/a4WcmF5SRJ938zrv/duJHLXQIku5v0+ -EwOy59Hdm0PT/Er/84dDV0CSjdR/2XuZM3kpysSKLgD1cKiDA+IRguODCxfO9cyY -Ig46v9mFmBvyH04= ------END CERTIFICATE----- diff --git a/SSL/fullchain.pem b/SSL/fullchain.pem deleted file mode 100644 index 0317cae..0000000 --- a/SSL/fullchain.pem +++ /dev/null @@ -1,49 +0,0 @@ ------BEGIN CERTIFICATE----- -MIID3TCCA2OgAwIBAgISBimcX2wwj3Z1U/Qlfu5y5keoMAoGCCqGSM49BAMDMDIx -CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF -NjAeFw0yNTA2MjYxNjAwMjBaFw0yNTA5MjQxNjAwMTlaMBgxFjAUBgNVBAMTDWlu -dGVsc2lnaHQuZGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATEQD6vfDoXM7Yz -iT75OmB/kvxoEebMFRBCzpTOdUZpThlFmLijjCsYnxc8DeWDn8/eLltrBWhuM4Yx -gX8tseO0o4ICcTCCAm0wDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUF -BwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBSM5CYyn//CSmLp -JADwjccRtsnZFDAfBgNVHSMEGDAWgBSTJ0aYA6lRaI6Y1sRCSNsjv1iU0jAyBggr -BgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly9lNi5pLmxlbmNyLm9yZy8w -bgYDVR0RBGcwZYIfYWRtaW4tcGFuZWwtdW5kc28uaW50ZWxzaWdodC5kZYIgYXBp -LXNvZnR3YXJlLXVuZHNvLmludGVsc2lnaHQuZGWCDWludGVsc2lnaHQuZGWCEXd3 -dy5pbnRlbHNpZ2h0LmRlMBMGA1UdIAQMMAowCAYGZ4EMAQIBMC0GA1UdHwQmMCQw -IqAgoB6GHGh0dHA6Ly9lNi5jLmxlbmNyLm9yZy80MS5jcmwwggEEBgorBgEEAdZ5 -AgQCBIH1BIHyAPAAdgDM+w9qhXEJZf6Vm1PO6bJ8IumFXA2XjbapflTA/kwNsAAA -AZetLYOmAAAEAwBHMEUCIB8bQYn7h64sSmHZavNbIM6ScHDBxmMWN6WqjyaTz75I -AiEArz5mC+TaVMsofIIFkEj+dOMD1/oj6w10zgVunTPb01wAdgCkQsUGSWBhVI8P -1Oqc+3otJkVNh6l/L99FWfYnTzqEVAAAAZetLYRWAAAEAwBHMEUCIFVulS2bEmSQ -HYcE2UbsHhn7WJl8MeWZJSKGG1LbtnvyAiEAsLHL/VyIfXVhOmcMf1gmPL/eu7xj -W/2JuPHVWgjUDhQwCgYIKoZIzj0EAwMDaAAwZQIxANaSy/SOYXq9+oQJNhpXIlMJ -i0HBvwebvhNVkNGJN2QodV5gE2yi4s4q19XkpFO+fQIwCCqLSQvaC+AcOTFT9XL5 -6hk8bFapLf/b2EFv3DE06qKIrDVPWhtYwyEYBRT4Ii4p ------END CERTIFICATE----- ------BEGIN CERTIFICATE----- -MIIEVzCCAj+gAwIBAgIRALBXPpFzlydw27SHyzpFKzgwDQYJKoZIhvcNAQELBQAw -TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh -cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw -WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg -RW5jcnlwdDELMAkGA1UEAxMCRTYwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATZ8Z5G -h/ghcWCoJuuj+rnq2h25EqfUJtlRFLFhfHWWvyILOR/VvtEKRqotPEoJhC6+QJVV -6RlAN2Z17TJOdwRJ+HB7wxjnzvdxEP6sdNgA1O1tHHMWMxCcOrLqbGL0vbijgfgw -gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD -ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSTJ0aYA6lRaI6Y1sRCSNsj -v1iU0jAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB -AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g -BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu -Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAfYt7SiA1sgWGCIpunk46r4AExIRc -MxkKgUhNlrrv1B21hOaXN/5miE+LOTbrcmU/M9yvC6MVY730GNFoL8IhJ8j8vrOL -pMY22OP6baS1k9YMrtDTlwJHoGby04ThTUeBDksS9RiuHvicZqBedQdIF65pZuhp -eDcGBcLiYasQr/EO5gxxtLyTmgsHSOVSBcFOn9lgv7LECPq9i7mfH3mpxgrRKSxH -pOoZ0KXMcB+hHuvlklHntvcI0mMMQ0mhYj6qtMFStkF1RpCG3IPdIwpVCQqu8GV7 -s8ubknRzs+3C/Bm19RFOoiPpDkwvyNfvmQ14XkyqqKK5oZ8zhD32kFRQkxa8uZSu -h4aTImFxknu39waBxIRXE4jKxlAmQc4QjFZoq1KmQqQg0J/1JF8RlFvJas1VcjLv -YlvUB2t6npO6oQjB3l+PNf0DpQH7iUx3Wz5AjQCi6L25FjyE06q6BZ/QlmtYdl/8 -ZYao4SRqPEs/6cAiF+Qf5zg2UkaWtDphl1LKMuTNLotvsX99HP69V2faNyegodQ0 -LyTApr/vT01YPE46vNsDLgK+4cL6TrzC/a4WcmF5SRJ938zrv/duJHLXQIku5v0+ -EwOy59Hdm0PT/Er/84dDV0CSjdR/2XuZM3kpysSKLgD1cKiDA+IRguODCxfO9cyY -Ig46v9mFmBvyH04= ------END CERTIFICATE----- diff --git a/SSL/privkey.pem b/SSL/privkey.pem deleted file mode 100644 index a31c25b..0000000 --- a/SSL/privkey.pem +++ /dev/null @@ -1,5 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgi8/a6iwFCHSbBe/I -2Zo6exFpcLL4icRgotOF605ZrY6hRANCAATEQD6vfDoXM7YziT75OmB/kvxoEebM -FRBCzpTOdUZpThlFmLijjCsYnxc8DeWDn8/eLltrBWhuM4YxgX8tseO0 ------END PRIVATE KEY----- diff --git a/SYSTEM_DOCUMENTATION.md b/SYSTEM_DOCUMENTATION.md deleted file mode 100644 index d402da7..0000000 --- a/SYSTEM_DOCUMENTATION.md +++ /dev/null @@ -1,263 +0,0 @@ -# V2-Docker System Documentation - -## WICHTIGER HINWEIS FÜR ZUKÜNFTIGE ENTWICKLUNG - -**DIESE SERVICES WERDEN NICHT VERWENDET:** -- ❌ Redis - NICHT BENÖTIGT für <100 Kunden -- ❌ RabbitMQ - NICHT BENÖTIGT für <100 Kunden -- ❌ Prometheus - NICHT BENÖTIGT -- ❌ Grafana - NICHT BENÖTIGT -- ❌ Alertmanager - NICHT BENÖTIGT -- ❌ Externe Monitoring-Tools - NICHT BENÖTIGT - -**Das System verwendet NUR:** -- ✅ PostgreSQL für alle Datenspeicherung -- ✅ Integrierte Überwachung im Admin Panel -- ✅ Direkte Datenbankverbindungen ohne Cache -- ✅ Synchrone Verarbeitung ohne Message Queue - -## Overview - -V2-Docker is a streamlined system featuring a License Server, Admin Panel, and Lead Management with integrated monitoring. This document consolidates all architecture and implementation details. - -## License Server Architecture - -### Core Principles -- Designed to avoid refactoring -- Microservices architecture -- Hardware-based license binding -- Offline grace period support (7 days) -- Version control with update enforcement - -### Core Functionalities - -#### 1. License Validation -- Real-time license verification -- Hardware binding (MAC address, CPU ID, system UUID) -- Version compatibility checks -- Usage limit enforcement - -#### 2. Activation Management -- Initial activation with hardware fingerprint -- Multi-activation support -- Deactivation capabilities -- Transfer between systems - -#### 3. Usage Monitoring -- Active user tracking -- Feature usage statistics -- Heartbeat monitoring (15-minute intervals) -- Historical data analysis - -### Microservices Architecture - -#### Aktive Services -1. **License Server** (`v2_lizenzserver`) - Core license validation - - Vollständig implementiert - - API-Endpunkte für Aktivierung, Verifizierung, Info - - Läuft auf internem Port über Nginx - -2. **Admin Panel** (`v2_adminpanel`) - Web-basierte Verwaltung - - Vollständig implementiert auf Port 80 - - Customer, License, Resource Management - - Integrierte Backup-Funktionalität - - Lead Management System - -#### Infrastructure Services -- **PostgreSQL** - Main database -- **Redis** - Caching -- **RabbitMQ** - Message queue -- **Nginx** - Reverse proxy - -*Note: Analytics, Admin API, and Auth services exist in code but are currently inactive.* - -#### Communication -- REST APIs für externe Kommunikation -- Redis für Caching -- RabbitMQ für asynchrone Verarbeitung (vorbereitet) - -### Database Schema -See `v2_adminpanel/init.sql` for complete schema. -Key feature: Monthly partitioned `license_heartbeats` table. - -### Security Concepts -- JWT-based authentication -- API key management -- Rate limiting (100 requests/minute) -- Hardware fingerprint validation -- Encrypted communication - -### Implementation Status (June 22, 2025) - -#### Completed -- ✅ License Server mit vollständigen API-Endpunkten - - POST /api/license/activate - - POST /api/license/verify - - GET /api/license/info/{license_key} - - POST /api/license/session/start - Session-Initialisierung - - POST /api/license/session/heartbeat - Keep-alive - - POST /api/license/session/end - Session-Beendigung - - POST /api/version/check - - GET /api/version/latest -- ✅ Admin Panel mit voller Funktionalität - - Customer Management mit erweiterten Features - - License Management mit Resource Allocation - - Resource Pool Management (Domains, IPs, Telefonnummern) - - Session Management mit Live-Monitor - - Lead Management System (vollständiges CRM) - - Batch Operations für Bulk-Aktionen - - Export/Import Funktionalität - - Device Registration und Management - - API Key Management (System-wide) -- ✅ Monitoring Stack (Prometheus, Grafana, Alertmanager) - - Integriertes Monitoring Dashboard - - Vorkonfigurierte Dashboards - - Alert Rules für kritische Metriken -- ✅ Docker Services Konfiguration -- ✅ JWT/API Key Management -- ✅ Backup-System (integriert im Admin Panel) -- ✅ 2FA-Authentifizierung -- ✅ Audit Logging mit Request IDs -- ✅ Rate Limiting (konfigurierbar) -- ✅ Single-Session Enforcement (Account Forger) -- ✅ Partitionierte Datenbank für Heartbeats - -#### Code vorhanden aber nicht aktiviert -- ⏸️ Analytics Service (auskommentiert) -- ⏸️ Admin API Service (auskommentiert) -- ⏸️ Auth Service (auskommentiert) - -#### Geplant -- 📋 Notification Service -- 📋 Erweiterte Analytics -- 📋 Machine Learning Integration - -## Lead Management System - -### Status -**Vollständig implementiert** als Teil des Admin Panels unter `/leads/` - -### Update June 22, 2025 - 20:26 -- **Neuer Navbar-Eintrag**: "Lead Management" über "Ressourcen Pool" -- **Lead Management Dashboard** unter `/leads/management` mit: - - Übersicht Statistiken (Institutionen, Kontakte, Benutzer-Attribution) - - Aktivitätsfeed zeigt wer was hinzugefügt/bearbeitet hat - - Schnellaktionen (Institution hinzufügen, alle anzeigen, exportieren) - - Geteilte Informationsansicht zwischen rac00n und w@rh@mm3r - -### Architecture -- **Modular Architecture**: Clean separation of concerns -- **Service Layer Pattern**: Business logic in `leads/services.py` -- **Repository Pattern**: Data access in `leads/repositories.py` -- **Blueprint Integration**: Routes in `leads/routes.py` - -### Data Model (implementiert) -``` -lead_institutions -├── lead_contacts (1:n) -│ └── lead_contact_details (1:n) - Telefon/E-Mail -└── lead_notes (1:n) - Versionierte Notizen -``` - -### Implementierte Features -1. ✅ Institution Management (CRUD) -2. ✅ Contact Person Management mit mehreren Telefon/E-Mail -3. ✅ Notes mit vollständiger Versionierung -4. ✅ Flexible Kontaktdetails (beliebig viele pro Person) -5. ✅ Audit Trail Integration -6. ✅ Service/Repository Pattern für Clean Code -7. ✅ JSONB Felder für zukünftige Erweiterungen - -### API Endpoints -- GET /leads/ - Institutionen-Übersicht -- GET /leads/institutions - Institutionen-Liste -- POST /leads/institutions - Neue Institution -- GET /leads/institutions/{id} - Institution Details -- PUT /leads/institutions/{id} - Institution bearbeiten -- DELETE /leads/institutions/{id} - Institution löschen -- GET /leads/contacts/{id} - Kontakt Details -- POST /leads/contacts/{id}/details - Kontaktdetail hinzufügen -- PUT /leads/contacts/{id}/details/{detail_id} - Detail bearbeiten -- POST /leads/contacts/{id}/notes - Notiz hinzufügen - -## Admin Panel - -### Implementierte Features -1. **Authentication & Security** - - ✅ Login mit 2FA-Unterstützung - - ✅ Session Management - - ✅ Rate Limiting - - ✅ IP-Blocking bei fehlgeschlagenen Logins - - ✅ Audit Logging aller Aktionen - -2. **Customer Management** - - ✅ CRUD-Operationen für Kunden - - ✅ Kundensuche mit Autocomplete - - ✅ Kunden-Lizenz-Übersicht - - ✅ Quick Stats pro Kunde - -3. **License Management** - - ✅ Lizenzerstellung (Einzel und Batch) - - ✅ Lizenzbearbeitung und -löschung - - ✅ Bulk-Operationen (Aktivieren/Deaktivieren) - - ✅ Device Management mit Hardware IDs - - ✅ Resource Allocation (Domains, IPs, Telefonnummern) - - ✅ Quick Edit Funktionalität - - ✅ Session Management und Monitoring - - ✅ Lizenz-Konfiguration für Account Forger - -4. **Monitoring & Analytics** - - ✅ Dashboard mit Live-Statistiken - - ✅ Lizenzserver-Monitoring - - ✅ Session-Überwachung mit Live-Updates - - ✅ Resource Pool Monitoring - - ✅ Integriertes Monitoring Dashboard (/monitoring) - - ✅ Prometheus/Grafana Integration - - ✅ Alert Management - -5. **System Administration** - - ✅ Backup & Restore (manuell und geplant) - - ✅ Export-Funktionen (CSV, JSON) - - ✅ Audit Log Viewer mit Filterung - - ✅ Blocked IPs Management - - ✅ Feature Flags Konfiguration - - ✅ API Key Generation und Management - - ✅ Lizenzserver Administration - - ✅ Session-Terminierung durch Admins - -### Technical Stack -- Backend: Flask 3.0.3, PostgreSQL -- Frontend: Bootstrap 5.3, jQuery -- Security: bcrypt, pyotp (2FA), JWT - -## Deployment Configuration - -### Docker Services - -#### Aktive Services -- `db`: PostgreSQL database (Port 5432) -- `admin-panel`: Admin interface (interner Port 5000) -- `nginx-proxy`: Reverse proxy (Ports 80, 443) -- `license-server`: License server (interner Port 8443) - -#### NICHT VERWENDETE Services (DO NOT USE) -- ❌ `redis`: Redis cache - NICHT BENÖTIGT für <100 Kunden -- ❌ `rabbitmq`: Message queue - NICHT BENÖTIGT für <100 Kunden -- ❌ External monitoring (Prometheus, Grafana, Alertmanager) - NICHT BENÖTIGT -- ❌ `monitoring/docker-compose.monitoring.yml` - NICHT VERWENDEN - -**WICHTIG**: Das System verwendet KEINE externen Monitoring-Tools, Redis oder RabbitMQ. Die eingebaute Überwachung im Admin Panel ist ausreichend für <100 Kunden. - -### Environment Configuration -Required: DATABASE_URL, SECRET_KEY, JWT_SECRET -NOT Required: REDIS_HOST, RABBITMQ_HOST (diese NICHT konfigurieren) -See docker-compose.yaml for all environment variables. - - -## Current Status -System is production-ready with all core features implemented: -- ✅ License management with session enforcement -- ✅ Lead management CRM -- ✅ Resource pool management -- ✅ Integrierte Überwachung (Admin Panel) -- ✅ Backup and audit systems \ No newline at end of file diff --git a/backup_before_cleanup.sh b/backup_before_cleanup.sh deleted file mode 100644 index f6fce93..0000000 --- a/backup_before_cleanup.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/bash -# Backup-Skript vor dem Cleanup der auskommentierten Routes -# Erstellt ein vollständiges Backup des aktuellen Zustands - -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -BACKUP_DIR="./backups/refactoring_${TIMESTAMP}" - -echo "🔒 Erstelle Backup vor Refactoring-Cleanup..." -echo " Timestamp: ${TIMESTAMP}" - -# Backup-Verzeichnis erstellen -mkdir -p "${BACKUP_DIR}" - -# 1. Code-Backup -echo "📁 Sichere Code..." -cp -r v2_adminpanel "${BACKUP_DIR}/v2_adminpanel_backup" - -# Speziell app.py sichern -cp v2_adminpanel/app.py "${BACKUP_DIR}/app.py.backup_${TIMESTAMP}" - -# 2. Git-Status dokumentieren -echo "📝 Dokumentiere Git-Status..." -git status > "${BACKUP_DIR}/git_status.txt" -git log --oneline -10 > "${BACKUP_DIR}/git_log.txt" -git diff > "${BACKUP_DIR}/git_diff.txt" - -# 3. Blueprint-Übersicht erstellen -echo "📊 Erstelle Blueprint-Übersicht..." -cat > "${BACKUP_DIR}/blueprint_overview.txt" << EOF -Blueprint Migration Status - ${TIMESTAMP} -========================================== - -Blueprints erstellt und registriert: -- auth_bp (9 routes) - Authentication -- admin_bp (10 routes) - Admin Dashboard -- license_bp (4 routes) - License Management -- customer_bp (7 routes) - Customer Management -- resource_bp (7 routes) - Resource Pool -- session_bp (6 routes) - Session Management -- batch_bp (4 routes) - Batch Operations -- api_bp (14 routes) - API Endpoints -- export_bp (5 routes) - Export Functions - -Gesamt: 66 Routes in Blueprints - -Status: -- Alle Routes aus app.py sind auskommentiert -- Blueprints sind aktiv und funktionsfähig -- Keine aktiven @app.route mehr in app.py - -Nächste Schritte: -1. Auskommentierte Routes entfernen -2. Redundante Funktionen bereinigen -3. URL-Präfixe implementieren -EOF - -# 4. Route-Mapping erstellen -echo "🗺️ Erstelle Route-Mapping..." -grep -n "# @app.route" v2_adminpanel/app.py > "${BACKUP_DIR}/commented_routes.txt" - -# 5. Zusammenfassung -echo "" -echo "✅ Backup erstellt in: ${BACKUP_DIR}" -echo "" -echo "Inhalt:" -ls -la "${BACKUP_DIR}/" -echo "" -echo "🎯 Nächster Schritt: Auskommentierte Routes können jetzt sicher entfernt werden" -echo " Rollback möglich mit: cp ${BACKUP_DIR}/app.py.backup_${TIMESTAMP} v2_adminpanel/app.py" \ No newline at end of file diff --git a/backups/.backup_key b/backups/.backup_key deleted file mode 100644 index 0ad5272..0000000 --- a/backups/.backup_key +++ /dev/null @@ -1 +0,0 @@ -vJgDckVjr3cSictLNFLGl8QIfqSXVD5skPU7kVhkyfc= \ No newline at end of file diff --git a/backups/backup_v2docker_20250607_174645_encrypted.sql.gz.enc b/backups/backup_v2docker_20250607_174645_encrypted.sql.gz.enc deleted file mode 100644 index 8ae4252..0000000 --- a/backups/backup_v2docker_20250607_174645_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoRHsFDJi5AsC1qRqcqnIM8eqtRWIwuHF7n2IL5DTz2myp3zVWmN3KmHNHO3pxV4Zf3DSWalPWCT45Ie-KapLGXdApCjDKFIBsTGlEStAxLx5UQPCTknCy0tqcw_osXjdCU1tE3YfLi6MRmJHFOClmipW0RVSDIoN8BBV8uex4rc10LZ79V1_UZ1pUjatSqjQW-WMOTdN3KcECW8MstAhp0JJG_AoKTZU8Px_kn-1wrQCyf0NIgcMFg4raEBsJ3290jRoHYdVs_89uei3xZAoyCfK1l2cvp0AQUKIC3RionZWqYxt420vmMYbninosyIHYKDmDj1xsPRWVZ4PLs6LPGrYY_AhHHj4011HSJmqG0kCfXmYqjTXlZQ9FHiPYze5mOayMJaCOQWDhkDghzKpW9Z3PHgfiEPKz-95soSeYdbICO_B7I4BTzjlNejBbV0iRZPYzkgX11QOQE1p268hRRLjl6PFOOPzBV1ectqpLuYdMuidaa243UmN-PjIfGOiAZrRCKsKbXF8wUmnCPlfLIT74PZo5YVLJiPPKn63qlLvRyZPn96WHdJF6sW4xOn5pxKn0wAtyg-Qp2RKwjg-W8a3RqhXfamNQvkR6w5cRZSgrbPjIuaPBE7Im-IWn2A-WjnPY8KqzoJDRmFpaeeKLMBGoQ6U6uYXjSXbmS2wmwSR3rmROcbsuIubTWpNakM8QbT3egfuFShWs68he4Gr8wM-mtddxci9HSlDTufqRRLgg74_1-0So94qRn6fR47zgMXF7sS0dQVUe_X7o73xirwECI_BQQe415OjeDI086PyNmGD9DBO9oARvIXcamT0Mxv5lJhCLFjT6vtASTGlNSxdKmwKdu5yEesMLPzatx0-tNf8YSFaYLFlczNIpEkuqKo04qlNbSYEunHIc8AFzK3WRurNrBvTbrYZ5cZHM2sh-nzNCxWlfhITodck0qt-aMr0XgRLkJ-Q7uzUfmsS2TKUsE5cJE2V1ibpHCHpzAAiWckUrH1LBirSGUSJGlgOP6hZqztUlYv46wNVHLs924HUTtBsUrHLCIBslgsUXR8SM_xBVXXhoFu_QZLMSPV63_HsOAhJr0U8Pwg0cu7S5OY03ZO4Ehpevqo8O-DLtKgrm3TOC_S44objadaBLnJUbP5KoZIRZNxqu_MO2kQKFT4_fcaTgFgz3nf6ztqxkBXMqQ8FeEm7IcfgQEcY52JI0jomkN_KFqp0aMa9P9pkcN-ZCi7ipzgjoJnJGt1mKxWM5uHn-eksD-zyhmw8LNyoOxPQv0b0MMq--5WGD1I0ylw7HuG-ZLt1G3KV3PqruZFsn3_as71yB1011Kr4iBNeDZfsd0IfC-VMUjPE1KBipy-zgtWN1244gGM8rfz8Fp_3_FXduE6ckYSXKbvCaZmcbUa537H_n0Eq0u6iG6-ZuhSV-ll0dv68T5LF8mH71HO_4fXMyjL5bf8CsqgL16F_EJHW25ljGM84XsXcJtVVTTPFfjwvDliQU07Sd1yDFhKUKD61DE0D74V4JJaTebg6HvtqFJ2cShAXDeI9WUMzdTmnS1vRSnED_5-ag-O3vgQYYbinJugGmj5W8J1r1b6OXD3Lok6DuqRKzPCYM5GJBVTUKZjCRzxkXTsbVogvNWLV8rRS5iC-hQgzwImuDi4tzMKX2k7Q5jGIuiCwNCu7t5QFe6zpDeR1hvrNfjHIwahQvUIIzInv2LwjRkG5S8eWpNBImLCYkyobxBRZsd24OSKxD8KIdMJqg9P3GlSgskopwsWiLEoLbWC9CuaS303aAgYjo3czq8Bx2QKrSZtI0uXyouNAc5P9t-Y2RSfloQh_TyVJij6LwCLgIHdoX4N-_OQjVCtxOqvem6PPMYoyjvh89bcrcIzBNke-z3nn8OK_4uTzzm9z3_OHMHK6TxbxaR4XNMogWWkQPZD2pePw73iDY4H7b8iXrD3zE0QT9F_yQWnqAfotudcdXDFe3c4-U6WHNJZ4XhkCcok2Kebi-pvQEkkv5YSi8sPC7jBx2O8qJp-VYCqQhSncTzZRyXt-ZCtvfHtZe4g9wEKmtf4jySJyW87YLe-fkmyP3kGaYCqAgCt6ieeQxG-n7Q_THwRTcHTxDn08YLukYoa5hFwurkn0LZmfcF7T0FRQqs-n_Y3IDqEK-32lBFwbajfvsPpZs_JQe7LFOOin4JhwCuZRKoStQNUBKtwEaxvXxayEKx5c2nJogshl284EyTdJesDujb1PMW1w-TyQDj0y0Bts41-fpIkCUxPl4gXXEP9J38LP-rg8Qh2HbSDHo-qSHO-PyHtSV6BQIVHQE9JUvEEa2OO35QY3cjMP3tyY_z7I2dOYGB4K9_LmmYQkn6Q1J3YDB646B4k9nQBnE8CJVAzTD5p2b6CrJm-r5GS3qC8e4EoJLg9-Gec3m-pjVo9E3ZToSDQ1Upf4Ej82YmIyy8r6aWrH_ztJ9uAwv63_osh3QAaWFcesBzIy9TnN_n2VzzIcP13RgAqwQZBhVGaXyfCZvUQNWo6vFOwgzvcDnt_ECYH-quSrS-g_Tdo1X9mv7SllMPmDZ1YEv0Szyr82tkP9NLBLdq8hZJ92kkrCahGAVgPEhlCIfJap3HOa22ezPVafmvxv8tc8qp9pUIySn5pmKK2YVL31lnCBa0IOTED0dlfyANKWJCregAf0ZpR0z57rqaG3nYDzN1f1sgCHdcTsyNR1revwlRr5XNd66PDu5_sA7-iqH2XGiqvoVfUIPz9mi7Zf4CbQW_gb1yyN4LrBQUq-fjn0xJMlkgJUHzVOcZo30IOSN_61sU1dLiJJNIvp_utwxD72zpOyxrpOzeytWBgKGpWmFhc21vUQar24m_dG9E4FmEULM6Nzep7y0dxgA4baQ5zoaYUTdLO8grxUlJ0PDoV9vldsXQ0sgwh4ioB7JcYh1WlKUTcQ-EPrhvh8lRc1WWjNnOnkjoRy1QFRnLk3peqxIkR7iMZRfwCAD46906g-TAiJWX7MAIOHyFEug7lh1jYMaHb_XfS5Uxzqj_YCfPW3KiPjyw_Bt5_NJucYwgEprD70VnvmC-39v3-hIxOCYQMMJdSGeY0omKH4PytVCkXAlM6HQqhY-Xt54-ZO6iVwrotHsH5oDgZ30Q9TT699V8XqoaKAP6ZgAfuiPzUubzJm6UhX9W6sRqZfgwxkb8rWgdMede80_ggxJkxV5fYsBw9hhRGwx6nCkuXL9hdTN_TFPilndna-AlSfwendm8Fh7YvIU7A-o4UXPwAVHDMwrlMfFsR-brEgZOvNZJgDd_LDYDLiWly5dvDUQ80L5erSFtsLCXTZB2TKrpROk1gEVyYR_B6Wf08A40MoKRLUpBAYW7B_Io4IBukAzTdkZp37rHJkpZB3gdmPpliLUpq5mh4lzQGZT-LyFAI-sbEWWNRtt-y5hs0S4VqIo9OO7JqmWuNScOPucmZnApfc1NzPA_2gxzd8kYDbtgjw0QD_WF0UTzgiRQK7Y80gzF-pJdeJGr6tfkSN0zTfxcLRBHhRM0rJe0933-V5V33pvMj1l66pt_5pHV4ZByXCNZJq4hd-TN8Al95VPox8qWK_2THyVuzRzX8BT0acEmsjDHWdZPNIlFdfMj7effacQSacxXTabiGrpB5-3sLXiQZoV94i8PNRG6ru9MFTxij42skadY-d3B5TVxLxifoyz2BJaWiLnKYzw405qCZdFIkzUQxUvrv6HO2ppGcZ7przRwk9wuxhBtT87G8vQR7y2ZZz2uqezrPPEs1nVlWXb-V9plqJjTmDVMgvRzkrVwGTLn0iBLrdjuJGoBUV8UV_S7HUkz6QQc9Apkqye7QIm1LUlEJ47s6jVkV3qQTrLu6b9OSJlUvK7SsFikNcCnKiqtHm1W679aOEaNKWZj6XhDDEaGDZqM6tq1mrTX8oUQdsXp31D8YbcftMzFBhyVLsXacL_1bkEPcwmfMoERuMPevCXcNk2KktJCu4t3z_ivC_bl-6jj47oMl0nau3Ug4bGe4jTD4TpUDlz6aLHEGJwaHaNuz9WbP0NUPaaEeWhCsrRyocOAeQYqDYI-jr0rTBGUPLrm74Tn18Hf3tD5KW5OdWOyeBF \ No newline at end of file diff --git a/backups/backup_v2docker_20250607_232845_encrypted.sql.gz.enc b/backups/backup_v2docker_20250607_232845_encrypted.sql.gz.enc deleted file mode 100644 index 5bd7742..0000000 --- a/backups/backup_v2docker_20250607_232845_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoRMst_s_I9DGKyjVegtWJajCPHP6PJWBJD2lwZwFOJBbrMzKO01UB8ITDZpaTWygf0Bp9OpiDXGhLDtUP01eeqaWVXs_oNrN9zCR4Uap3ZgQdlMwqiYKfAs7v75l9gJftKn5ibAKeZ1tKLh7XMpPdyDrZ73Uen_4QQw6IOFV6qOruJsLy2ulW2ZxseC81ZfPVEUPHAmFUT8GieeS5tuM7TXc9TfQcqz4ahY8imZjx33GCXsNOEPW0oYdc-qF56KWe75xSZvGbuq_OoaHzFf1RmeQzHd5KhJkXvXjDEyYu3UC_c1xd21A9zIFmO3k6iCD8me05m8L-q6oI1N2gxS8hEG--16XJL00vKlSLl3FCtqeky9PgQxMYdVlzU77AFxkk0gaHUtFU67gl676SDRi9DlpLyJYXSiQSmt-uLGak80eDVGCIdF71wyCy0fZoJah2ZSWCYBjK72KfQI_-lpQCrbV44QbEwiaJkh8rPMMiGK6Df2pzUNUpOBFKCXdC5Hd-oz77iwfb0ErtcLjtCtZKBk9jHIcWZvYtCn6Gjo3gubRlgbNhLJstrPA1rgpNJl4sK2_jL0gxUGaqOuoibfvYZtv5OWZz-11d3G7CPH8IxHw49XQRbuKfgDWpJ0nB2EDVU0C_SkUb3UvlddogLnaHaJWvum0BQIganZYB6aZvTz7lbmz9CrEDAKDXzz8TXfBybAf9tO7IajH08or_2R1FmEq0GnKJo6bfMqChcfu4iuxrlnqia-W214_WNnIRQJqwONFc1JjzwYw0A5WojgATjZe-fwuDi9k8N9xjCIOIBPHenyhw1OgwAz8CvyEht3M3JoR8iOhjdjAtQfbNbbwt5csA_XdcwrbjLKuIe8io_xCCuGHwP8OHbNXqFSN8bohE6wA5tk9420gix_AgL56CXbjvNVtDAjEbXTnedew_cv2N4jXqR47bMcgEREzhNrTyIgHumcvT9-nQCBzsmDW8TYzrJW51XNnC_al2RWXhZM1zGjuYo2VyRlqKxM2lcOgx-A6HLZZLjIsooUT7sv06HNS11173tFnxXVmtIWk5UhtCb7iKZdlMeSUDIR4vV9JIpe6HNp1ECV_KznwOYY7oH9bRkg5rHKKquhqb_9XvqLPQZ06n2Qe60U2n31gvdNnnG7-ppgRPycBXq_b0EnGP1homNxGdLlsrTflYjzh8MBjEE-K38qm4yVKwcit7eB4jhzDNSKWIQWgohFxdrScbAu0cImbMCqytLYWhE6eMTBwTXr2c3WfhWBwzIwsWa7skfV2M192OqSX9g0YlfRmPZHXy-KvvJINYLt7eEVY3d7hKSfvc7yV__ux0E3-hNhCQgc9eSnzkP99gggMWAgxl0mzEo7iLtsbMeWv9n_IBD9vzfefrvTh5NxJlroR5MZNrucmMmI-KTfuzQyKO8xuN6UUUQmTQRiMq2Cqq1HJNw2SduZjO33ldlkDl6JVxT6HAWFo7smKUDkG0PHYSYa2mwtlVjDtkRvXkqieFrdX_IVLAAIaEGv7ROviTQVXvZmmcPoBFBvtgrV5xl9WNwmSPJAFI12xEM4xyRnf48Zs-V1pEAD450aXl2jkKFDcVekp6yBWcoek1kgPZblKK_B6t4Dwdc75s1KpTAc5U1lZWSVzl6_VtI4-pmvOTEPpVFfAST2m-m-TAZ2G8aFFxj0WenY_vhmC0oDsgUjs2LohtGQtO3s-7o21l3y07gt99PUYcm6x9E5crUeeWpZ3bAwQbaSS505QIr-Kcd6aJT2H4usb-5MYdjEyuUAs3P3AeD_AZa43s1b7bxAvWOhMip9LuPmNPVgnnjFWhmessn_XAmNxm7_1Tr8HkNIwtW-yRe0PPR0cRv-KY6uoAsWMlP3Utj7-C0uFU0gnZQlX9akbgf0S_7TJUa4KM9R4QIl7YZflW-B9EG0EXa08ZtQmiUOd9Myd1PfTm7Px0TFf-GWZFzb8OTgmyZi19iKpruJVM-gszT7v5oE5RcZImJtJG6wykXG6ayotEXO8axEhvVvrR0cAKNkTLt-H8TjCBIE3ToFkvI2slgZWS6b6rEApvFDSZlQu9-SjUqBu2JiLCDieXdGj1V2O25fnVlqMk0PkSRBKOm9M-Pt7bxzOpSRNYbdpzjmDEW-mnXxdaxo_3pqojrN--ClDlnjGqxaCafz6B_H3S9vrTGj6CileqHNCtNDZ7vSlfNebWMLHitYZHFkH0dcaRrQW0aWICaLYYKiAqr0PQUuUuRvYEFn270qsI9uQ1MdQ40nFHyg1q5dxVGCbivmNO4dp1cTtSqYMb2Ic43VG5HHG8N3DTXWV1QTm_4uGWbHwrdZbRLxI6e7znzybEHgkoWE79vpkvaA8cp1KXt2XZj74uevUlcsNv0cc4qsEU3PGlYotTombP-JYo9us0G1QGP7NdRMx5IjM6G48DMPS-rVlcOotXN0FoeclabJhzCH_sQeSbT2elzg-3DbXSFPvCezz1vOr1EpRHSa-MIeeOK5QWFQeIP8PWAQXbLbNn4Gf0UPWCjUQyzDohXPDXUZCA100JPzLxEjoCyhIPM3TZ9RHdh1MEdG7nLjnpASKOOPG5vLMjuuAZo1w-NGfgyUDdLGW_h5m3RkJU6aLQoJR9kji1d4fyUSqohMiNfAhBNa6HaQfkaORvKFKgL016-HxgloUvpNyVe22R9GUaCxbyrF4fBpSlOCMVIRwkQZlXfsSDA70Jrsh04dJyCk47uxsGD-_0paiUohj9wYui9TYoTfXd-1PIZqZ_4rPsn6eNQJvAGeOfD7SlZn5oJpV_tXF9q67aQgy-WKwkE7bsy1Hj3eOmWOZ_4ZtI2PiA7EVxGpZk_nf2vgl5K-e3rl-3RjltT2kdo0zDui2-hS575aebTVxwR3wVHejy13tJcPl8lq4YhjUACX1dGlMyvq_j18ChmRNFecqIY2t4oS1Eu9QGjI3kpL9Q52UYhF9JjbwRnvHO4hi2vad7XAB_g59l2d3YSBAMq1fi108EW8GYYfP_5vWXJdry8d4E2hXDT0_fkPN7z0DpKyej5QdEEWZYVwMTxKttGhJZdNLLyr18e-LnCqZQvIvHfHp-K2prpPQfrZKP6HcBWeVEugo0IRW5QrhY5SwcqueZcmcoylnCYIc4nTJ6cCb5wIdueBR67KvgDUw_53BZo4-Zgxk0gIjAAHOugzitIFkW6BsAaRKNTWEKW96RlrH7x2wL9dErUPlS-2EX0x5wHTalrJLDPIGJUxkLp5K3Z0UbSb0PbOJlyM9YsZ8doFQxoQXoB8twMEUzeHQbxZ34QDtr63EZIuaRz9CpHKYHYYRUf0Vv_j2b92x5k6suAGW9rPFBdIYMLF8hx9MiKUgngnopQV_sIw4m2QX-nGjlq81zzQZtpTtDsFqSySfFwgop4U32oAqR3rZeeLL2MseAOB-Tg_-2RLSyF0Oc2qgf4UzNugfzehne5JgsADW0ePnAzoT9H8HT8RvLM9gQZdZJzMBNVPH1Nea0YmYpPglp0p2y0JeusODH-Wwk6_dlDoIBfNyqdYphitU6AKRkB-Lj64B7WlO5Y6MS20dsClbdA2jSlve05lXWnCHW07Lga513Z99IzGSHZkUHUUJnpb2NzhwQEYpd46Nv31rExSelnuSYP5YIgjK3eIxuEy4NGzco5c_mecgh-robPmOfZVXjuzjTJlw2p2wMTMVxvkwOn77ObAjkZVJfIJBacTvror7yb0k1umkkAEgSRWGjLUQNLdYSwAIMKWI63WuFyUP63ddkLQrnDR6vcWOOaD5e_mT_Je6_7tN-rq6OkfbqLv9cffmuW5YmLG6id3IqUi4Pw2QkV1L0AwnEvOTiAAeyFPfU-SIIN5aO0cdpOyGOFq8FFB8eG9UPRSfQg-LOyCluQDRvmy0_Oc3zEXBT5QOsjOl13mJNYoBF2KEOU_hRgUHqgztqD-rMJ-ytAXnedXnROnoXK5oVUzOiAvx1gHFC1r7ozdDjiiIKZFiHAD2FI-atxY60a_gkyluyf7t5lL4Q9CuHRXCzKGDEmDHbwHDC4WYy_heEK7RvN2Tr8aYEWYqV4tC6-H55UgnEuIKYJlq1X9-wBHe_iOYm_xCd7rJoFpQNPjC9FMr6xjwTCH_8iltOidw4C0jwKZ655jl596GYkvA2w070rZPfKJhuq4e1pSX5kzkrshLXCzkiH8TGykaq_HhRWRHEM3Tp2symFyBPNBL4cU2_RBhp6Io304dWRl5A18u-3qWUb5Zj4TjSHWEYCaSdbf7tTFreX_mV4O_lBa1wdTptmT8pcWKTLw6GoGQRmBWjXsIoI0NdN4HZsgaY1lsyi-Gg9uWFnRFh23JjhOGNP-39o0eRLizJBtEMQu8XvZfRXasILDY4qa6vnuwjr4rAz6-ZY_KGbCzH6We4JZmT8h155iH445gwoduhA683uLH8A4gyDjr8oSatjAZsbzbnj1m3UcsOJdpgRg-WzCdb5yfHjIEb9TfYoU0StwlNT46MnowCXMwVyjNbjP5t4icrm303nLCTY5J3XFaCL_LWokK0boivSCYqF8Cca0yx7uFmLgmBGVP4lARgMmDk7DGq6ZxjgEUa-kVQn8leR2dDy1uLOx0Lcayi-fBNHLwRjuwtZgEhs17hLNV63BhCpy5MmHSg9NV2VUVOSTebDyRIqpAZ2YgPC59fKKiwX4nLUxetpfv4e1pgSPbsy2o6ETzQEWrz71My6JUD1Vz0BKWlHGWVoWXlJm6eJmonpSyipM2Sr9RBsq839T2zyg6CgOxOrbXYYYBSOBAy1XMM2XrhYoXWQO9EhLR6voiNCp0-DyXG0niq5aswGQzr4vBHEfr24le-vxD0Zg6TnItinQsuIdgfa6zaUZxDF-vSwRutPaEI4g1-9xVuNpQ6ysvB7P42tlIDiZBtDwroal5dCnnFqB8BoDIsmmpC3VAB6YbGjU5Urs8gPupjQF-uCU6iUD8ejFiE4IfUUX6tJOHwt1RN9ta9kROfjhijGlytIJBo_bAnf4Rqe-6kkZzYbO2P9pYrqgl60sNrEKUOuwrOV7L4fy6NXbIowdtqKmkIpxJRJKtMR_hojSaMCuQM_BzUCKBO2-yLsYhSXA6OXWOZRJPjDCeE5RODmtMCOYHyuPZXDVRNZeaEARl6fYRaXKNaf6I1eJfJoSso92di8h65qviCvnsMtpvymYbYz6rt-TY-5_GwX-aAZh8mNiisXkneu40mBKua4ejVYISnDEe7Wb9rLZH2DTPlQsy9i4TsfVV1fEB3eWeOBuhFJZOcGk4Yn1ZlfqOkBKMtBQKaoDYPFwiK4YG2hR4agvDe-4C2uBYyV486zvEhThsFpXnzmJthjfDqZwApRlLtx-WYI8XhUU2mtDNvfk7-U3fBgQUdtMkkxTKSSyFUw1TCIl9nHZuqJKVXU6sED6FNsNfs3uUvaR_ITjN_FStNuxnOtQ050RW-YJIN0shOC3ZwGS9L6bSCfUcUi73DtCiIya-fHmWhvI7p62LfKKTPHXtQnoLmdMDkO659FfKNp4XaphEX5DMf5uzWbYjjIAfY5G5Bxo9_67ME2nNVEQNoC-8aO2qaSYFUpwU_WuCiNXi5dJ19DYdOlBelGYexYMffJKpLd26wqmhCCgo0zSoyw6ccsTIFMxyOSc9_MeOajksaE8iIN6Cg6g2ettCSI8amiR_n06e43MXy6hEzFEfvc2a6zvctseVoZL76yW9KbLqjPApjnlHK4FBNA5cECS8tnYRIo39cpZNF3h6cvkB2VBRb1TohTTlzm4CQOdhzc406adc4qvUYEiTD4LJ_OJyccbM9nMKRELVRwTJS54AYR4Z7OsTjUNx423Cy5umPLFjy53h3nm_KMRFoQI3e6XRf9p8Iun_7s9pw_hsY6iGqoBHbgH_0hRdK14EMx1by6RpKAPFKI2hd67Cmth208PglcqpC1EpKNTnGOEQkVRzauDqN2OCdmdRLPGj6QKwh0QJBgORZEiqXC5BGPin-9Or1c0yNVeLgg_u5t8rj2IhK9dDr66UZ4g3jjPk9L2o1-JSc7sDuY3x-xAeuVIw3yXiRLSQ8RBoPDcA4whIne_r-m0AepHz1bF5NI7ZOSUla1agLzv0vn8dLOWBZH1AX8qtRYrYoSAe95Eoiz-tYM1DeapD74qQqyNSQb4rbzVzWyNb3jGnNkut-K-h45n42mgyu5uedjm0RqbrE64u8ZVjKPdOJY03uJpSYNtVSt9Y92OMyrBPyTC6C2zcFDnFeAq6XICZmbf6BObRKz-D9c2Rk1bzKehR250J4D5zOYp1B4t6T3QYKb61y3ZKDDJAywj9eYjvQtWilo0aEUY0hCmGkEi4ZKZ8vDEL4StE3IQKAM17WFXDc6hlK0x6Olu1xYr-hkGGH8yDjSJP1lXJM5abb025uSVUUJNYMHKaCwRHOQ11-5eDQKXdWx4xqaj2-3AnFl5OB_pVoXJolUvrg6vkgNNlmrQvAMwohhpNfOBy1-3AAkMd0IBNlREiIUBzNvJSsBP6fJiaw4HCtq0TO7iiRd2INdgXjyaQANHSH88cUJjX1dJXAqxYW80Of0nac7JpIRbhuco01Ui4g8RIOI3MKBxWwzqWwiJLHiSbAOvvWAe7zQJ-_iLsIILoiKxcyK4QGUwcR4oUJfsRDa2ylNc8_PfjIWqtthqDMvokA0UxuZ3f0bggebsgy48VN90k6fX1sLU1GzbW61w-9LGAe0_I3T1bbQbwncncQ2X1_2s1fHQUuNkaCkTuUJr1x7esW1byYmj8Oo9YpXrixis4mH_gP8B3bfqixMkWl8nhnwKd-gY7oUyS73L-4GUC57J8iC1Sm8XqpxCjVrJP0v9T5g-yDpwLI4enk53_slwFnCuiqurTzWflYLN2_O0FWSxZ-Q0MpMCMCSHEUnHP0LI0NYoPNOCau5ypI5ZlMMrR25I_p80pHTM8fxrsmiArZkz-0EpVGSh2WC8vsd3qipuMRDCNqNLVkbrIo7AV2wSkX7uhYFwOyn2EHMmkQM_f8AjTRaDgPl3CaXN8oMk_8xHFuuRTdmSG6lNB6YAgFS6449C1WA2e5EY-BJo51eKsbp0M-2uLiR3php7g2hQjAvjUn5tZP3RNqwFzk \ No newline at end of file diff --git a/backups/backup_v2docker_20250608_075834_encrypted.sql.gz.enc b/backups/backup_v2docker_20250608_075834_encrypted.sql.gz.enc deleted file mode 100644 index 4c0d6bd..0000000 --- a/backups/backup_v2docker_20250608_075834_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoRUKqa6JmvYu3c6xIwYDltw4q6cJgFggFhee20YKw7GpzLLvT6mEjtqnws-moMdGF7b_eyvqVJd25zvmRnGfBL-ZxfOd2BwfT0a_iMXheg6c8BSyj6laCJFkMwuN8Rc3QYztYuMzpK14NQF_AIDCZV9NK43XH5a1ltgIlGjxH_iyNdbCoNwsJ4LCuEawgOhs8hSgF920G0MmnYIkAfsEKGR7F-IZRZQR6OhqPVadsvS7LJi335isvVy0Ilen7OuCQfYFUnEZWgh_HImiycVTAfW2bU2m93zIPqaH769bAoBTswsTeXXfzOLB0_xQVZr_Gq4ADAwg5G_HYVuv_TcPAixxBu9EVbIDr0DymLrZnesZaT8PjV1NNgfsFLW3a21NO6mclr-cYJVxWo8Jm_917d-dkiTc4avrQdnmkDqXEBvKSrI89mw-A-CuC2u-lGa4lxK55uQwtkaFHDHzNjOL8-3XD7WMrj_duuSQAnh_LM2aSzm0aGX2maKuWjBD2GWhFPJNkje1apVgZfurw0JQeg9y07uPcqcrLV6k7Jn0y-UULRQcpn4TGLf-V6W5YLqtG-yfHRWGFd831CJjqAWWS_ytn4f0KngNUCuATgU4ksiBzmCon5EzogEgcU2oGr3i7RBUI2CRsPKhkugm5bEPuGXCGh1hLMQgNWSiBO_w-2k9rD6ae_-2C2Pc5SxnUY7zCgy9D1HxU0Gxs55zoD94j3vO-ZdncUprnMmLek9PkKVRJdzShhMF6dy7lqBXinEaTfoJDOoMw4nhftd8KQymMLGyOJ9T6bWnPebzFwnK0yKsg-EwQyyzFCHEAIQp03EG8bQQtYNYsrEtmTEY1egarKUkr84c6-o2vYzxe_Wa4PwCQYxzcAp5N5dSK1aLC-K4b0UITQwLuAXhcfNsd6VGXkzQA0gjwFTs78AmfCBgHU7DlWqelEVidwgaXC88JJbxZ-m0qrbVpSdrBqZGroWliDxqZu_wKfsr7U8pd3zvu-St8JFREBuKLXj5y007ZgFciCDl3dchtgMW4VeI6Ro7GVh-rFEkQoAqpIHJs3adGEm0603RuYUzOyz5HI4XG8gdNR5pyrtfA2R1MRG5RP_LCCrsWkn2-8AP4HuC2uVLxTXh6-1iyLZGXKqq8mjg8ndCKtrw1avcFaLgzPRJm5ARv3nyy47nYgZbtLSUI-LeoKzH3dTvwenx4Ni6ie6Ls-9H3p9o3sO8vKxY28P8JZ6Lrg5jJTQn5VBQBldXuERN_6y-FbFR-mu06rq3Df85wI4BjMV-nH9786VIIptKdsPl2QElgT8qdgQR6IoeyyQF_LXF1jhAw3wUSkNp4agj4VftZepP8hEhj8tu7QTH57AED1vJ-LeA7W5m3FMh2JjpyyXY5yXTJLf_hOEc-LmYc9CoEsL72fLhFxm-xnvGIWeb9qEjJIFGOTWb5Zn8pnchF1Xu_iy3kNolgfG7XvusNra33uiXy3eix4X5De5XJ0g879uMQ6USj-_rrZiPINUpY4C80_e9GMU94wIJkFr9u60FU6P3a648DGUGNP5GGD90EfwXhPF4OWeGLEsFKArdGpW4t1JBW6G2lR3lJO9N3tfvxynLCwlh1oWtr9drlSt-36gC0i9X3I52YuHnMWSKrchH6a0QU5-pVEVMHTHMyDmfgpBwAG-XlQwtTPzzQzTluZO4F91VmCVFKFg_ZGMmcH-_mglPRVxhGUCd92VPcbEspynQHoy2eGt17KnFC-WTsvb49sGsk9BaA95_ed8IOtFkfnwIM_oQ064sqQnGquvulNxh8mglXu94gVP6dwm7fQM29CXcC5rBtbKr0pkGqtFAsrdFBwj1N7MSgrGmXjPOpWtXFININ8LROtFtlDdp2UXIrE50F9PVA_5RfExtnf0Ma6ieaP2EjsZFGahCb8SgEUKHt403JAgvBw8MUL9W0McPLSfOn2hq_AGr-3dXCrZkpFID1Bzac89iyO5LP588liHBClLiP-2NVMsUvRh5OZKBl3ALKrypjUFF2QcV0v55AL78auuMDawvbfy-2UT2vhnzwkAP9VStxp_MRnRn6qKOLi2uyNafmFA3fd1kUT-ZOp8umG12136-AOVqoTIV8-DcWmTOWjA_pDPmSM1DIIm8VAZY9VMt7JDI4_ExO7V06UKgvvhPZyqVyUqa2urnQM0Jz2wUvTErQ_5654qafZXhX9Dqu4WZT0E5RxsCB-9Meq7LLGQXNpEMg28zHLlwRErGDmQYnPJ0mjobNMmfj7qyPz-CcB8tA6qLwrcA9DKkbQAT8Lqtw8U5E9aYKfBWqenJqKb-8n8c7b6nJ7uMUAtAE_W3e9w3i408by-nnzgXrhwGqrBDWP7gR7E0KSx5KIKnii4Yo7yVahp7_DCjAb_eWhlrTWEzKTr0xkZlZmhTwskV7oA9Pq3DfDSOCuTW-J_8bHpkH_eddgo-nBIqxBQGqE8_g75H02y56iIfDHpVII8XwNuwzDeQXkfsOnOg7NS1ivNWe4AJ-NpLscoSv1x1WgwlcfiI73DjLBuZsIJf4ccelr5fkP2lMJ6QJuEKvLgrBXpDwPotnVG-G0gqmYuNhzrov3Yapk0ds8qnPtBNFD8HatWmhfGLjvTdnpakzM_NguAmn05bFnAjyWnRT-MwmATrngeKmrY9dpK0DJrtdDUCWibqPKTWNfPxm_qTGglyltqBdFiTIbQwqhs1byKS2M5ni6QhVo2rQa0PFftK_26Mf4se5bapN_TNUAyfzrwlDUXnBrZvNLn1LLM5mBDlXec80zE6uD1TXkYK1pX3fbs4kOmwTUaXmAXuIIi3xe5f9B6THt8ehWvLgNzbpY0LhX_3voRJKx4iuewtIiyM2_XkhOra8E2-0uXAVCTZYH48AWnt0Vcq7X1mUwdfVlJ4HSkRia5eaui17jvI8H9wosCKqPsWKK72c3CSlJlhK9RlvD_tNXCZyO4DjjuLhPOEaHYMIVm7T9AYztDfYdmdorP3ZCNWw-Qs_492sp7ufzQwpMdS6xCOeB2P3aqZZaSEY5xb4I-qD7xSBYR9blr7kkRc_sWIL4jMirQ-iJlCmTdaKmBNOKZ5WnTNuC9Pf1y1P7_6Lv570oVvzZikHiE6wll0asR3d4_BzdO4mvF406QNRsdX_mGnYXepoTgWmQhzKSJvtFkYzjeW3Wv3ztu-NVlW1Lj96H-s7pYzK7sfXaTpH5UR0x5LRrfdh4iQRLzZQipFkrff4Q8Kl4Y-eNR0CHHetrLF06ya_eDI5zqiAVPAH5Eidc8phohnN_G0EHCGXoNRcHF6m0bJdtkMYg4lgcvZrz7VsAFXPa0KPMrjfTTL2C1nYgOUu-pQ3gvjzRcUu4uMyWmq_u2K86iMelIivWWZ6X-4hqpaFXlCD7Z55JOm8iparxN8vz1Y9A7NBkFF5AuHSKu7atbYYPK_2B92ANDjN2slSehtcMwGaRYACCPWOiFeOMju0B9PoFIa3oDKUu_jk8FOK-9C2Z2He9Ptk63GeEBzpP5T0o1HDlyuMKRmwbRMzHvB6pdhnwt7ihTuPC2YHHJlAXyxVa6uoHDKjfQ49iIKMsVfFUee3x1cXsNN_E4Nza8gKn_6rZWYwQIbJ5o3lAd3NUvpygcXlQTDZnwikwqUXbJbnn3G-0NI1udstSu-SELJy9RyliWKNiUF0nBvju4B_DCYZl67m8uTamjroSGvddl7xB2BowkPJCaha_nUUzyU7N_FavBzue4V9WZFP5b8_Hbptm29Qdc5XsDkjmYR9xfrA7Dm-LD9SyxPqBYCpRYLCaYObhepsBDprsCoRXxtnKeytxEsg9nbZ75tJCENWAwp0thYhYNLBhHUh75uoJqXpGUCR7JBeXLrZBCcJI15GEkOwqKYMlCYqs4jB24Q0hQhQh2-06KjFEDcLiQQuVvTMyNDbXtPAWVqEDhf4-Y6OJ-u5ncVOvrx4t35V9h938Y6-Nhj2IWLmJxwDc5ieBC9rTSSvZRGzoBt-FgoMVBFbI-Bkrub5vONDZYSUJXD3-H53sihpfMCEkJZ1rDZLkEBFVBCDyv4Wh4xWvXjNWU4KDIO4mt_q8-gJKQe36fRZpw4Vc_16k0T9Zn2Ft-TwOLgS8arrICe3JRhwVH4EQfWTvREejCf8JKHWIcWjF0CvjqaAhaCXL5fVwWhg80cVs77xthv4qBqjOuyDuVFfxXEXzZy08YUcXSOwhPpO8cYieNYLgFo2aoIso6PUeu0K58iB36dXXD0A81P7OyRpyIayM10bRxjTxmPQuG-Keet5axNuvMsuAXB00bpTa6nKWeztwFoo6R5J3J_Hvz0XSH0tceVkbXe2Rd_f_gCx50auta2uAxAzSUhTHyKawRVMMYlDb3JlhHC7lwm-nUw5Yin75Sa1kfV3OWfpXEmno6NkXx-qC3BovGyxy6Ywaxj3yjXQFD-QVm8ZM6OSZC3JV7bX42L2qDAkebQpEIWSlsNQqJKWBscSu0Qtl_4ZIcgsblJWHIKc4Ra147fYRiqJsdkfkk5-69G1-Ob1EDDpkg6_z-kjHB7tZOa5tmdsDo89i1GEqSn-CyQJ4SA6EC9huXmA0lYrni-pGDXmu4hFXZZcHVSfiak0jR-TAZaMtKPWbIAGiz9b19paxTWw140uQvgl3U9N-dkHN8owzpIsigSZPtR2FGQ2ffOPUC0d-Me8s-nFQH8bRTeng-swum-7YtxVhiWgixojAEnyZlSnaxNckUoaZFtikQeSx4Bn9wYpxHIP-gJkNLOpIS0awn6uyr1q6lEgkJrZr9VcKGvRdLvKQ19cDwiUGzPDb8D8usjB9Jc-YLW3cN21aFbSRIofoihUPAUIVE1sJ6x-PUklJn_3qHxzhLuu1b5wou_KBWyK1izPpxE4ahNe9kcVj6WOJM6FiNw0n0X1Plnt--Tsc5Lma3oQljYq-jaSWIaCDYT-6mjcpP6CTCnUZigXvi1PXFxdFy80X36ed27iIiLDK8Sdkf54sr-dKkJJFcuLsMO1yKvrkYsbWfmM2j5lewHuEb5CnrWUwELXlQyU5RjiRviUj_s4WQJIko5BEGlzb-u-vIux08FJcjszLZ15_deUEUI6D-XJ6v4JHEeJyk2Ddklmev8ftUP_2Rm8ruSdZYOdayeYHWjqu6gVeSpt2120oY2CgQFS1xZ0uEjmJ-uppIhDWFcbEJqnFxvUD4owMg00KfjaH1QekzH-hWXiqIr9dOcfXYY6ybWaotYGn5Q8RSoWWb9Rds_LFuLxudHuthEY_h3peUO7CcNFm9MSUniNbderg0W9Ifuw6PSHIb5qJmHm1hpOdTkL9RcmWGUx2btECCr2_E_xl2Ocl4vjVHttLK6vyPwaPfClM4eAA3Uyxz51MP6HJxfMXx_o9n_ADNvB6qUFF0SVl-Mv3zp7SGN-YCF5XIKopriOVWtQVc8VRFNgw1c78Jquw3eUlOBRBPNSm1w3fNJI_R3E7VKCN4xFoITZ7J0n3b3NRqY-RqX3iThyvfzmB4-Bolc6jUPwks3IKwTOD-hETFj7f9Zjvox_Uty_gZniHhTViPzHgV9QsXJmPEMQCShED8us4cjVzVWH-UcQVzKoPpbWVQ5MY0mMF8NG16j2vNG_6FbwWUkg_5J57JLj09anYJN3VMvcagKTIbN6oo7Pcj5cWRra_xbK8CcbsEvegFxlNi-pCGcr40hAJtd9zDomcXqGeVQVMhac0ZSY5gnfze7-4NsDS7CpHVN3rOl8B6qojEZPKY8GXWWaqJqjnJmQepkSLSgn6wN-ThV1bGc8K1DQyTIdhUkja3jBBlAKAawc6WXL7tgJyBrZ51LoVtI3A1cmUeaTFBclb_VzDBmEX6rivvr-VboVqRn9TITh7O-03JJxQ9R-orKtcJe_IQ-NGKNIdvK-xScEv6WTh3gZd7tmi9wTVtb55voXDfLf53DLIGtcH3AyJXBU_3FUhsAVBXGWqEtsYEqw848nr4FJm6NcImOQvm_VyQcechwMv2vMnjTxYGFsT4F53dOCUmiEJjBxgU5ortHerkKvYOIb6bVPLyVngtGIO0OjY9fhldP83RbUY7YoHMofqTXoYjiH9fXPu00umh87N5hYE3i5MC8n-_qjpvT-dxCsaVP8Ud1d9vKlysZyLziwOqGgnRfG1VuOMoVhADfIIEIlLW3bnMaxvuIFMEvmnjPgIvOeHeX3D5nMgVGfHCxDWpjiF5hhW7_DBuCoF-QNYslQjLFxKhF_BDoxoYe8OpD5oPZsuoWCBXYt_K1am5vs5pRd8s6fv9FmUpvpEHGRSF7UtzBAs25kph7sQwqNOTDzWO8pwaUL5UL8J8yqdpWgZHue5o_ktxgWjadEkaYljw-pBzJ8b8aifYWF3ogrJ2Vvn4W9HBq1RH6WWc-OF72MQbjUBiZ2eU5MoQitCwn6A5zLAgnD0nxSsslPrjP0ZqRTImcTYcXWJviEK_o_7xnkPmwGI_E7km5fEP-owYRH7tnkB23CqGuryiN52zfaL7vpJozfa7wKABRmHjTn6CGC0zziDybaah7TTgR9ARkTRNy7UfyJU08Bms8ho5fL8GANpqxff_QDMNeMSSzRPj73b3ntnx93RE90GFgy8B4H7tFQElZ37imiHg0dALdETI1qE90qTFbaoFldv-HNPbRq-8kqklu5juhE3UR3w4H_XNJonLivtU0oQPRk-FpV7yyVdLIrhqrzfAjB07uzRak9AToYfg4iLIniWgfCQf7MVh66x4Nb2_yDZFAP7bmE0MY5VjuEAZ18NFjLegCMzAiGc2yCMlqXJyT3CX4rZ2QQm_B6UaHW4U34TV1RWo_P4r5fO50M3oM1truj_gvExe56niuHNqpo_LRP2RLKwbogJGqUUKQs8-mL_sDuPCrr9aVGq7JbzrunQLwd8-lmqHLsfEPhPpEh_WTgm1BwTsSWofUhaUFAMGSOoYdsnBPzynqZ_gt_C2DKly1GurzTIPm2mBcHC8BNlLUnt-Ai-2sO6eE9qhJPJH8sYVuvS3IlqpPS00jr4sElZTclYJs8uWxagagFQNsyYixn-aBFDO4OogyF2wZLVHhjFPZWW2G1UchU71aH_XzCCt3ONFpSr13QFXWFERQpl7yjP8C6tJDnZVnheavjPhH8xXYgrOJiWL1CyXLCa8rT8bnp_onIYX0f5yssl-Zw5tRsz0Lf-kdqShcaXO7GiTVzVpyqO-ARzzcsXp0TU9-c2lRoQRHwAJDbKIJ1VJDDHgszG2DXVEYQK7fVZwvXhslpkWZmdbAwvvYAY8oofzmFxpaxDxuRRSX9002JNlLKhxxdY-HT \ No newline at end of file diff --git a/backups/backup_v2docker_20250608_174930_encrypted.sql.gz.enc b/backups/backup_v2docker_20250608_174930_encrypted.sql.gz.enc deleted file mode 100644 index 797c966..0000000 --- a/backups/backup_v2docker_20250608_174930_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoRc0qjs3vUkRcedDtrMGB3IUJVQQFDiJEdSLhhw3_ipY3Zyqvz3Zk4PcmfZJlF1tSQzJA7uwn1wEx5oSlu53F1Wxaxr8bHB2OgL51GiotdzlQUBFVswbqyVo9-tOo0CmlWWPp0B6Zzib6i0hpAe3IaCK_aTUZFNeTta-fwLUsKPgYEpuQADPFCJ8eY52zpahJL4GbmVcIIMn1Rge8ImBQuwhio6xXClRaoUVItYJjbpHF6h-rC37dKaK1593HHU4-TBForZRt1UBq_A4Z659hcdPYPG5xhe-8diop2vBHuJ2fEvIGNLC8NSyqUTSj8NiqsNcdBbVVDd_qpzvFPfD2M-aPz7puah94odfbr6CJp9fB4bjDdTEvP5esi8PsbgtpTZ2l0EGqcIGgpvgPeH4J7R9BSq9NcFmuCBm9tjYPv2P3SFPxC2EfMuqM1ZzD8G3UxPJSWn5grttVVxM3i6lQeOcsCo1tet03e93LnRcco2WA_PdcnKpKQUB_gZ23Ll81yIbQu8fXW5pqIo_RlObCgpnhPcQwXcabJeVOGAiSxM_PZLh_OZnlgYiySYnWXZMSDjbogbATui_OKEnNqHreONm3ELJ7u2730ZVqD192B-3y0AJgXWAZcIUnuM9c__sHwoOFa3IOFn-xXgdv_sWUSr3CyxUdc_DseyESL50iAHtYny17PccEmC5eo39xrcvdaOxSt3_rsmqKYDm9fPuIT5UnrHOcdrRaKGJPovmRdkVlvY6hk5SRALgZttxf9TF6zr_MfX3bbPn3hxFzuHemxHFH9gnD4ePmG5PGW6-Cw6G2nya5Riyw2OwbWwrgWmHKOlcvGkfMJBMM8CXoNjqGYjcFmMWSHEEa-9KJQDUBtvbIYMWZ5cK6QEdlhnjsEsYPkYkqxIvwzMdlIPNKX1EcqD4sI30OvTgb5ylYFjqPCnOOIoYlolTZ-diPKVehHbRJ1Dne_jnM-Z5XJlZ8C1xp865VxMUWAMqcCIaUIb8jq3sgIXQ0Evsw6HsfldVRnBFOxkqVEHEv6VtPBKmqqthubIcTE2rGAi0PWJ99Af2fPmP5Nb8UfmPxzi350EC5Ud50HpCCY0jUQsUL8ARPSSt6oqe1y8cfc5n0ymzaCz-Gr1wsVDimWusMVwDwciieM3fL36ffsX4WYz0D0rYBySvmsWAqbOWtdjPgg_28cVIRElnpkfwIvNUsfYw3lK_RtzdlpcjfvGqvRcpZrm3dSKsT2YogAPoA3XQTG_eoQl6zqJ9WCDYqnayiQdhzUILjyE1Dt70FYM-dHIzYTQwP5eto27gGYPDT5PruOQk22H2Wls8UXX5fJaMpY2cuOw0apPv3dXUtXC5yzzT7I6GpporMfaF2YHw-uMLgzRZKIwxpZU0OC5EQePtrZVWYNy_OtA2BNEsBQl8Ls1MA-nSpe91ki6mT70vsSRa3ynYLgD-R5SsRY_pHPdxq-Ps6vLyvTQH1lhOVVD_nbwvIDvGTR45ddKqomVKNBdagx8ahvJ0pDkwhZyk-W39BQJEoQBU9nRBmRn52zFtGH1zoiQ40K_1p8H-eOS8zDRxky7Myx1uEZLYYEUC_sEiiHA3Ywc3vxqUeogS_S7ulvdMDSOmwwk_NXbjd1pvF6ujp9XdFUql0yDXOS0xYdi0I8-K5t5gJrdhbmybSIR02HV0xJvYZPq0vxzYwUVsCdw61wcnQX_c8SsiNE7VgZJSbPwRQOGC2vUyo1j_VmDTrfM-rcIV47Nc9AzCtd60eb_bbia-PFDzW-acle8PyDuQkgTSYqKd0zfBgItcO9MI4H_5LPx8qgbEHruFEKjuJ6Cs1DIjXluuRKV1LOG9fe2ZVp9SEcUE7-M_yifA2cHyKfALRdKvFdporvTkwApxc_5FkHjAsEprPFZ-SWllJgjqk3mWlvWuSu3jOmZNCK1a03T2layTmRn7M9WjCSirPXRnhd2Xn8nVu1y0OEkiE034fO7Qwcg95vHWm6z4bbLPnglI2QvLxIjFLP6z7nETKv4ErNqLYSpoxUVuxNdlna9A6EmpRJwgAvOJta1mOlW11aOzXlK2EqXt6woO6H1VCQTuGbT1zFNqJV1jkTYf0QQctmg-hJ--ct0qQcNQh95VLiW3RwMYgBbT__RMSDX7PnA-f0w8FApvw8B-LAHiYgN5d5j7Di7XHwkDtrS-Hn5cTHbZ6Sy4TzgrsgYBuEGFxRKJh3_EsbWdh764Ecl3yyfAmaSIbWsoxOcxccqDuFqa97IHo6Qi_hDlj3UIEgvujF4Bao60UArYpbkmhViBXMh6fJ5Y85naPkcqQFITKS5xMnucgPOWKKRXJu3bYRcUdRZskSRkgAae15KLB29dtj_vUw1U7gnnSTpHf3sQXHP96zsYs0PerZveyE0qWmyc6Fyfr8IjAca9LLGD0NZI6ACFKKCRexkmuovc1JvbF6HEITcbUtdQHiCUWcmeI_9QYM8VT1BTEHjMecEcb1AKbSe_GdL7nVKV9OBhTT74kt-iCAD_ahyhIbT1ugsllZmHHh4Er7tPGkY3aYhX43OYmyAHXTZf3jeZnBhavWkWv7u1pvZLYxg2-ZtcntwgkFVphrFRl7Hy6F2-aQse3bgC5v6mopojw_lAG0xvnD8keAOH1I1DF7KiJxJ-KJMaqnyLtFKkh3jq9KfCqMpe_OgPS0OnLof89J5hVM1e90sinyqZzl154icm6wwCgftZi8EAbJeQyFeyjLk-X5SGWS4tnb6WBNPrkCYtPlv4CZjcLkfs3EonX2v7KBO9YAgF0Xc_iGKrg8UsYw5rZjI-0C8Kv3CQp6QCjJM4kfvA9HQPEmmgevhiBPWGdCGcaK9zfdNECVa7h9tt0ZTpEiMoKr55M0YecUMZlwjfINAoyxAxVmgo4YxJlgbMj9Yo3PcqNUKigeTFCTNldJGibLklV-5IJ4lH3NBW-6ojh-dGxZuX2BXN5HtEIj_awkFdJrRVa7JdYRNKTYumcZ_R16rpkk7weRORG30-q-PTN-spbTNk9qqeCrm8WtQoGlJsgEUyW4VR_Dv37fq-6-LjhV4FkW9OYaULwKgjZoNBIa4PogvkZuI_VWzbwAhptebw9n4zxI_wGbeUW4QTYTBgHAdRYaBJCPzU15qSEymgwxGazdgPUsw2AeWbvY_da1eq0yq6lsc9Ac6VW-s-NNNkk_tDP2XcuTHZYX-oDNml_ULD31YpDHnQrXfBklsVUOYamVXxYq5vEYAVZgxkZ9VQQjvAqlAANZQCZ46kPHN19hG084Xukq3TtH8RS5BpBmxmo-3TCZUECSylmzlLdJ5cAUSThKlVAat-AdQ2YvcS1xYMggUKL6CCHMIRT_DFSnsWCwZl9N_h89kpsYVg6K17pNlx38MPWFBPGADnR4UrpQvWefevETbezO1oeXl8c9LHiEysjXoAfjK7jFdjhXm92O57CUtkf_-D_KM_6MJqfCjFLH5nM59o4gSNfbsDvttltVIklnoqQjTtFv6gJNQNNcbhXgYqrKJoLjKAN83wfHJVQGF5JSlrsIBh-FQptZPYxiEB4aknmMgzoaHp-hdm2zv1fZyrZZpydW6uycOvBM5MqVNTY7JWWpo9_mvgeKR-HAUruBwglZ8pVabatmgWcVkGpEsRs-ULpnTrL59Lph34bniEPAfzTmMXr0Pk66epmvsPFrpJkBHZtWNw_sDmFHCGmdfUQoNjRhjxjEMgxf0uP0lD6pTwh4SrNBZcu7Tg48P6kH8ONgSQu699FKSFmP6ptFyIoBFt_f7MLuwg8jHKdwXZvhweshxp_a2cgiV_LtsZBtJGDKtao62ggSGYly0gdX9xBsxCoiSFWY-rfKcR6olWC-331qKbOc0_EQEZDcNBAm6WAq7NHBLtZlC4O32SIeZLTX5TUBu274SEww6hdNF0dL7en-EdhspXezG4jKRyheviN8foE_QAq_paSGMyzQzfxLTyNEoRAYq_R7_s6Ion2FY8KtIACZMAORP2n1lS_hVDpzu1-7JoJw8f8FygZnGwffKqL2XWgqyiTHeaa9NVeutnxhr1fHpVaNBKh05CSHbEW1eTgC6xlBTbOwCOBg3dmaQa0e3gAM554Bkr8aqS0auz_2z6H6Q3nhLad5KJzFlN6pJlUBKiMF4gxhalPXdMEKZSwyMjaYPqdKS9svPUI6WJkgdpElg4hG79fB3H1DV6UVgHPALF4NipN-xo_swOEivLUtqr_oBrfmRmYO7k5UDlZQbXjcIbzxNEKfEer-RW0weFB2u_8uJbEEN89HRmPouHSgtMQFfG-Z0I02VBuKFzI8PnZPmd9ImG33nrQYUCCi-oyOELkZJiFw595FYZEnheOpKHRiK3X4a0W3uDJts98qHMZ8Zb6w-gwKUoGwrI4MHXu5hhP_3btyDhXBq4eBi5o5X0594SKHvY02F1KmJ3BsE2_V8kd0UsBXsfLiPURsTJzDT5UqgMTXktBNSNqKKWxCPGB74P6pi29MWgaQyw7UQNhsTCO8-fqYYe47ENXBc8GYQ-EocMvaGUQBkEFy7-CAeb_wY4B55Sh2DdFik3nSmc7uYUzjY-pSCKzJ0NUxgLP61d8PjQCO7UJVK4crAwvF25GYTxobI43eyCnkePbDu1K3c98tkR51WWqIT5s_uOPM6pZjZPIkj1FliUFr-ygN0AtihYza1JMKG6Hh38buUbYWxWyrEmGP6SxdnNEngrkaPWO79Nt_29ytrdDS_Jbpcd4I8zx7MOhSbuPy3T8Xz9NvAMnAB1a2lpi79rvkOWdAJeHnRKv4pKsqhHjYX9m24bgiv-wRolUO2H18SYpqQJnM_XwzchzggZXIi7I4YN3j6qBX3puPEM1DNVYh6vZtMeTZkF8rydXPxD0vu6eH5MrL08d4Kir6jhEZ7KirT1G2nnvFf5QSSSx8nXlIVU93BYimlevV1fJ7SGoH6vhpwdClLtt5aBnKt2bx380QdVHGv6b4xQi9WSJ8rCyynY4BGjXYtNfRxiXvtuSMm_fXgQGKXfs7zQ97VLJV2VQ28JO8XmNTtBawje5ZNeDrgIu3LcRA0eZttKpX6v7znYeCUIctjOBlWJFnCp-crhGjBjoKtoNHNpZ18TjgqV9UdUcsg748dNvFtwxamCKJ8Ndaf3ITFac5sdoYH_GBMR6493lp4cQMMABFk_YH1_Dfgd6DRhBYkj2ahKFmHuFvClRf9BTCUmh6jGw-TzAWTByv2xuCl55HjN6VsyszOkrzAJov1GWZU6Ef3fhj1ElcH9AGhsAfxNKQk2sVl5HzeAA6EsJ0GSceQQTCbWueYqSOMzUEo_xmHizAggUXE6JWu00QG6L2lbCBSG6WOiJWtICFAemsOUEo7C0EtyG4cCclcbAdvHJ4Wk8D91ZIkb1C1XGa_OpgJ3fzoj4giEcJ4rzaplIJD6PYk28brvBLWbX98Wb2Xyi67ykEAsqHXOVnFT4BvBGNwkGJNrhvvCroZJYYtLdSewTPqV805xcj1lfypfgm1bKb-vbizii2f2hnwWGcc_eRuk5A5r1SMAW82Li6I1vYZpERtDkwvXHAgvFRYQZzMq-RbEdt4Mrbb5LQXOFtIBdbmO9N0hpdDQmKZlRkOsyRrgXYlJ0KnTpKp2uS63Torc_u8HmvUI7zxNVoAi4xud8w89d3K_EaucXwwu8OOK1c_hSM9GZQti2KuW9aaCjcI_J4FQR72KHrZW8xOFidJiWQfxXQpVOHLBpru20AkDPF-Gt5Rj65ZADAt8AKsffe4TzF3hAQno5zcGOffuSYY2WeKt3hOqV7H0VRXW_hrLnbipk_pB4pQ0pPKYzqKrJftiGp57UeruWMkjbPwVV_RvkVgtSrhegVX0IcVZt9dChTc03pgBzTK01tZ-UavAs3AKOQ2Thv8q4r8Orc_v_V9C4rVDrl_ncGsJYQx60JHSysqBBxTbxcuITMUJBOOQJYBQrwgAxHEZILnomFo2G9g6lVJuif_jWRz9bZS4XT4q2NNejVjAmpdUefG_hPjHIGegLQeybdfzovAKA9yUGbMG4E2Javx4zm8ocCD_pbZNEJGEzw-_uC_PM8kIGD1n6wV3LEpkzhR4I9PcnUFXMd9R3an-kI6-DyK43TYCIYvxUnIogkchusit8-dDGiaAoBBN_5LNXWc-nl2Yztsy109UapJo_sM8eTMmjJXvctqFdXaCCLl7Oso7_MwMBq2q_DMYh0wjG2n2rkVAyuz24JN9UH8-mjPIPlVqzgObB4658e_IAPKlfZApHT27HHqtRhW7HWyRivj5g5vKlUylyd5vAAKgmjvti6e1EMmJ2w3eGzuS_dn9l685R34ESX8UTtgg52aTd5omqysAwwrN7PKRm7-ier3EwM3N-gDFhR9jT3sOKk3WlSnaeu5YiuUAAWjnH5vXp-Xq8aW3Ej8soVRWlwkqsEiTW70DLDYgUEtG17FmZT0YQ7XhEgAZkKeDPhJhKpLoIbiC53YVYZQ7CEP1DOAu5aoWcTL_JnSx8ZB9qriRRmJid-dlgYu1ce0SkKPA0mHpXctKhbaFU6Jcmm1nIvN9prJh3dJKcJzVqJHGG-rxd7ul9pm4UsL2zhfgQ5KnJ1-FUMAutKzRAZOIbdkUsCwaDq0n4AHo7N5urcNr1mpXROfYb3eaO4hIM1KwmydxDU8OxJ0eKLS_MGk_EL5FPz2CI1hBr3aPGqn-i7wmIje1A0yh4J9XfL4oEWkA8eXuJUQihF5G5jrxSYV63YgYfRl8q_cj6cPLLPWfQYZaAmfOYh29kD9nyH_78FMvcJ7GqJqXTPanodcfgb04-Oh_fHORjwXMG3ZY8aQ-68X2G7HejmlTNU6pX_4cl0hfjqTMdQnN1OyTMB2TStOwJcvyAURblCY73EnMZ3rArB5IF3hSd9u_7OoctOZEuZskSKr9VZ-30lAnDzs5GjFee7hana98esJ1muIelLByYOafwOq-DlsDMECwY_gByINeK2uPZ0Gdd5PBuq4WXswC84O5v48m3ioGDRO0wfAemfKYFPWcCyBINHAXKIHdRBzOEIMh0XjNCV5b55ZvRlPtUjUgOREHbXSyMBa0CQuEQh9hN6QBVVAMX9qM2Iy8Jp_yU4Z4LhCJZ2UGGxJbjKDabFLz46PcWjYhARK0s0O4j5IoHG9x2tQXShpP7VqQML6dxwGmciTI_z8bZKbcH6526tjh4neVbroGe-lLeW-cRPTPkQyYd6kFEFHiLqDpAnX_QCXI8gA8_Env4HrrRBvfVF_ITRcr_Dgh5x7dEsKlpcZYmPOvY-JrEpurnMWhyNWnCKCeLd616HnBnf6xkRfKjsEs_Ais0mHDnBRAK8JWxWD_QtFVYJJ2yfCds3KT2J47fhbjRWk5GJdQ5vXGP2ScMd1PWlql-afYZTix_6si5uMe0ayyHu5KAvzX6zjHWRnuuoN07LLuTYRwQVzbPohCCb9WlcdtxRuarf7HVn5rfUUVJp5xDCFWiWfkviLNggLKiBp5ZBU1y0q8W4pgqjXZ5Vz9P-yZ7Mj1VdtOtKAoqk92LV7R3V7vw_ZLX-Lx2kgxYt3whiWuGHe_GHEuLdfJB0VTSOFwIdD9q6-Dk5nZ_kU8gg58jyuxc9ZsKzD8UGyf97t3skmOo-6ZOIYeBkMewmQksAOAZ6KhPB7DARkY9emjClcaqdZhthkh5FMjkMwmhvCZI_36s_iSWC2bg \ No newline at end of file diff --git a/backups/backup_v2docker_20250608_200224_encrypted.sql.gz.enc b/backups/backup_v2docker_20250608_200224_encrypted.sql.gz.enc deleted file mode 100644 index 35de0e3..0000000 --- a/backups/backup_v2docker_20250608_200224_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoRdAwCQUOlw7zhwRLq8LG2ktZxVwQcoKA-TnAcaAOGUTSxZiBdvUbDWB_2CQHcipz-ajGra03ksX3k96ysDT2wKSImL3fIsxtKl5y9YtcBwmJV2F3ENpAlQonFliHmNQJHOuA44t-nRro9oCgBjzuwJiSJSDvQMiJDmR8Ap63sOj1gHeCWI4Kl2khCfqNfalJymmfAyPj2wP7VTt0JIiws7kUO1qiWZUVRReD4pZEEk2Pzs5AO6pfRWAi_gQ3MxofxVHhNPYGeF4Byklm7LBKbWWCJUc02EZBYV27nGduxX4PUP4DjCsI3uH1Qb0xySa6yBt8x-U4MmmOfFq9VRllaFJDndQDfkZJgiEqn4klMihnlGxmm9wS_qELnl8iA2mr2lRp0nW4g5u_QrPx5Dr-JCV5jCHzdd26cVoX9bzPYGTaezUhLdGRiC0Iwu8rOglpBcqFQ74nOR_b2DHq6eHwsTGBnJfl7IVEhEVMX5R3CvrwfppjvhXhibipCZnfP1-DsgjXDrwJs5CrkkGlxk0kcLve2rcm7gDjLnKaTu_WNMntnufDpwRwNQuSutefPqalUwiqw14oE20sMwdG8gOqFp7pPi-sZcLZKSBAXxE51-BfO9QS963eZ982KTJzFV_YAXIK-AK9g8YtNwNd9E86Ee40HDhKu1OfbAVQDZKGlGfVh_fgTSYrkNQ9P4B0g-Chrs4nvlSQFjhJyddlojNVYLl-RjXcNqYg9_MxIawVS5xwFvNW_HgjV_r7fWRd5YMObDgA1cgmoRkA9ewSaTMv2SyeOfktuvG3eK51N2Iui2FcxwGoKKTps8Vp9KFJYQzLc05ZaYPH-YwOx7sXMQXbKA1BWb4OYPYUlFwNr_64C-YruRSODF4ZKUhBlY4a2KAeksStGJCB1lmfLD1fHdMZml1ckfXzdyz7CuArfU6jXLFTzTdyJ1lfHFYduEce1RifRkNc1ZjljCfbxv4wCMF-LqjF592YncXrIT0SrYW_IlcWiWOvkxupTLMl8IsnbZL4R4C30xw6CEWll5byRJkLV61bFLmHD7m99EoFJGJV9K50yywzxzd8VkfWr_Yxxd-oVKtM2LlWbkjtrRn8xlmwWj_Fi_KbnoLJN-UQwG5JQoekQC_8bivRmAeHbUeaIfhIRWD9MM8bB6qPVJKBUI0d6H9DyD0XFPKr0bl-7jBfrNFUZOTeND84Xje3p-WSn1cPHqiqtyujHsSQhZ4DqABEb1mLGaCuGIB-3usY56-J4SA6dLuwfZBa5ST2IR1rAQetW_1ecictfJ31sC8Qj490WFEMWxgoDXjd8bZJ-Dhdgwp8r7qyY23frFplxfH_ashxV-99NCtyxP5ggrJHA8QLpfu2U9ydMHJdVgWUprICritbe6VxOw-UTGydJOWKBQx1mDzzUyGEL4vxFI3O1IOKdxRVDRzz5FmwutwGUytySKcDbMou7oiIi8GDPsQCuKGB2OCeepeMSkNP5K4yiIN_Nez10kyUKYWkcPwW8lQOOuqIkTT8kzAv-VCyEipeZwXoNnk37EWpE_wDyXNFiATPznXXS27wLnVQJ4-sjFBHRaDA1XCKbv67u1jyZpHrzPb3SJPPZs1pzKohVSVogdIDzoxEmxV5SgbHa8P8gtjcihg5K0qQDUVRvBf5ACogoHArpx5MU-PZu7BNqB0gTqdTuSAEet_ksFqrRjgmu5J5e4_tKUlrkN1wfR9mGWOFtTVSwOK8mNTZ9RuWDnKk6xy32ywtcY51GlLVG6SV-syoMed330xolP_NsBEqqII6MZFZrO4ZHpr86GS2JVJ0WP2IXoYbYP5mz7kaV5ei9iGpfh_PTSgAg3AaqdOLv3PJtu1ybhZ1zfsQPWkcexO1lFFirBCazm8O0aPGYKuJe0D3chLPIbLTesUa7PZ-LQuHXSHYIs-uP6-CRz32oHgioBcvZ8fyQcNn5Ixb4Wa-cyX6QqaYogeFZKx-RUZvZ8AELjt6LN9oWeA0fX5RIbO-FjsLs-tbXVT-gRwlY0IszsQopJBXHtdPD7KM6F0kTMo9ReKWikBzu36LlI-X1NI2Fx5eX-hSV_ZAid_Qiawqo-U57VCgXIgZUgTO5TkJO18ywfWw9TvOeI-qBbokIid6TED-wTT31pwMw_u7HDTN0Ih0P4UhWZq7ZTFnul32447F_RjOJ9h-A11lhBxI3yG0RxG9INnYB56zdCF2dMcz9CVQ2D1abB_0aFciE6WiOw3AueQ0QffHw3ffosJKT75grd3jn-ykFOfX5ceKXxJ-nioCggM53OhX495CgpaigwRf5zTHTCBncIAY7BpGdKwrHbXXSWJmpPKP_0yGsCTg14RFjKcM4r56qbqgkqpMHVMAYGlQPYyISjUb5pubB5sEBozpcM3Bg_rhup1MpphXADr-zJE5G9l0-5oMqqKZP-uALOQIwD2r4DHxr8RpEV01acWmp-zp2UrSd43H52btWEjkzpOA5_NgPaS3f-Z0CZNGLZtN73Q0L7SyrYBBF5d0XEDSh49y2QYga9vqynug9tPq_GqhlzmJ_hKGOEG3cNaqAeYQQMp9fyGKc238fkDt60rmkG_vnKAn5z9JLoEeRJoSVxJK44RxOoeAgWzKLdSgpJ6kxYqqMYbbt1oMuJnhW80gC2UUxKrPgzchv8iIq8cVGejFCJVcdwIua_9GR9p0alTFiJPyp3K_3Ss2OZkSxh2fUMnArzsZHLzBY1a3OhuEdE8e6uhhVBqCrt2DNCVvyPqv5yx6PJKTcIepdI6KsBiEywMxHdNtAXjum16KLnkdJqjHhQDgbLY0iuVIDVB1Eq1fUnvS8jCy2IpBNEDtesjzG9MienNZd6W_RE5pFx63w6b-eeq2ZKDAV7QU6SnDOEK0n_jCVk6a3eDkm2rqFSvxjRu7hh0Riwr5FMoRp-_MIPjyTCD4J1bQDQUD83caw8ZNvqLDsjF5qW6LSxgPJAibFu0FiKcp7iRr-EqBLiIOJ6Y1Hu5y07cjPMfvwM72Xf89xYdtBD6CS9MhE5j79S0t7aY6HDsnpVdHM9luAzcxh--rPi10lhHS3apyZEQrAYa9s3-CMtleda6BbirAinvWUaV8aBkUtO57XBBJaQ8RpxMmdly7BjAnLOgJZQCigFmNxcl0heeUPIE7l5qtdgmPOxPYa-37TEbpRA2MKlHON2WEMyfcT3Or5wIoBnfNuBAZLQr7aZiMARknD7JJ1G_EuKw9_DJJwpjEr-pRQ-LAi4V42CzUxYDnAWi3KXJRydfqeXIjk-MbKEcNcolg_jqJlo_o4htV02NfqwsKteUSv4qCvxRMxTdxi0EAsB0MMg-lZPo4cCtH0fnneO5y3s7ig7wp-LxoN6fsJofl4iSq4hvp3k3cpi9Uw1wRDEYdoPeptv3bENxryCTgaLF9B22b22-9ABFL-UVFqApr7VN8xSA8154KgU5h9Aby-wYnR4apVDk_Fm-vfZoW7bmHUtYtpvPzpELxgnUkEbhJloGj1epFsQnA4zOkhyXipGbrX67ND8H0e84tCwUezkRKWIBi_pJ7i1QRup6R2qiGgzE0-wiRShlN0CHiHxwRtNRbrbuVo3dbYCLcezshCJz01AW7YJEMJ8Vgx5A5f64zmh2uqqaxCTduPGY851439fZUbJ2AaSVZPvTzqDDUtmUFeqbbjSwQDrFyRuFHroFTAwkDg-B1foSOIfAL94svi0lXMN3oi2gXkWvyTG4wOuXHCXvX_lqbDLVq9h633XJVPVcTqcLxMKUOY77wuLE3vxxYVIcM_gDafb7RNpNsZltQ0aTrwakbDTZ2-N8OMb--qymvP9ybxogmkvglCj8uhIBCkFsMKX-mElEZnCiBFOWXX1azNVhoqIn2fQ3KF-YZDak3Y00ftn-o6FItvSMSjnNnIcY1eFjHRcLALfBCF_BmC159Dwqhij9VV89YcPKYTBDa5TsUoQVZtHcH4HKCjxjWD0ZpSGaU52ioezPWeggbBXbM7vrfgQ4rOlDIv98qpbu67Uq3JhqyWYIQZMrEp3qhGhDlmb5-9WzYfMPChu6jMcMr7qKxpLtFcHSEbr5L_kAnntVdT4M54T7eMs62goGFClp79xgPhdODTQndx5_D4Nx7f8kUMcdCniGdrEIMbBssqFubkCE9pvP575s52Z2NToZVm6V3zXwStN_2guzAr3H2wAnv7o-Ry1YTpdY7gOv343HIVHOO-_RihOo_TZT0eyEhgnpY6reuMYMT-yVO56dZPzTdEB2rWKyppaTjOGhyBrALfB5zv9gn4zw0nJ5LYiDZycTs52sxXDOosw1FZznRP2lc--X2QvkBDzqL5fnRGrKT8wbhQul_QX-ADWf6C0XKy1WhYzkLoypuEdZ87LMPeOAshvz7Wv0zvsO77UvrCc6QkptFO9kGvShzFKIU_hdcjTaX0BYCePkmcE4Hbfw96QiqbTutXTF3RJwHU9z9egIRecWK_pFsmfosYel2LXoSFfCKKYaoenQCHZBc3Tg4luPqQcNop6Xgqxm0_PN6xVw8wzxlATnxxU_EbvuegCa83I4Sjh4mOlenaD__Ab68wvzlPM8mZ5Fmofy35FZ0WJonEK6q6daUGMJKfyaRO3-9Wus17ZBcP5hyi8YO187s0OnJme6LKhfuFYIkTJuiDx_IUaSTO9NyLLmKrguUOAgLzvSURamXyfWX2HzNwR1dbmhxSWeNH6xcoadsAOitq3dk91GJOreTGJSphZyI0LNqqYBzSsK78I88LrCLhnmBXd0qRHqGUl0rD4EMfzl1uZ4PPa9IiXIHqrFsLCnNF2Y3mDBp2Utb_BKoFym69lREZWTK67moQO4k6FxgwirXtHTcgl51Ra-kz3vjzIVaLOtVsuiOCkbH7iDwSyjOHQtbfv4AjQf-v1F-z-WBVv-xrV-9bxp8xzqgQhxgVzkqCF9BEP7h_0WPBvC5Bqwu2xemxTYdkqluHhgFEy7yOX_3nzpLv_Ja9AmqZM7uG_MqPJXo11uoSS6nXLz6TRP5Lo6DA-oAWG-vpoPxvEVJYJv_iCoHtuQBa4kWRRukn5l0o5Cg4ITnJCEwSRih4jf1my7bjTNCytXrN6hvBy56Hizx8T52iZxZ7sNjOQxRX3LYUQ0G6EpawtmYaX9SG2JFijb78WCPk-2AuZRMtmBiQOXXvq8M5H929T4F29AEorWy5_CGrcx8LSbYK-Ea5-5diMdBluNlgB4eIe0nPGwmmA4BoNT1S21Pti-ZmG8zt88W_3JmGCcMSqVzTsX03IQ3jHXI5NbonIqNdacVzcoY7xDbsY1htpKd1kmf6YgmyNu8DSrh9IweYqny5-7NwLOddw2HDwcKlD8gjokLU-PEfFN3gWAzfI3A_sXvrysseCkO2RKYbe0zzMdbNp8uX7Gd054Hz41H4D98vLPPlLyS8vFlsh0PKjcormAijC9IEcgLy0lf_JZ095uUnzrX4_asai193HC-DjgVNhVrepAdh_XpT43RD3mUvS7Dg9AiKxnl-gH6BHYUAgMVY6wrBibwNUfH-96rgjaKh8Gwo1S_-q9vO_2-HllXcLAc4IJ6OWjXv1m_cy2JEcJ1-Vlwml03P4G8WKpGJINAG7iWFBKFvDnEYqxBQ4c7kXAvLfpGDkV27TjuY0NLuZTLRsrK60-PESJbM92_TjYROR1oMoIO8OvpnXLwgLMNo4e7ch0tWE6jeynLHHk_LL_LrvSarx3nIqb3RfDy527UeiCdrs2VQLMEVFy7G6nemDLDE6HbYwfOjr3gc9IYzfrImyj5jzea8D9cWh_C3QLtKcu202jo5xbn89YMJAee2940z7s7JZKiBtyvvyXTw8CozxpjWBK9Wi6eFdRN1mEN14mdsU9xJakEZ7BBA3Iub7P3x-PHNclwJP2nE1lv4rjjR3577pATN3DDKVkh1MwRmA_WGUFL5dCHcHCuTBNA9YTo0hfI1BMKvGTByG2ytw7vhrMOv8y2wxmMkYUj9IC2Dpn0Vro25FPj4ATOepZu2QAgSkgIni5GjhSiztc1OWex-2AJoKmFefgcaqZkCTv8D6ep0HkeSnKSJ4yr3LUbmrY4hw9e3y5lVCBmP-wkvBUnwaPVnfs78JFYeDDENFJfInxSMfvrljm1yyABv0GoNqGTVRMJ3W_QiK3PYDBK-E8mermfOyxA5nOk_xcJ6qMSCe18VgkgHnrn6z0sVGlUepTwueTORfo0aphVblZbKVhzCH-8ywPI84TiknGFcQcLYAhF8TODrINHtkj6ldpeDI9ry6TViExUWvtbT5IDt6PaK7Pbuqnxbxb1qQYD-T3qi_KjGTQHh-NcfhWB8Q4Bb1ORIHS02YUkWH_xRP-J3IRZtGNwUfSpLONrYpRcerW847dhGoAqUiJDWu35iLlwNkQrixaN1JWNKdCwL0pM2_ysBRVk81fD0LqZvCQrYemL6V1QnnDSsDXyTAnl6de9E1IxOP8SxJVmqwBpKf3PX6FlgAgKQ463orghNw4peKzyidoW5ZB7c4bgIwoz0ccJIWmtAv-9Mdo--I7zW_EXPFMrTlu76aaJdGFxwbAk-LEDxoCjwI6Y99IThv8mN1W1eVasjsRbRG8-D47cDJIpls0yAjHEiWs4TdlJ0ZIDIkPbHpS2mhvkH-jMGL4X3Gqx80Hz8E-7BOehqXuYUqMqlY0eYdRyMgDE4xXugeZnbaaI5bOGS038isXQ8rhN2lJ1reJH88THxVuWgMyoMjeBqMr4lR7hxkRcCexHvtrlakGlTStHg-yfInv3oNLj9NmvLgq8v8azkEKSZIfi1KeXESwnNLVT1leFDCtSjU0byi2am7XMht7RUBuyV36vG9V94aKozLnFF2vA0tG-d9o8zogW7F167V1QBhYqXumdmMyRhcnImQ_VD6ZkI2RU6wrZHsZixx3iInicPzYPVsyhRm8rdx548mD3Uzpgd3jDTqumYcynIYASM9fqs8LjG_uLcTqw-Kxy03itZkBaz35ZucVu-nfxDP0U8vSh2KZm9Bwj-q_qoz5J_9UuMMJOtDyZZYAbOh4o_d8t1ZvOnO50dFxIIj529zjmJDupGyFVXD3B4Iq_P7C-kBcNSAyyW-RcAWIdtAZTkbZO2ZUwWnugBXDffqAVLlcvIVuxJESOh-eTSYfeLT9i9LptszYBqSvFZurO-J4dDn_jbnwHVk9dvBWZjOFxnlngcBz99jb_Z1Y-cOFnlm6-GfL8BPOs8kuIVonlaJS8LepSUqEdu1LCV6OY-ElVPlJwgvlUsjgoCgFMfTYT1_e3KXcZuKbnZPcKvoygznd5WJTpHUD2UlE7U4k1-Kz_hYL2O7kr4QF5du5j5E0VxiJ_rPp7Q5yQ6h49TAmyXFR8sKTEszOUe5bSNkWukktuQa1DpH_T7Zu6mo7NHY-JJRZZsmntNKh9dt0DHqualT-NAopTU7HE_UPJC_ZoAqww_dtCrgvxcsg0VD4yTub_xrqrUanZddNJQD3-f6Gqc-hXcEEck-3mfs3LLDwMDh-23BAoVk7RaYwk7taxIyq49cQOikasb1mz8-habTimcjoNPj4samQepR4nRDNECNX61OqZXkntnUgDlxVrfov4UTEDdCBW-91ISOU8ZCbjAcFcPa9Or3kt5U0fjALQpbR12OLZw3hhkqCG6s_MaEsRo6n3vo25GS516_cuMNqEJZTaxa0IniX9JzjoZKaZhih3nFbqpTs5CIxn_mQUvKK6H40hvqvOgl1xEY0n1tP9Z6VyExU97PbSfaCxp2QnDOjmceBNkXEj-SswJxzfDI-tBwhWukXnNHg0NaL5YadFKdaA76co8b-3lrn2xtXRe0g65eGwJiHpUbrHcCxiXVBvs_XTUuqpGQwmqrQ= \ No newline at end of file diff --git a/backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc b/backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc deleted file mode 100644 index 744d470..0000000 --- a/backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoUGzasqXT7Upc4hUrQTruf_2ZAPYIDCdISy7YTEjzQjNp6RBW5SieY0hHfBZmgXYm15qhnf-LkI2_DGxHMh7Z5UAE7U4o50bxwL3Oe8muv-9lS9TSsIua4T4ULfFwfR_H0_-Qd0gO4qTOg8tuQoHT4nHKygejQwM9aMXFtPnS7nlxqJFJx_TmJvSQBTTR7Z3pCFsGQbvAI6HrnW-66s8a_2AM7gHodrgbf4j2Y3jTaDnjFSawnu0zvZP6nnn8t1OMnmKGw1z43eXdVraY6kA8-biaB4hXNJrctbFz23cGuZzA8paeF88n1QDuLMmgktUvZBMKN25JxWljZnMCtLB1AUa0lp_cmcJ8F1ui9z0sB0j3MBIXyGsJVxVS-1ssR3e6QrXR8KwYc_wE4AjcJ0R_W-5i1teZVJjotFF2REgiwZqtK8-F053-1R4y4q6Jc3zv-h4PGUMsWPRoKbvppzavNiGuig4-Y63YWLY5FwzR0LUOvxW4xMfMJARVEoq2YzRk5f4KWAHorlPicVGGe2UZrsouEq1kBOG79Trhb54lZqKqO4RgJQNZ2y0NEiMBk7qHelw5FQ6CWoGbtJ6tqt9gjMg5yFxlGZt0OJtveZEGfqROffnNiXx1XVuyPVCuIKh745cU8mhCiF-tDxiElDUMn59Zisi4COh1vPEOLwou9kkd30Thz5SOiQNOs-njtB9UYcGAUGilXTO_nDW74u27FTtHAN21GnD7RfGOKBS0eJ6Ge8GCGRDWO2Z8uq1jwC53MYO8e_0caysfKEtBvdvsLOdxCh80WbgpBWI2l5kD5nDJenP-TeOic4kq_kj4btAgYcMw3vf_OatFq-eVnhDGnnN9NoEti-_eTi6pOjOXJcTRXt6tI7d86DYfa3gif-hMgCVblPrIf4nTTbsLN_UcNhdiCIqJGlw_OlZrpquFIK5YKlyq2gqP7bIcJJWTM0usl7KZBNqMA5Xk-3WbEuKWlTJs3MkUzKjlk_6Ak7MmGuDBNPOmJ6J2HCWCeHEl2SeA8JQOAwawyeDYUnqDFosU2uN-wU0jxA9sBPnh0tik3tJikO3mQL8HA6_WrgW85vCHebZjdagIETzeo28Jm_zL2LTdImUK9khqDLGyk5lwH6crtcFlxDW4yAQHntr8Bqlu8UM2e8VypDhXZCd8jt-ANcgh1jmivpw9Wo-iSWvZUvX5tSh0LWtbi4HDz4OXvNspyUu4Zl7guzujuH2UQT4a5jwUsIB0nwW4BhPD40-pAU--hz572QIGUKF4lNGr7_-YTxtd80U2E9nrsXh2go_CF75GKQp52sNNaud2utYSoi_WauxvZMt1CxCoxcX5DPHnem6skk09ICU8c-9f409PyCB0pszr5WE4tfRas1sPR6MckRHgRnKx4ImNitdvi-UUApyRLJK-qxEn_ACkybnIzSzGU9hgesU5XbGF1Ce9NpAfReSQ8PF2We_-JjtON8f2Nm5SFY5wtxCITnIw6sTeTHOemKlJ5qwg3MsEbWUpWLqhahOfs27_Blqgy8tNsS9cq_tUvcwn-IWBdtjBgZeh1jSQQHXF-j-7WFstsbxFBw1JxklH_1Ghq-kYh3BhW7UZPhvfmYT1x6KZYo7qUNTFwH0c5ttJxQf1R2AUAF58XzsA4J0t12w2uH3z_TX8SEDh6f9jt4x0v0hMsyPNbCV9Uqtl-2o-rNW790djzZKdbyd3z-_NuZRh3DDQVoMEDmipgOBO91WWTkvAe22Pe7BcUh1eAkhmIj-YcqwQdLU6W2vnpihRYyJU7pufggJeFaPS4TAc-ReDxJHeLh5tQO0UpWsPB397-5L3zKx41vZ9Nm-JQwOX4N033Gm7HZx3r1UkzOPmDBbPyEm7QOaL_pHpFNCk4WS3Ftl9hKce3-MskiMaPmga7H7CG9zYH8uGTA6eJA6UI3yZVAiTg_0kTP90KZ-hDT57nPI0LNU8af2uZ60jn1RUtcZ8mjRccGI8TDxS9E6u5dOh2wtOGnNXGqPTd7X7BF6RaW-XiYA_xefU15ill-XINokXGcvev7VXOQGhbqckx4o2d-IIDhOwFI9UmyZDVet3VEXlCnInCM-FWCwmhzPWMNWeVut5WAwvm_nmFcj4DrptxfiR6-8hjEPHvJd5KZtHHi_B8akFFTFp59a61PGmJekKr5uPx0EGTEWYYp2U7duhCWkxdnyCITxlhUvOtE_oC7FhRfWS4bCmDc3YCnpmOnkNNPPI8L8ASujsoGeGSC848OYRH9hVJNmw6yRQWu5EeWySxQptRVmaJrC69GfSBcB4nARDz1dFcHjFSkrpx_DEaKCGpUSmJlFk1RKNGxNKNUWU1xodLE7LWz5VpAeiais2bLA05YZCbR-NAEEC-720aLpWVlilbo3T5vs7NRfqNlsv7ZiJHLl-D_mkjquZqg8g8jQOWVNS3eqtcnFZlr7PEHLuXf-54JEgVH3_pNmvO8I4Q0xas1s7I02mQDSRlxrOG0H4yVVsDWDkuTH1YotSiY9aNQtx6cwfrl2tVbLZAQwO0Hpt7z6vqQsEJZQnyELDKij3CUPM3c0XB2W2PWke8xM2q6XLoMhwRBJ7SHQ0NgDhuX_CfAERmLiHGpQbAFXPcbI1GiyrFDet_Sfcnz4DwR-7L-qkMgR3dGbNvbf6XfBCUml8hknn4LxQbp0QoVhr2cCgHFzrExFX5ZYEf9rprIDWhU-MW5Fqex3a0Vm4I_o0l-Uv6tm6I3-9KwE7tNYTvZcBodVk2QPdOZxCNFvdGEB7eO200s5wc676IhUvPsYmMWLqTCFBQp7ULKHtrPVbF_gCx9q8WaDzf6lJrgWgg_bqxUYogdvPzPyJUgWwgaNZu6sEDP3jNQtBxUEzKIagLK83FBcDRjuODURqFq-5kSlLmUftT0dSme9kiIHSod6TtvrNOehHE_TlvgI15qYOOtXdgiKd_d2Egqf8qR2O64m7KGKvUVmK6b9s_K1-zDIiESVaYmxx_WDBYm-5vVzxp1t6eoOVncO_vQHhkOUDNx0kSJWTtdia4HhargbFShBvvokBKnoTqRKSiWYS0dkC09Ji8Bb-GRxns0-wzulBwFZ5pTGJBC80si_0vhbJJ19-1t18L3s4JSXxZ0qeCi7N7PV-JrXBEcbH0q34mUYDLbewRDSfkFiKz-B-82qO3uqhNAgOI3BycqWDNRBea42EYKTDOVdjjK7NBGuI1F9Y87iNGMEwV73cV4VuuNz2fZDt0oCxd8MuIo6IOTd1tZzh5rkncNuf2qCIYzAK6t7FshEDiH2gf1u_gAnOGdElLcIyaPnffc07Eo2FDK4EDcLEqOE5VgZq9FmAUXUy-uetUUU4wkrwYcESfqdlG1IqNjqk5KnmAivaGT0cs5_kSZREc_td_38J0e71ypvN2SIRErkb4JhHMYdOmsua-luH3zm8HBQjZ06xoVCgdvQ_s_teocfMMlXPNLRdBBkBBNODBvuoQaPn4s_BmJNM6lfvkohFqtOReDlKe3HtH-5XCrWzEh_v6lWM5cbWgLX7xsxTsbmt_dV-6AGV-lq60MXxD_xy-nfXDoxwcFUtNnA12WTp9oumaitZvlUFtipPVovCwfvhdaPlNlExLYiEyWhAVMhhlWNr3C4riM0-WettVcfgWfsfPj9th2rIKXTtf19QiDHFqKUpsK2hSUaByRAVp4eVk_FkHmoi_vNqyYIK9QvgufdHmyTNsp_JeUVZzQgsrzqlhQ2ZzBq2zCxkX1OTDuTGAaKlLYbPFUAuLCB7vD3E8g7eNjf0vGjSKymiBkyM2zh2GLRcy6ubCc1E-O97F2Q7AQj9Gz_doCK4WggXZKvFsoWeZIcwnmL4vzm6zYIY7SGqwOODRYBiz5ZOIR2xt4a7G9KYx6yVFSrDoCE07WDimWnWkiFVaNM_2wRs_ajC_eL8zPnz5iiorFJCAW0khJbhhzMkJtVXjAoV2d3dXNuXGtvIQZPqrJ2LSuKfCtTetXRQn1X2s6EkDar5O8o49BjDA318eGSpn1IsdnajO7FFJXylNtBkGltBs0V2jKg7-UO9nblOC-ch0Ih_t77z1LJNbtD3r5-2NJzqUo9s89B0SoA8rNzr72J1P4QRkZ0MmNzhyeKcH4XhrMKqjBIiTOWCzFqHJUauzAMGD3Vntn5uR5vTJF0EV613fTj6uipiPyQuktxKCIQdhF0XZwdsZjv7lTqSL46VKJJ3pFTzU8_6a-DT5Se94Zujk5QcCnCn6zRbFnEaqYPbkopjTCkSj_QecTAg602MhOnxkeR3vV7Se3uNDsndlWYAIM88T0SjHPC-uWYwtB9cy2uMlNGlzIjmG2vh4YlvXfgvO-u2KHOtxS5Govyxgy7wjh1CDS6nRXWFkTxYvpfV6Z90pVilj5hpujU88DwMT-FEMf__gtCKVKPR8FIbl7V9KF7GeTTwFovu-EpqB_JMwrZ04YqJTsiv0NZkpa_bi5youZpWk7KXjfrvPVG9sRn2js-yNcfyZrO3AbC5wE4Pi2ZK4hJAiBF02Odn-JmQZpS1zf6oEyRz7j_5AVcbJ656CrMBvbRF-a2h-M5PxJNSLwDZmbEwSN7j0DArLEa-lJYF15agEfK7MUWIlwlb-zXTjM4WqCMoLFNWkP5dm3de1VsRbu4KiFROrRV2jFArUrk65hcxRSyCaqISQmBGt9lYYpZRbEvttFCwFTnLR4wx4JpFMSorDp84-taCL5PLcUCr5Dg2QgHpVkNRNjMN0GdoT21__HEIyIMF1gOcZ06qDqMXv0w_cKeKwDS62-eSyotLw9k0oGLs6beaDRs7olL2gasIywvuuyCtRqk8fXfHAHhKT4JO5ll1Gb4l0Q1na4CHjePALv7tNIDw3jFtNSYysz6yEMOL_eK5kuAQPlEUUrgYvabfVdMniyHJFVYiP1ST7V0TUzMpfzHqcVT3AXo8tPtZfLa9pTxi85TQiFumisQdTe3bNrW-LlJHhvndx7illzXyX03-HOZyOpqaXXxkg9LtmqhdnMTZ9fbGZMioPjpuVjKFCo23NKg96gvzQM-5dHkvqK5sFdYL0vZWj2-KdQop37aw8UbUeX5KbC_rjw2mtC5VXI6zvtQ3Elv_of8lhxk5afim6W77BLSiNwMcYPqX8qT6BGKlb2NiVUJhCRSaBrfWR64OGYJFYM7UNvwrmdDjWlTauyytqUQZPw9wZpqZ9LeEnDnAdVF7hsEFTQkaLq80YG5d8QjFci6Vdfi839Vr6kIXbiUjRPWTNm_1M-rW1V9qY7ylww8GSo6MqiJ-rGzJzo7gy7yTxsp_WPWC18I8iKAfr-VgLtmcyAu7EWeSoJADuBNqBB4O-MBViA49Bse_x-vYhEFqEyRl7JpwTRQGxLzxEKl2N8FGf3luWZan2EhEKqfhCJOxk0HHj_ke2dwT54eHYjgwfD5I7Nep98yKrUkWzbotnP7IVnfcu6tT7WQqf9pAhV4vUn1GdxmOXQ29dW1NUgZMZBG7S7nh6hcHQQV5OQZTNHWxbOxjuWN_7bRMtVpIIWi9tcPaY2j9d3-40O2pso6GiXK-1RmtPWqZY3SV6hhLAhK70YVvjX6vw7REr-jzp1xk3kH8pD9WfVa6i7mquI41YTF0EfnUb4EUqwTG-XGwW94D94Y3iloMJzijCGOuEMqUEdy0eFBxqqVn0njHOErQe_UjJq1AM19OAnif4UL8Co_nAa6nHzxRDUPA9xKTnN05TjOMJuC3cU7elSBAy0AY1czjMuSTLPlbRUwi1JSy5H7Rg5tgolIWPRYjb31Pwd6-CSMmw24EyxuCC1ylezbprJMEwhytLlTEZfw8v2sNbQb-E8ONXDVyDNFd7Y-I_ypUdodP672IEvmh4WoPWr_XSONC9wZv-MdJLleGEfQJ00GTrbGvDjnLVdOBMAxjQYAQxqD3cWPfq5VB8UbCqPWnOKv7ktVOeafQgTsOFHvS0MgZ6USqkXVKn_rxzwQZz33I1oxAhOXJ1np58zajXQp3QZiAeG8AoMItbuVb0LFvZpkLD4cx5dPol8e1T_jgBkeM820i31Y6a1q_9BqtppQ7myGOr4zk9BUs5BW3Mwt9eMfwz0rWNnzTm0XXV4nR1o0yzH6jElnrOh6uyIOKpVSO9ihAUpEzlCDpp5L-7P6pwn-dPJxnab3VXZnBlmravihjNe-buALduv9SJfpJCA0pFWnSbfAJJGU6h1lhloG7tFEX1vXB99oKkcceYln3k7SD1GffUF9A94Vz05fnDwTCiBtVCtD2pN3oWsIPUyrMIAhlUQ7WR-b7WI3fT8h82PzL6Kz0XHLppnWGU5J6Lmw65scVZBOx3-JCQ48sHU2TKQtTKnMhwSbh0p8U29wP9mwzMyvSk8HNWILC5-24EiOgYS45KofFsaCJvSfixVbY_QFpuxKVsceRs7Zna5QBWMqccq5LPTyge_B1rXR0Prny8RnLpCy6H1o7SJGCtq7fwxg19o1iucg0ydyYO5pPLBXlNFA9DKV6horUsiyUQFacr8MvVik4vTImXSovIeaREjgpK_JrlbEXhVoxotUzlwuqy5JmYRniLQGt7yYBgExwtcw7_gpRyaW-vqdW13sjGFi7ZU_1FWNT-YuEqcjSz7GKvaYF8-_jOeEZ2Oo_bGmJdm6BiFdei3IVBgqmRVXWpQVxzmNAWgb4BAOoFkLqwGJmUJ9AXmh3czQPj_fd7dbYtS-L_zow_g7GPvE9RluEUMSYfjpmkyv4ppIsR_ItvYe6tcpkykxEeNCA8r3xOgogR5DdMiD7JaGZ7h7LPa6vfeiHRiQpI5_yJLI8RKtzMWNTUMS4vikRB87piPOKX5KDYyjZeeT2YMI173TZ-Yq8wF6c0-JCe4huy5FqZBUL5QJG5SYajscyQFocD_WIrMEt8QrTQqP-bdtR0akUHd2ruhBWIv-ToHbiaoGWpGuEzhOWN1gK2S0rPYvNj5HmcqDGYWdVIOl6elzebsOD65AgFYTFPzMe-gKQdzCxiNueaHL-w99_UTMCWd-sWXrzzynANy3KP65Ob_CxCriEwDGfXln6vMtE6I49KMGtmcD-8C5jM_H_n5Xg-DTLR7EXig-Pnl3EQOpt6wZ1kprhAwszafgbjdU9OrezILmHXJu1fKZ1AX08C-TLGEaMbsZ3_WazNDP73k7j84J2DQZZemF0hwvuLOh4Hk0VuO61LXWBGY1js2BXbdlPsd34f9Ioa82-tz_FWZkYYPJKMskRwOlMl6Ni-NnZ5hK1SbvykEtfzH7BCoeyecmx3XcjFnvYv3ZlWxue2AzD-rRDgE3tKYHVvVIEhZwCA9lMcPUdEXpSEQwjim6EPMTYL7pwYoioSD41t5HqG_5WswJYDiibg7WZLJ27d0NITjtuRsL4UHWd849eStzRUJuRAbSZj5eBoiXJhBPHVSO9DbVP6Av-sjP-CyBI-YDlVB3rgJ2JQXm0d2r8GdIXdRbzh_mdO706FzwHYN_hXBTjFFS0Ud-a5FFc0X771qgxp1iOw0fEWND4CsvF9ElKVWiKHyrxnyRtpK4Xj207BO3Oa5rk-qVi3adQY4jyPCk32gSgaaDmlEW83JzXOcwZ0pVeNYFCaalzkokOfYdBOD3Q7wmLh_zUqSvlp8TN_fBcOwqIwrGTRmaCRudXAa07PHfe7YsX5AwAdActecKMHpDB7_TAlX8jfc41f1iNKE8ZjemdpErXe_ew1OP6grq9P0RbMwWQGEJi4Ji2R4oMjsjMEUwUFiMUr4jQmEZOcaUtcPf2ZNIt92F92EyXB7CK8ZwVW4sLmeMr0d5jsGB-PeOhu-AavPxv_lASxGE8Lz8l3hvTM1jtkB8p8Sa9GgIBoC7Bk_mTPy429jx9rSzj6Q2eK8twd9WdrBSYiBYl5-ipayQvWAmqZL2mWwP9-Crf23jdBH6U6RU47pN_bqRRYm3u-cxqaJVt9MbY3T3GZZ9W3czkYJyNBqznHO2k8q2MEoefaxALKUXItygQOxfJ2lWE66mhEu65fBMhk6P5cJ3ty3eXlgqpTLtMKL119rwd9lOuaaiT3nSTq4ngCh0IweR0sUr1R9vUAVGdif5lpT8rxbglII05XjTRP6_lHaZu9vB7A90Hv7SPbhNVxlsiWzCA1JOOCmXyW9WTSEBTZc7I7SpQG0ilxy3CRWy1U4zmfFUn4xs2A-5oUYdWMYRvaF6BzeYZzwbpc1xeXpgMMjJUJIBO8khA0E2PlyaPDujzyecP7iJuQHZADUi38qiK7f8kkiLdg9ShkUETjHwrRGqp4bLUyGknN9YA57tjVYVhQEdCX4ykU4okNFfquJCgy_yOsh57RircO4VedGkqHeD6bczTzOqFqnBhxiv16rQSZFSdpQ2R3TjeLp3M0x-R0qnXx9dZ_iWqW6D2nnsZaslWfZ2sV0NCpTflJK7kWxF46T4iGD8PXSlvxR6Cn7ukROUpRqcJOaQAytcClWSl91tNw8UcczPcYTVJsL9jUbpIcbUUGDFjvl8XdAsVOwpdSijCNmXBQBYXMLMgQsgblm9pAbfxZiH-lzlwSjRHfONEScWKnihXaKi4LBDp5Pd2Izym0-xlwj4tyHCmh5lL6gmI4lW37HziCR-HJqIek34O1rvM2RDmi7MgO84XoANcMJQDvS6vWrv_WYG8_MjK_HEExXSkqrDYeKiW2D8DLFoXXj4P5iKYWOHVAxoO1x26K3pH0ATvNwGzxTPcHpH0BJklzLjqMtwbimSO8KCoya1qWSoldmPVpcj8TW6pAjkxb37c2k2FQJcroTWdcBRwTMHX7jNVrSi94-zZRLiIUHL7SHeyVEc5531bq586qYOzkLnkzlv3ply_BJBod6-qvXDr_0u_PqeZRAUpRdoJRE9D6dJx3DPXtbVUD7qdLMyPVRpSfyC4eZ763rlMFUVVOV_jPSKM324qttpY04vTH1wJGm3-aH5rkz1BvXpmWOlMTIRVLQWSsNNwGeTMPy7PJEzGKir_7flWd_Ct6NzFyF1n_3gH2ggIDyMSNX91z0hT4A4PIGDfTnm2dm0w2vBdN5WD_75ZO6IEyKJMOP9YhYBWn8y-2LiVPZgNVWxPh9uiubM-O5mpBIMO9-QlTf-YX2UvxHcx-pYqYhGfbfJlDTrVvQbIvQzXVmE3HanLag2pPMbQw_9glDmeNlDlHdxvDqY2ZH6RcH5qSa8aucAJZhWmmThNSgkQocpiXPFMBJY6yqlL5vl5zaXqDkNgoDQtr-ojHuWVKg1dEG3_TAFPw3wYXTLCwQvKpMKnjz2bxFVOLfo6zgWTSpbJntXthBaN6cKgiMGDb95k7NtD1Q78cnrkvQ7thWhK652ny4KBV7S0Wzp5V55Og8xfy-ti3qTT9e1vzwa1zrP5VaCZ4q_3I3ud2DLyD1hOljEvP4p9FoT8XmGQthvsUTPzEVXKIBfJYEw5MblXNY5GP7EaMYqwPTyXkoNviObQY41GrDazpoTtuxI-Q_XkSjtNk8v35GootUC1uIz6VLoF4GjGPhgkpr3vrMCRXJGSBZlsovfDnRqsZBLUQ-Aai11p8BRvXV31A4K77B6i7QoAmNszipU0y69WrAf3P92xACr53aZWEgOFofhJKeG15rhsq8afSaDu7nfo7_pJuvKAFVD4YZlwj30uqv4JEoaj_gGvlAONtD6yagjODJdKAXvbsgNByF52wiVaFyuglyokS0d1i3JY5l5hFY-LwPOu_exg7EJ3ZF61mcjzxJUGqQp-Gcy5hB1RU0IoI94YAkKs2O1Bx27ejPbXTpLEuu-_XHw8FpIMih3D6tWfqijP5-aEUXJ4XX7TgtIG_Jc1yYfEXWP3EA1w5MsHVyCR_6Gymx9iIloVz63Ybt5rjThgV2kiL_MwkZEmbMiR50aN2EPnF-j9FEK-l-Da3mtDlyPsj-B2LnMjbGadbqMu-Rx6l2isUUIZ9GvzwBNY0yZRg0KdaA8S8M70WmN1TVUK8mTEiGq9eT4ZXaQqNh5W0FJqIaYXXccuhPvU7D21aLSC4au0ubelALFWYSrH3bex6g_XlCvw1PTyFqAZvAWL1rLHAUvDSLT6C_PhB7cCu-URO3Svx1wik4Ne9RiPm3Y7izgw1ByuEHAmlE8NF3nR5vKqBTsAuOX4QLmtlygmn1JTvfsTiV8fgYrOoOHvbJ8r_evNFKYeBxBFzKvZ9NkkFFM4DHr7aX-Wiz7Ha-k5k3-_ngzavwhnppFPfPlF2LYTiyxE1NqJJKxXvUR-q6oxuQYrrVm_FwCagWdwloYHyZqtCXgdmV4m2WjNCWqa7DyglTOoA-Xi-W4_zg470ckERIZEADT3GuppCF75hM17aoUYSZSM8nWsjzkC3AvkQAi35bRbTD-jZ6MvpsSLmIdjW0xH3aUTUZ6kskuGgwviT_7DR7qwPqHYWZF2mmCU8NpKZAoaZrHBLs8-U2ZZBiI-ghjOMvJ-rXHmAkFJCjBxHY92ZxGDas8NIm0bMeM1fKZyV_72PPRbFgzSZ9mFf7Yo_e1xz0DnMhCPy-ELN_bSqizMmsjZ6skk1UJZJIZM2oWIRMif9v8MTaKYkrVpCg90XEdUYdqp0_IF_-VKtrUkBy2BW8WZGUfQh1daVy79PTspxpWY9KBSMq6xEyjyh1EL00ktUmZbVvSQHzo3Wn2_7UHyKiXoQc2o1ULAezs_5l8dP0Jn7WFHQ4RT8STljZIvyhghixgHVMvJHOcRRWONiZoH-Z1ARRR5LVXNgrp9tG63ZRgJyhW9kp6OUXLYACA2Xzisu2ldm2UKEVuTlFj4TmpV92pA_8JUSWCVod-6CilkiF_buJ_mn1PHkmyfZSLiyYoOjv6-uy8nJg4gl6859Cd5dMzX-3BfJ5i4UdkLa-xlqZZxr-d5eqmo8XgakrVUEPFfoHOF_444sBc_mR-a85qol8OUvzl3eH0yvWrtjMN1GHjfOZAEZQbUoMt-tV_szbsz9-lfLoBPYNUnOTHVmc7l7lrPBE0ZyeXCBLHbVH9rKXdgPITUSxoPJV9pD3SL0bU63J6YvCyJLUJQUEslUYf5MfHqHTW76mWU11DJUgQG7mhRX5b5yGsju7TNOSxb31cfPCAr8MGOZ5UizIP6ngKM8y5cZ4n4NUDilQpDrHCbuUnuMGkO6VpXqFywf0Zk7B8AqjTuakVyKMhr3s_HW31tzHnIF7x1dCoWZaZusrmQbiktjUE1trr1JaHLRv_Fv92ZXaRIeuNGI8eU3364HuzcMWIDkqOEYQlSr2R4ZB0c-XAOHmYeAXjYycKmKRHdDAb2Owu3BgVLU4Ew-qPUcY0Yi-mCRRZjwnsIPC7nn4wwRZijovjMduRqR6LCz5f4EHE7jad-5l_mtG4p7V0f4js9TjFDXQ6-j1qYvAx3aX-qC8aFFOj8LSGq-bkGyYkszKce1tJcvORsRi-tSPPjREjoaqs3Dk_wqNanprmvLRS0XS1XkLTKhGggnKNgpq7llIIvBGg-wjRM7jM-98_80JrF62rTSvriRR99wWVb7XLyIgA3byD8UF8MXIjci3aXqFxOvN6bU7G_iwaGWZeluLSsRoY3QtxJc1n5k23SNHLN4PIthdjKvgIIeI7MkafCHYNY7zNxuA1FQGEl8VEM1NbB48EM5fZ140Me42AKot-LSorgfSm9NT4y6y34YGVRnDYfPRvecTwNNeHKWnpKNkHoGPoM_xmen4p2tmqmNytWjJAt2q8FR1_leiJoeXfmZ3BbRc27FudLr3MD13mjj6DWdeacK-eBAQvYce1SYrrwlpeteTYL4FVj6Dm5WaCMlMzg7wbiOqf09n01nTtOXqYcXu_2G0YvC0dNP60zV9xAq1OZAUABVxtuY2661dE4pqWXK74BKg0ZezRRRaMGzFR0Byeao_XMPh6s4lbjVhPxkAZRvjElC9z8amdmQ5RNhW1r_CFMQt-Wioa4DzeijPhNV8dDCq3XmPKIOrxgrrpfUCzqTZWzRtQd5kKW2kuhEY3nR5RP897BiJIj1J1Fnec049y1DBIb089GDJQs81JYKqqByAgdvcOX40JgpqZ26e9R9j1Ad6mUAM7x_Dw9hwPcVzsSwj8jIFVduCX-ATk4oX7w7NabCkZSnUdWeLoNgg-4N9dtYZKXeFhbEsLiMP5a7nqQIK7a2-Kxw-5tFRxjbucKGzcSdqGq2N7_RHmYrtyYWbu-J_zzFailvuk1Va6VxyL2Zb7-JJ1gNMW6jNnhlbgdkJfnanbVcrxcfcDlXcDXSjkjqoB_b-CO1ZgYVHtJ5WMZiybjODtzI8A4Lx9TGMHWmEfbGEAXb9wo1YjRjAaq_x3PSaZ8wvnh6v93ntnFa5sPecRz5kwApvLwx9f_iYSPai1eCEQ-DHg8nV8LfLXpUwdfpjDMcTyYAsvH3GlkNZTy7Bu2BQJ1yjFljHYjM-NcxbTVDTB4EPRJoHed9fqe7L2UAPC_PGKkB2OiWh5LVTdqMu4xN0rXPmWuab0V0qvPJBBKMsIvmXbsogPGpDRD3OZTuA8eK40rL3z_1NIvY5AiGzATA_90sCjjubWjx0k_bV-uOK0OOKNvnfM9xu31JbnN6crBDkK9iOvNc4mWetWkwfDtXH_Ensm0UvMvTXKL-_4nmAELnQhG4PCezS1Y9W45NiMnkt5gyJ6QWmKQvJnMpllqOsjJan4A87MudA8LEd1z-Zw8EjI9jYval5N3FaJBe6dgL5_OiQUeICBUa1-T_E49EpwceySCa-9Ubwa4h7QpDUjLN7F5qcY78UT9YZ680N4K3-kp3A5ZT8-9fZIcuhzcRVEBaFGQINBaKIG5Aj5rPzbI69fyCe0B6etPwtHJo3ZBvA4rUZJ_5O2TFuflnauM8RhGTg88qT0eGOBzdvaviRBcVH6xgKzHzPOSibdZ1Z1z_jMtV9exUivtpH1s2X33csO_lSeR_ak__QXM5kToV4SjiK_CbEEzmQa-tqEgEPDFim_R09v7TWwDZvip5bi70KSeANT1W5s8Tf-N-BP42ewtVJy5vbXkQdUPqp0vmIpGjVo0iTup3NFeNRqsoiknN-ltnypEjzIqJTbFSl2RuBEb-7HQu36gspcoCAZzWl5youzuRgePEa3-4UfzuNvfBrq_Ay1vgB915tKPGGUGpZjhys6X-hN7989KKaiV66qJWVmMoD2aXbj6fYlAtXNMlhXLNeBTfZK5EwsIFdcIRC5RXdSpLV38LgBSA7_ADPCfIhzc4tu_f999isnVUh2Kh97CBTDS6DfSoUxK8aHPe6hYtO9c254bKs7Gqt6yQ6GTyJTSqvvTUYspRfHqhYN3icIs6LkRbeTtXdduS8wCEgBRGMPkvNcFHesu8V8KQf6cJxxIfTtFv6hS3_DMKpZyK8Fkv4QDZ-OsxAdes90W3na_Np7_Qe-YLTpXnYJujpO-95GCM4XTEsVJdO3MzNH9oRzuoS7o2LZU_LM9Tqc-wU74ro2FPjgYuNIEQPdi329A9oty4lVu1vPyuT00yFMPepJqv7ik755pBmnCMjS-ncOMN2lPZFn1SoqDUORWmIEtg4MHtr2b_6qYgiEeOfQrmn77XcAijPnVsOk3PCF0PNNYJrbXKR29VtWnvcsU3CjqJqSjzYu87X-DMasmAPdjdOjYGABrgSeA47DTfBpSSoXLeRuccQXKqfLdXwEOMSPL7qZXmmB62pEaREBGNVkjy041CPoos6TDIptVf3WsyKUg571GF1C6zJeu6O0OLnfyEZp78IxXAXQPEvlppFbf9-sB1dDfuf_6z8GZPaFz7koF6_zDWriB3_PSoElIhE8shSpwBLGgrpRTG6ZzX56Ar-dKpw8vUxdH_riXvUs8h2E9zwo0ELVOcRbgTi-TUTd5JlMFzQRxU-NQx9shvmb56i--x4Jotv3i8htvNC_3IYzIi-Ui-ybiUTt-9HsC2Zwym7kVrN6i1cCAis7rOJnI5HmdZZ2qAvz4sUfqRHYDO8Zd0TSXRZzquI6aWWcI50gDRGQ472F-Z05weQVrIDV1l_Jnzvyh8FlxyppC5Pb_aBnueDh1VDvJitXs3rZAdyLiK5Ko8RlKyYCneOwTYMCRvPJFyhpOHVW-cSKSK7eDpBvScQKZLYB8k6njP6Inj45ZCeOl37vxHrFxvK3mfZ8YwgPFQrfO6E1C4fh6ayXg3KyXBYx4O0fzTH_UNIvNFvq49LSsfT-IKLuiMJ9hBztqPKKsuEoFE2jigAixcN8MFARZqB7AINmilkZVWqftzxCzAHc4UB7smexr_e3mqkkcBOnK-aCaxkyXNyJmXW64O4YSFO2cb1Qno3O4bNFA7BqNv-TuLR3YyqUDghK_hnJWRmuDA2rjtaCBmY1X1OA5DlZovya9oFnd_TkW2znQADUdRtRP42TQ63eORW1lNBk8W3juRFI9cU2v24bLEPq2G8CV5Dmlz2xnvFnuyy_GKBTfhymTN3z7-KzWqxxL06hlOAO-6JAeye4mswphbPGmfvvaCOXMyGUiaFVyVAinvXlexEWuoLxjlPePwSYDzZq57EqoTn49Hh_p6Nv-qv-cvB9UAjKZYjo0fKJ0rmoYEdN9VjSVDpcw562k-PI4wPd3p_Ay5kZLo0LjDIzAJWBnj2TAq4531_yFV2rP7sV1Ss6J3x-2YmjCpoagDD98yKDNN86osii3UKH0iiTfIpuG45pBxsKC0z4dBP1Uo0g0g9NvQO12TH8Aispi1FTajKbHfq9cQc0xYnBEekHzT-VGTMMH15P4PZ0KI0w956O5qjxVJpnprJGRDbV4oxhrhNPFVtNmN2ixKu6XITpWnQEsdXzdOk7r28F7BTcCdk7Ja8NM1rkmaSTYOn4U8mI9HrEPlCS2KMXgOUDrvqfSB2IYOxarHBR89CKs_qEy-WPAfc4Khb-Tnpc6xxN4zNjIKixcsAopFYBwU9TMqZ2Lk-7_p1Y1UuXRuUgqBRsrau14A_eYdt5xVmkCED0YzR89iYBU0NGDx1RArZf-iIBPUijelDM8zwmtt2v_Etvqo3DGg4bKWaQge0-uH5IjDcYqI5IwTW0VFIWijcq01etbIwRcArsX46XUg7uZMN5h3CmAfSEYGpR-hqOB2nIbvRySo6uth99m5-7Jau7dGHDWaxtDEk3ZvGnOPWI8_J9wOcYia0r8VmQPYlisNFhbNN2p1lW5EVHHmmhf9q3hmvt8RskpNCd5wCS5XmgDySXJXE-S6MVa1wgkjbV7E0N4HPWF9Q26MbfM-6puHtYz5cpQc8WgMCQ5M_Je_RoCD_uxc2T1_cZF8JpvAU9O5g-6YQZDTr7oZaXjXZbZSIBUlQsNAYRXzzfrqOArFh-4hnMsv9AA-p2ZvwkN9K_lTeQV_j5uQWAegv62-HXNU99urQdTf2_4SuiJsI9KDgZDncHC_bjNZlnOVnOcr-hMDbwNs0cKFLHX7ypN6sbeWeV6-9bkhpCazq_F-E-ulK0Gc3i9Al1OFJiU2pghupjoxloWBSf2PST2K_XdHdo0mrqNrD24Gfnt60srmvXZqwI_tebInpGyTBnqTVdn68VJ0fddqMGhBoe3y0bsml7n7xpI3FUcXoPOOEDszlyQPU3vpruvULZ2J_Gn9Wj9w_JIG0ny_OXtsdvOmana83K05skvitnXzJHmeksX2hHvxlkbuThdkauD1BREfyfmXMajglkhfJQS4nRK2HJh0ovxLL3qc43jx_foKEFagEj5Fib-aRNoKBukvYWSyNT5kJ-EX8mjg69U3gVVp2rbXb7hYVQipzjQ625agZZPOtMujy6Yw4gOF6ssnMwfezqgWQ7CeDVXaI7ghTwpGYT0_BktvFxevN5uXxNfaEUtYX0qz967C_TumTR9Onpwf0Xn_LgVIc_xBek7Kt2_03Tu_3mBomy3bqG8L0baKnjAy4fmulFrvVVkJ_dAoWJRcBT1C5M60adaorbRMHCsK9DJeJwRco9NqsALYnj2NLcY6qRKI-FVN_dySMFXiWVD8Nlrs4Qaidy9PAFfVOD0uWq-H0bJd6CXuV8ixVJn7egi9lasi45GMXwl52i__RLBCD9jrQJhpfOo11ZhmDWhoP1iQHH1f65f_zH3_vf_G0ZX8-r0DY93D7-LsGC1WlAvfa_Hlhw3PgysCTBb7viMJdypTWSsbuakxWSY4CFx6UwA4BJY--A1k5C0ZROnQ8VQG9xg1X8hJ5lJkHdK9xD0O6OuHC9OHfxYgGP_cN87WybDhbKvm8-Zf1ShyffKaVnTvx9krlNYoKbnnx-uaP6itsXnapA-5oBeTRRyxKb1QMZT5st-flbwNZ5L6F2NwqHZH2q5ykaz0lKoTcbXj5BhABDVv9nSV8dqU5DV5OeQn260XJ11PZ23gKFlXiLtOU66w2pd1ynwlnk_5BAbm63PiIlOkazQxGnQF4RFjumic6OAh8ywS7XINdIOZald4pQV38oACIjMZJe-wZ318V2v_FQbFio9R85J8F-2wxsBeKgnm3WbUKMN9n4F7HG6LCNm69qQm9g5chmIuVcnkNudhxV0Oe3Wei-uAZvdx7PXuFr3w8V5hXKFY5eB3uwFtxXBrTKypGKgQFP4FMK4bbz5z1-V11DovGPGH7rzCD6sbBQwTnldCzC7s-aW0HEuinV8zE0CcbB_tosyS87LFTnFVxilmRRK5LXp0Bbugdm1X1Hgf89JRCHPXvuNVDhld8v8u39TsWzlruHDCn4W7XxXwjkGo25GyNIF-JtIU6hn5pgIOKOXiPnY6FHviIjFzIVSdrL-A0Eedpo6p-YOvh4q9_iZY9BBpEKVt7sa5AkSTMeMxBdRB9pGl9eyuccmx8G7bOg32Txb6MYVjGh-wbmWTumljl8ZMdgIioLl_gN2hnLpP4GwFEQZZZYjn_A00fHPTHPS9wecg86xR_MaGtjegGUiUBXl6Vu0qaU_DaWN9qwhy_ltdNXw5NJxc5OHfIo_mrqNzg6vYbDxyRZGzbxgLKnJ_kSELoYAAeK7VBMRfe9QlBsOLtAKegI0MlN3HQ2SLJtBDTHjalL28GqCddyCkmP3Hwjt-qNAsUKYVNwq7KHxbXT06JairjfMdVE1kkjlCSWcKo0E8Ibnwj1Wm_s3S-sYsUIxoAFlv0LdJUyXgOTPPNqoonSMWOhGorMPgHZtUkQD_zAwHzjp8GIlSukTtsIAEQjNWKT9ky4Pc36dFQOosZ2SfcHjeDiZOOeqRRkVU50uUnGyY6o1GJiD7nrJqlURaVQbkFshVhNCgvpn3S-kpKHSb4DKRadEut3LHmFrwPhADKFqqJA28C4Og3h8H0RXGeMfE_YfeuCPO7E6E_Bpq3qzJrrlC4fsglTLgDGnwPWMoZpCTpJ9145wwj9lhNtx-W_FF3J3efSag8q2_XpGSj3vbk1fKm6hfMqfhU3yNtmBboM9AZ7Tyf_K-nSBZyVarvtL-fZ9jpE9hgBIrZVzJLR0jNAk0izoUH5I4g2LdoLHLbtm7a8y8od_A0ztKqJuGOilRFYb-5epwi1kQFZJTdno5KNJYlVx1Ym4NHwjY1QfBlIBPrp2k6iX5YmHUOQP2I0Roxvt1kyWYBaQ5fjqUTu-sCRzJW5rOZZ4_8NvOlyebVMT7giN1f6Qo-pjZJI2LDHGtGeRqmilW7df7976yD3kfK2rSttc9AjAyxgDturn_HmkXqEWEVBKo8mnjX5X-ok30qhjDdK2bZSkvCKLcPD6ZJnqmzIc8d_V5PNN3qh2aXXXImO4Ro82VzmcHwU3HGCsLgcblAQg5sbXnDt6zMjkq5Hd6qoIeJRFQrpc8Ww3mnZ5zFI1ODlcm7ZTYrzJKIGP_RNcx3KgLYisqwPHkBhIDNga_5ElnVDBufCaxKc9Rfzanev3qjipEI_s_4dLUPFCKqFbGbSSJ05ohwy7AcXIa7nsE8SjRn-3hpzYdwuRVRMN2WLnbOlmc6hb5-Jnm5vQEmqKUUGH61Dm1TlA1U8oQ_F6rn-L034gnIv4U-rk6gPIDiNB73EfNHP08SElrt7-VGRbHAlg8kf9YEMyulGCd0o0nYGkTxD7KI8-wRMy1m5SYdxm-yBsdAegnopZ3cSxDbGE-4rW1-ooJQ4VY2PnBJ0KfYWo1l2E7vZMt-VEw85fpZHh_QNxjNA55H1qsHF9A0sqMRelHFVxt1QnjYKUzzIDFGcdmiyCJkR-6cejFI1dXy0jkpNGP2tfaRo0i-hCpTQnuqIxjJCE3ieV8ZT2Sq9qm6qfQrYppgqF9cOgAnJwiGCCS0Qmvsw7JqXmyM2CpJmxvWALhIsVtY65JXOy6GiFK0UIlz7PBWgwfD2IZJn5NcpNR8lf6kxPtUHzCnxUJTO16PuDMqET99SMyd0Tv56EBEuByBz6CT2nI4W14HV6lgo2Vj3Uk1-U1EN0vrXv469Rhn6t6FDPD2NGbtGWZKXQtheVp_pvAMIj7G_RgjrlLYFIZXILXfmY2iFmG9Rx8VhjDNZiKYH_O0-6ao23HjuV_3YXsERYH2wDVJHaU7ysylbtqAVxKEnTPRhcW-7y9VgX8OQyo3nhAkn1fGN1qkFKhiKr9APIyxv0gyEsNLDCGhEFu7uK4JVcT0aVMFYXoRs-an1O-d6euxDAFriqjNtrbdwygBNySEoyyQI4XZemJ7blhbSTxldtNZOcxqwJlwsETKuc3BPHk7B16xajWf1axyBrNUNEMcQ8ce9LkbKAUeUYcZeelOVQz2OCvb7ZmrtZwQQY8oBJQdf-kBuvPXyBDoE7vZfiRq6q2AcN5iCxbnccFyRhSUj7oxXjIBRUw1Xi6DAIVOeCG-ZjUFq39rsLL9Wn3e5YKVsMjyVkY5Tvyy63HdywVD4j3pPR1GpDY_vH6qF4pmfZAlEfhruSd1Na8cIiljJtXY5t5ontn3sjrJD0Mipipe4CpniQgZJqQ37qzNTv3EQ2YbukEk_Fksz75vfLDC7B2TloBpKH6fRPV83yIcM7VAvdqS7IbYZ8R2kvT1jXJiNinW3s9kCXJV1K7FVq8nj5LmFYJclrZJtUp6eInXF3xfxJCx6tFeqJKIbE97KZ-tw0QcLyF0u-dOSUS6Qg28G4zUmDzi1PVeKGb--IKRP8C4Nu-6qdKq8bjimf-Zc8HbX1uV18hbWmwpamEAYCBS10pZ36kQP-PBC8F9CAUR3AQymTzgrDN8vKJcN_BdQKvMgTVWsDmTwAbwD7MUleuYB44ky2IKJW3wVYn1aQvVXoV86utOsiCSvb13_IfA7RT_qspPu77LD1Oi73tluDluqJzz1wS5-r8N5T6uYGU_YV1mvKDZUC2AsW_PBQZZIUV-ldLEOOed2oD9pEeHAyxLwl4rkv33g9omN7Pn121_pBfQJv2O2-1Gek3y0MDepIFPFaCsIQmNkaZkRaf6HakU6bKRfqpYdXX6LQVhiWTLqsEhLUHV1ct_dCxnsNdthE56RSHPFQ5BUJZarg_gv2izODhIg1y5a11YogSe1AZq7TXUlebQbFKDqmCYUQvwo5KS-BG_jYVHM-ZMysDXDLMXAis63wDtXPsFnmtFMypdIiVXG6A36mTDYKIH45m8SAKzN8VAWCfIB0_z0RzBBRFKekrS1bSmq2d7waBV_nLWRxNhudKOK_-AAy1CfM6lw68kxhXXfu9YyTX9jJnUlO-RRYNMn7ZSsYvtprUI7cHAOMxBFekLRk5bjjtUQhA8gWUn6xTSMxohjpF1Cg3KXYPeN5v6Wj6FS-dEcxDwqbbhhHv1oJMYdmiQVo5_grkwGzWkclrUV-y5Vq_yfMm9LOoFdo8pDYwoRzMNNvU4WcZtVKCv26Lk7EH1P2kf7Y7WbudIf9msx-n-QVzbiSNjprLM8bT1qhTXSN3cP4j4VCL3RnATzxkoA9psj2CGE2rGl2WkoYMj6eSx_nFvT2KhMrSnlJwV3HUx7IPzYy_WAKNw63IAKWJpV76xebksp09VYq-dv7QfDUNbzWMNOeFIiNmVKrkkLWE5UuNWxpx8ikWzZq2s1kmct5YOtpVBhETmIbHdsquZ971RPcbKkirnMaX9du-3iw9ZJqLbqWw7DSGjDvzAuGHaz8Lv4zUYJ1EjEnomwJgUcLzFwaU9iAtx_f7tUC_hEkRQzVy8Jv-tj7uIAjCdR-5Ey6WI6nr3iVLs1tV-nJcsEBTwDwj738VGVcKB5aUTZCfsbGDgj6QFcN0_tt2ux8veJfUAkE4Nj7Yc_4gVQa3e183hUU8soBe20KYIYgLget5BiOFBiP7hg8K3wIWnosuMqcDicABBzxSmOJtsI1kOIgDSBm9Ekgd0M6fk-ZADWsliR2FcQ9N5C1Id2_tNnH9TRlScfD-UXvRXDO19cJlq3CDt6K6Fg2YGLIc1TsEDokOL9kWF3n81DzfEKGZ1wip9meRVQ2Ec7xEuGKYoWld87ASiUceb7X-gaXU11NV_c15uJ9pNaLJizmO487XSGR5aBkIbFTS9xbE-aSRUUIAnC95zRE7c80K5b57Uys0_GoHa8VMiu--yRDX6xhkAQ_bO2vPFQHFjxBvduMnwl18f8ybgfY0YoYsjbG6xxAMcdX7R2J0wVM59FRaoeYpeVioVKoKpVgbRWCltx-HuAvhiKETTLIVpYo1MTzBEaqRYpmF428dP7Ca7yozlHDFl-52_ushldBYqvd5BB0JxaL8dYzoqHitDEGfdJrPKVUqgflgFzpF0psPeRcVMrm4ep_B3PqxIPGxhibDpMgnjlhwOjH4Fm6zWMAM39GrB_CtVevFlKODJmGMsN8RNICiTcSfSaHT6Q0W24B0l9y04O8TaaOG5DrsWnGdT6gMWxE5epL15HdoF2BFkzKJX0tOL72GaWtE7rjkgUo1R9e2zKx_lgcg2o4QPUp12E1PjB58JGUvb-7ABXXBWe9lVzNfg9tFGC5yQTVzLawtICkvBCUdCjfN-acrzXQXNV-oge7Bzze7cAHUIseix0A7j9rv2KQQXcFP54fYJLaMjFDpCm9ZRw0cXai2Ijpz84hxSs6qqZDQD4PPu8fCvCX6_j91BLZwK0FHKNqMPOYEN4fSOR_q4reWK-IVAsfnCABJxZW7Y09y6i5ujJpUz1PBW1RF9rOt2vybAPsfA34tNzhRnWOJP4o5jHZfnnqDTRfaKUT131mRzpDAlzUQmS0B9_QF_TMRNtbunQsKgmSWSlt6bwMujpWq0xiPfv0hp_JsTtK_quqveX_Cc6MjQn61nWTyHFu4jXHzoHsh78q0pFQPvCzNntQecDRB-9BC_ChCXkU_-rSGgRtcQ2EXBhbvrMKQZijVMKl1Tj5YDogGM0iqWHl1oCQqZEMy0I90zhQO14hUU-uPxaO6EfDCGnoetfobvtbbOjFGqhjiDk3BtvcjJYUireOhOUjf_Mn1Nmk_MfDly0FcB-xteaHh0jUS3b5DB0seVsRcUM2uTiUVUg4r0UY3qsqfv6chijxdmQ1XSZ-g70VyTG8KhVkz5WJl8Jf_yIsoOJUKzf4X9mVzPeQ0iclLQ3hSNf4Tya-_DHgmt8OudYK8eBL-lfYbITHaA9Vqz3rORj8dyeCJT3IOXT8UTqr1gcaulrtVJrT4jBY1GVenJEVRkd9Qwuw9b8C79T_Kn6exqSgXOx9mR8-nX5d1y6moRqkTdol_Ky4mtV0euUj2hWTFqsvPSoAsGT4lk2PD-75WDvg2X1mfNKI3HpEnI3usyiwSmdCT6OPRSmR_UlAb-3PlRljR8R8uCeh1f0eVlJPeOo5RqXtk04jW4yqKDlwjPnON1DBCR4Q22wXBagtHpFqtlPQ24FZA1xoegJvy-WjHknXx14atj8igpaEAg7CbGxRpq70YUp9Eat0hCNAHrJzdApNuAqNl8Ck9pt6E0t1XcMerTD6htQElbmn44IwwPf8DlQQnsowdm-949D8URus1j7xRVv7AWJocTgI5F0uka-i7qpGsEqZ3D-MlSwMAByFTHTQXFKQFn7PMAaCVLMcaf5dFY62kq2vAYLTlgaejLbBvfoDqzlcPf7B3Z3cARUwxiHsbFgAnC8Z1NuCoEbH7H6TNHMAUtnzDDApJA5v2Hmb25LQTsxnlsog8VvPRGstIenOSwdbdY7zl-vUkFg_CUKBo8E800rKady4GD_XkT-EOL8NyyXOCJS4C_GJ30RUE_gznTzz4Svypp1_AUtdWCgOew_PvwKOxWaQgnfXRAKsHuwP94e4g2K9yqOZCBhbwVLQnY_ykt6ooT4oGmhJztifKZPT1wWyow8mC1ftbLGwvCrvO2v5zSahI-ijYizd0nIZ74tBOqMYt-XDd2pc4_fsRn7jGY_NBlc1ZeI-2t7gmWNPwMFYOwrZdVNH068X_xjRg5FjIywEmdQYDWKpF4F3TvW3gV5-YKTJ3jktsEmXDtxD8ZvIx7366OOWcA1qE-by8BZIHCG1Hguov14ZJgm4mDOIhxxu5LYEPcTEPY_pd7HFdZnzcAN0gQB37PFvR6GlH-qIK5XK_pl-2bQxLXUipbVFF0dFNWD48gw8IX15MATW5GKpbIYaiVUAcfbsVNzAMnpRr4sHPH1xbjUvil885A6KEMkSjIyTn9Y4a9I0yhCz1gZGcLgbYpbSXnKrRPNgjhgECqczvxnVgWHGirYfOw4Z_N4_7NOy7uM7UHykEGFRIWj5vtscjIt4C-hZBH9aYh-ZJmQMITLtv60yP2WCTeyXfpdWo237_YCKL5i7l2Qrw3F4TDt_87c1ooKY5UdDHoJ6Lr8= \ No newline at end of file diff --git a/backups/backup_v2docker_20250618_020559_encrypted.sql.gz.enc b/backups/backup_v2docker_20250618_020559_encrypted.sql.gz.enc deleted file mode 100644 index ea84505..0000000 --- a/backups/backup_v2docker_20250618_020559_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoUgLn3m3yBpuSD88hakf-4_f3CNHsn3YPrmiExFdiR2AVEAHUkwFQ5tzYsFtP11hgwE4vZfRFbgJHi96GxU6LLmt_uYGzCRNurd8PQB7nRIZctRxmnXu6WmLq5cfl751YG0kxquPyrEfdSLd_yv2w-jW1OImvyRmNSi_jcslxwUZSIzF4kUtRJu7NtUeNEE87nTkqYXFODfoCZVocl2NS9ESYEoovK3VWm4tCO61u0_hZA-bPB0Uy-oMvrjOmjwx8gY8pNS7zyBsU--mkNc9u-DzbVqFbjRHTblctKNzN1Y-sfHMW8Di4wHFiARpViHIxufThOh2wjf1JXuY3pcfT1PxFuJRiRKsMKlkxz9knwqd3zsIDPgrmcCBuFJv3DJc0aZ5nzuVESgc6dMrCz3ZFVDvKmocSqEhstUKUIdQuEC_xUSQmKKz0dAhm8wwMZhXsF1ft8ZdH_sZ-wy6H0y4IKwEsyPJIAv2U5aqd20eColk6AQvytiaMcszVFPGCI31hUKqfA7INE0KvgpHhCoNwl6C9WytjBKR_LsvJjgINgdIi5GX46MwMqrp_PNon9zAcgFnFZ6PNZK-QGnX4kx-ej6W0-NmEkd2UKiIWwr30PUvLkAfy5SDMEiE177oS4AqDo46oSFzHmwHaSncKIn-cCnefsiG0ThBUIoWXCs4-fmMTr6NfRJptXIxPtuQ7xVNiProAvUHjyS9yy8DNgMP7Ao5kW4pSjx_NjwsriY86Rc8K-WKDF1s5pQl1qLEhSO4M78w5eLntcH-1xwefc4tUepuQp40Q81fpGWHFsBcx0VpRY_mmrtlyI4dWBk90MHNSkA2-C5JaIIjSKPxNYk1DZEM5jIZcc3jqK9XFWeWUVtlBtrMT7VqyWkaC0OAyHGwauhMXVpYNw7no_FEeEa6J9rMXOysfYBgeHj2rdfLk_Nd2jsf1loddKcHbh6H9d98IWWKyxtInyosPbw6jwZHq0UbpZGBH1vRC0g5SGrk_48nvCuN6WYxveSfmUV_9XF6tQblyFtkrjRX6Mu8NhmCi6tHyy9vESFIrDIwyvMmjHl500j3PPx2Z8RKQJCiTdlJ0_uYtco4C0xTd1y5rdoJFL3fI1s1mrPT0JTDQvLnPCLQq5tY0dZM7KEUaQUHDy6zcdItMhbTY1akAeVkrfYlKZfgnEtDeF_PJw1uq2px3pYN1eMvzGSTmc88v0YpcUc3UsH3HgUl_nZF-MA0atd0mNzxrnDV0Zj8738t6aDPfBCJkPQzv8MT7DTiow9ibq0Vgl69miJvkuId8Cx0voipBb9mCmBmE_pdpn5MsKg9YyRVIeTHhik44j7Rk1fya3kl-jAkr7d5DOLS_e_QJOQH-0hMu8AJ_N2Jn2PGA30RBNwlZUcygrfIPV58Gbfe75XU7W--bSzUdsXMLUzwl8fjcRzD2Vfw5a8-5RnFZ40ielE2slAXyxnpmkfibmPmtkdH-K_oDkem3fSeSVeU6T1MB16N-0QYa_shSjtsCFE7lM9zupoKLLmM2wS9h8tgO3ireWN1fqacsMHG9C_jJiJg4jl2tI8R8HlBLCpLvpq7H0lLhGLdLBfKgcKsHhIW5cEa-3HYL8QS3W-iMBM3hWn0oebfJjDFBX56oRGpEdfDraWB2MIo8VUkKeF_nfXBBbeyjLhDWwTSAujQT_-gZIGPR5RXYd4ig5KOOZoJPtuocoFNH_LzFHkxb9qBZdnUPnLH5E05NasFrFMXsgVV9hfU-LoXjzOjk2ZeGbvnOCcCZsUNiZ_CU-R-Zsomk8_IkENR9oZe2mxzHPuFS06sexYCqDjrszPAbmSG_EimDa9Mt5w4jKiOn8ZQufed5foR7sRepnN5QCsdSUhjj3lbxKpl4XHF4lrJL6SZIRgb3bK9qT_S_Tz0ccLnoHQlV6fxvNhGn2yxJu59HCvjKwBVITxt74YdZLfvKl-a38E6HoBDIDU9_BrMehtQ03KlrbdwWjaqEagQgt6gvc4-e_vYtGxDu_qbj76Fyr33O0YjXFfxWCueZ4noHpjlZ3FRrqCKzquhRA-YaBzS8aHxCRi-t8RnC8yymas5ki0N6dsZWKUu9duX0iJxxNVUVENu1smPzWW0Y4mnCoC5V0iXT2Q1tVSdtW6WkGeEIsVAY6S7XcSptQIcIw4L64bbaX13EdKXm45InS1gHniWsV3hVXdG6UvFo-fnl5dztKqK3KolFKqxMSbCIR4huMijo_4pIdG3ZLduIEm3yOudXO5VPkz-8Wwbp6GIQc0lah9mcH3gzQy8FxKkrSwG5nPs6C9N2BHffNPgscKSH3N8NcNoZXML3F9pIRTz5BPdpO9cm6qQvCf1nNXxiyOhHxaZAGTScj-cyGiehsDLBZH4nsdjqPyabJWDCxnJWyzaa5MZgXGSb3Q0hD18ETKK2wcNG_an9fECQ5AJqbN0tLaXLoIyHR6D1W7wq2Flz7rMgIxecCPvDp69Ue_SrWNMuP2e-4OQd1cQ8vAu0j83wOA2onLQk7Wfk-iSZUkqqMa-p79MLxt3ymW5bkPSi0o_fnOV4_FcpnflZYcpXZ0oUUJK4WPjkRZeGM1diU1Ax41iCbUC_mE3DL1CxeBbxpVQOS-lhqoEenf45QeQBFabgBR0dxZEQDKGAXDXhoGteFVVCwQNFHX4vi_fiVeU1b2ilO_XNchBsd2jTIzha1XH1lVhuOQfuInyPQUdzbYryTFMa8fIRcGJEpQrKn9gOfb5zCMXdleOHgzLDH1IyouXGP3u0cdZXTj8ROyJsdANImwvg-Hgasq9f0NBybDoR4B3LezaDvKEK-eiznrNwfeZVTDNdSgBy5VJzJJl_6k5ZuUqf50nI5S-TvdJQjT2ogG2FvBR8kVrE8cyMw32L5lqQINhbfQ2I7qEC2sp6Vmy-omsQ7Kx_s25E2Ibdv8e7TorHO_C9qiFPES8zDSgEGXXcuapJTp86aONqazGvAoRF22F4aB5sxMJtSeeNpM4lJptfXsJl8F_-rCYWfIDjefOSV29GXuZ2H_LPhFbIILJBg6PMLez1d_8-3n8hJXHDmMdFKJYr-yTfB-o8U-2SSv1EJZDXSwBLrPQOoAnlhUfTnzgIDnKPxbgcExfkH2_LWLS2QryZZrGKp0Hi5dmcK_A60jyoHwpIa8o6WHP8hkowPTrbm3rGHcvqa0XaCSe4cdHp5-q8qIGv2iZ2mDjvG8pMCE0BgtE-jv4YeBrXKpjbJ4nwVOb2OheV8hKa9kOzmqC-83Snmr8a3N1hyG2bo9qjMY5n2PFZg1_ReKmB3bSznS3_0Fc7VrMdWJprxFoUWrUGeLAeme2wgHJ-P41RB_4y2os3pMUhpZbmoaOseF501O3WDZbwJOKrplv-90jRluf8TEFYeqmVj6xXJNvDffDRWitRtLL4hcfqhM6KIMszJ7atn265WauCDRJdPOd2brZUArDYgPbsTxdER2zYmXt5_MjJSf9ljZ4aiSLrUnMWAvuLYfBSSAvpPXFBkU-ToClvnyXx6HU7T02IBM8h5Y8ppRNNKbkEY_7RDbZLtsBOlfrwHXJ3jUx1QrNG1zruYHcCkdEWvvQxsawwkSUpA9yUv8Bz03no6u3FDtMWZcoIl0sp9_DrNqQGuq-RzW4X8Bw27bI8gtNGGi7fdI6I0P59jx_lGazqPWlVOSCiHB_KW74l_JfKnSu01X7SRT5LInEx5pp6kgp8LgKGb32dD0Wvuvph_1ecop0Zv4m8l6g6EWBRxR_ddc16MAGiYkFjVmzoi5Dvp2o7PzIA24qE7GKVK1DGnp9dDdRqvjyQajDTGpoeAl00fXBHSrGoYUkpEo8QZFYdrimHzkNvFIusRRrQLQyPvM692no4mV9bsi9BtN4whDaMevaZyaubnRJaxlNHE08c3jlx5TGjXUuW-XZrN63-uR9bV08ODe6GEe3LbALR4zjvwz9lk6EN7jOfhLAe9hs3n-lWlsdn3ymNljzUY-1LbJhob8xJEqRMcE-SEfjfq1NN9SMe20AU86Gt7yZQGDAzsEmoznZ1owU9EakEkRmJY9NFbCD2laldSAGnnVLADNtRxHq_VjIjm3U-HuDSEPTv7VyvEqUla8wseOh41kO9jfFRzFKlr77sC0doLUGxAOVPfKfN5uFz3IEF4GkZwHFQb4iMkJcJfzfYMgzvmvB4XN8b2Un72_INsRpf8jO-9rWtU4TRPYMEOKAhCZDmLVu2gfWfkB4CShh_6iSp7WzmOBKcFFazWKTY_5LEz8S8hWcYdIdizSAW0bqix5MuG3mwpZjUGGonWIfvnYA-QH_H05Uwm2w4sC6-Wl3JAuxTCz--McXMVIBN4k9xrNzIw01hgYgP3-oDzygSnT-_QbIN_CXMO9tLEH_IH8PX8ExnJwHBfWHHYFYFQfXPuoyQ6hcbVmcRqyUiXIxTrHom1Gb4-1898AGdNOmCu81oNe5_ZKjS0AF0D8skJOsjtYfbUoZPANl_3DWYeH1b4rJ_m3hqPJLdqW3O_a6xSBAiznU3zd60UiaPw-EqoEHiQ8nF369raZS00TiyzlQzin4oX7NnXnrAtfxyLGHcKhZ3hflX7Hi0laDbkZfWThs82-hNy3L0DvANlUGckgBvVO8sbnDnI_O1r6z5sXzfqEvE_k1Ep5-UCE2mmqOm0Ol3FZBX_dTn24MDkgJllCzlwyuFoS2npmUxrJMD2UmemohywjjgULDZto2H4MPmWQY6g6H2JiR4XLsG9B8W19SyIyKx-Mw-ks9NdzNlqEYjBmGWNOsVd0XvTAaneFaTTNfJlXjurai4KTlJVDLhtPqOGhPcHgfLI6WEO9Dt9-vMpR5YU26JbP92U0yQhLvA-9ER1n18trB7pJRYaK4IdegokcndPbHqvtMAZt6gnI8MNwNR6LAKGoEF0GUejw2vAm_z2sehX9SBu3dsdKmHkgYU1K7CPlqWIQd6Hu4K9eGHZX1sCXnsCyiHaADfRW07NYgoHtt2UkT_rcrxz7KAeRT8prbH4qh6y-lA9rFTWXc4IpMcrbw_1mQppt70UfgjgLRcQV7Q-WjKRAW0Vv5UxfeDthsI3LXCoz36Kw9lCyfuV-XpQh7y1ZfuUxjC7UI93LyFx5yMyCGIs20k7yi2f324LQB9YcIGNnb34tZkP0lh2W-vSRM1V9drZtSOWHGk1fG0VP3u9KL7qmDlqdhDlP1QqrGNq562B6-FTcUYnloouGcViobDcGIYd3A9e_IV4dD18FYTI4sS4kb3jUeZQ9h_-Sde3SftLh8Ejr3WQZV6OvQcWvG76-sXrlhqWfOl8Sx_9FY1DtCgfDG8dG5KCI1crAP66DiHwecj4Tynk1GXIS9x4phduFUM2R4mQ4_c3K8KAh4OeSqYeCgYIoueDWuDm60jlseJxQzJGAE8kDyUcSzgLl0iDhkMdgaxfjqKfBFFEtDaHPf6TnCfZTPOQ6aChwfOdHbxwfaue-fehDmPPq-Y7dik0O_ykXtpV9BeJoRiZPjB721i9e4yT5268bhBXltMViPbWzEYY2vzcH9zijmjPVOd4pqzykvm0yx1BQIRhx_P2iPq8dIh-RGTmjlRTVfRmNRlEji1OIm5ujb8ah8B1vFle5cV3DX5dp9ltpRjfEAMW1Ax0gk5QfVa999E0XE4UhnV0T9U7V6GRmMp-BpRnDaPxnHHwUNuZyVhOoO1T1cBah639csOpW3qN3swxHJC6IGxbTyQ7MXggxczLwFIV5rwIY0Rn23duCoqCA5Cyxz6XDqIEbNpI209te4yedtWyaCvrVVFk8HVdmUqJfB3ZyrdUsUkWJxlpMpL25T7mzX30FxOPgGn79OofIUw02igxj0FvaTU4aK_xfHGmESaCYv7UCFjwPAmX5dDnyxkeF4me2wreixRbQl57rRCI0Tb6s4qjFg9Hb_ehPusFSC27EjF_FAZW9qDPQwRIwamV1HpTWfINBitSJ9iC-cOEfp1ngNRcwBsS7QYQ2mMZgb7rfzZP05OgiJ28PWf9e6Od1HZYrUmf2nFSeKBo_oC6pBJ1s3gASRmjkQrFzvfceUkPIq4IF6-S9mXHfmWhWOmE_wladiO0qgDU-gqIKkHI0y28xhSMRtM0MVbxib6broNyburnCj2TJCNvsdc30qE9-83I-c3f-Nq4PbyJukBRF6BLJup3Anl_dV3lTzd8r9zgJhR-VGZenaP_xHHPIoiv7zLk1J5QAVGlrBETLiayWHxuwh1cogrs6qRmPJjuKYAU6Kw_7pd_gXim3gMSB5OP2OdtQVVuug5SFM_KdKK9xmhTpO7skM67O6z2viynS4Ndm2llOXsx5lNEQrPQZblvZ1kubs0gyIByxsJDVfyKQm8_pD_qrhURBns2cNdWo4-WgADIYHjMYx50CEPjxiaOcQxkrszNivw4Fu2gOjv1V-B181_EOuQsiSWiUr9ywR8IDOYVEnPvtr668fLaoeMdKg31X4vSr7JRrvxD2CcZr2LNa_afNE4cCvuiddPebylNNBwKmN8fK_qjfCTSeClsiXxVCgWnbsyBKuwkFI0VHRkVxzF5lhB8JDjhDc5AfqDKiwcxPsAn4kDa4OF5SpnmS5V2m1atkJ6-SjtRL751ZceiLGVF2V-OTVnNOz6KVb7pzTsF621bAQvqBj3HzUF5wDZIjms-KUHb1NVtv85_-xbIYMOWeDvQqnh4QcPb0Gn6MoiJ3GawVrqpX1wQQ-IbG_i-xx9tztPoZFSEm7C1QCdMmUOqNxQyEMfsXxD5wPHAwThXt_yBFEo_3X13nRBz3A2xir6ejMQh5swGujZAMORk_YM2XzzUc8qutKUhijQxue3i2m3bWRTcgA98evZuWhYHp7dJtXk-3iu4I9qrJ14VVE0XwAjuqsnauRvk_L0hrZdJjBMepF3oUJEzpqmo03YxPuTjCg5nvUfkVr6jacZaPC-cP5JgXFR518108vSFIj5htjpEVSbqrlZtFq0KmUHkLuWYKSe-7BDe09aT8gO8eKxmH3rYYfVk8Y6dfBUX7wQyMVGHgaKKqselq2Wgj9mvtuXdfnKtDts-XYJPiwszhlXd-tHd30_eO1DT-srhQ29gezy62S1078fMVHbMapg-Rt1M5iL7M_kHRFeJUeVm6KvyftgJ4a8ZJ4gKZvXaaoBU-14hDVE3t8hheayiBVI4U3g4OFLqpY5a6QECbPhqwNpSYqIPmvhNnZQGhzJbvr_1lCL8cHEOpHohtGtGNmPIX35buHMbtpW4BMzvHqftfmVHKQsGO3gLc-J5PU12o4A8IqcHRj70PAfzi54dbETu2ZRyHHk1O54sadHc0uuTOEqNUaId-YtgL5LtCagtyYj58ycmrFfTZKnY4VByGhYrz19SZrlYejS0f81ap79L71IlZK0U0xzLU95qCydsi8Z88YNbxy_CNGyjpanUZnTHnF4rYcYUcR2GuWLFtdWTzqK7Ily7ilwgS7WFeX9rLFcfumx95AfKj8xHAsaGK4rD19nsNAC7mXFUvc0rnljOT54aAxlgP4UvncIXXYjG61_zfko4zD-6C8monsnnvH_39NUyhxSeOzyPlTa_nLR2MoIcyv8V9RLvsbNxY5vAMTgbhZoZWkzHMQ4PBNizA2bEgySbWQhKBw4uM3at6RYyrt5hsCJvtxm7GecxxERnSoiEapPFZIEs4O0KWo3oDbMSJFV0Nq7ytcU7bO5WfpAWrqQHMjgmzmmNRL6mAZXvYlYIO_GhFIPct4XmpKVr1tFxJx1qOjUI5P63XhGqvkXxPCx5GFZM8VmJcQFWzCKWsHxjys4tbfV_Ls0t58aCrfID00Ldlp6eBsIj5sVYg4peqdNWnO0AYIy5V5CBToeVIWBsNhq72s6v7aYOseXeB3cmgp4yMlQJkdd54E-5gtLKgFDedAM8dnBVvebEcFZk-dqpjFiQ6aGLSDyxpzQDEJlioyGZcofmxB2JKDPzizPsFC3oVcxgqMTd69_HtEHthI7XwwRSsagpyOF5uzKK-W-xaQ5AFD-4CpLZVqYOKlPPub9cnxw13TiQE24CI51CLKnQSx-Tfy1jSPap_x-GXb77bFnL9twxi5ajkCX9HSytUWgEOCCsKMouwvkXvXkfXqwX2LQUv0lLuP6uaIBeNtDhP_fA1iH7XdDHps7jU7ug1iKOP0FmUdFFnUV4cVvR52URNhZnKXaw44fQwMIyJy7nEZ6TN2BfwQKTscTw6ICnd1mN7Oyrou7H8GrbqJ7Ns00uiM3AxaH71D3EyDvGOFPDLkR3Phu71qG45zOm83CxrtmCwrZ6OJxXUO4DMxsV_3F-1l7__qa9lAO61AqlpMdLuXXTjgENeMFueT3kqoTlYLF4f--svspPFLNkI-NivzsJjHX2yOKtrK-5NDmqOWoMNi1BjDQtUd0_nxhlN3JCdOZv3nkIgm8KpHIzYMRNACUMe_q-3ONVWjtNeJnqaxUa5k5eJeGXSpvAc32RG1TYDxAm83TOi3SMwDa2J68j4Ohs136jpTufiScUBopnycut8eNZHHkHFfbWM_9RG4FTrWmqyPZQLg6uEWJT8Oub3ncaKvU31QsmR8ElxBEvxE2aSzUQpK95MqiQh8vIIR8yzjE5sgFchcEmfKt89zY6IbAB1G0ucCdjOqII9BD19bocOTNKQ5LVZz2skxs8Uv5iCP14HLot5nXYosnqHHNOozxZ04behNWu0bMctS_sf9fS_sTFDcwwsQbkvUC5UqDICeoMZSKysPsnJazQ4lAqJ9dSLT8BfG4qy8MBLwyE9bSErVeFCnnUVP7HzbmZFbL3Dka7uWJ8QjsGuzhiERZVDQYbgv5YiSjC0JfdNYNloLnvI5o58QRRbOWty756W5_tNd8RzyPFRinWWqnQYu3Zu6QjfoeKm-UCrPfF3UbWVeybiaOGYM8wGpJz4TQs4Db2G-jv1HHqeWOcu2Vin-uAxVEacMMWOZBdD2jUR-nl1R9vUqu_qQDFDocWA37uFHtu5ro7QJdHkCCjCbXzzJnj2JFR6_FD0illFdDsfQMhwkKr2FC5cCl2blGPJp-OSLxmmB2U6NJyZsn8abRcAX9aitXompA6ARQD381UK1-syqDkEmhlP7rXeIOkVNvzjV1rTF2bQsBoiZpG0woE2-80gwfebcvVn28GSNUTg6EJ8lFGMFjVn14k0B8Qv3RPvi0k-J8-2K5NIW6qtwwDzBrG0ORzlJGRcrvO_tZSG8xJoVaI32T9oIGjsQ8kPJXbah2VyH1DI1FOcV1qei-KmB45wcB6gEk9XoQABWpD57wh3oCAY0Z6EHRdarcDLyOyHTbYGREIzTQTtEhmhumfGK8GKy8M2jInAIhRZkYPUwYNbkwbEmyu4LjK0_Um6bILQ-NjlRdIb7jVK7ravGuIR8YAGq7injPzu8NDBP7SrXN3IgQj3jCEzsT3ZMKybkt9jU1gDuZbhEqkjMYOymnPQuJPj7uE1wEwdO0RG-MY7tgfbzHgFuRIfTZzt0Mb_wSEcC1RnW33ljP2Mi8AEMbvf9-MEPcX3pZfJplGGiCRqOKBd686MV19x5n3XHS9cPzW3dgwyYpqx8DnxjknB1Eypwi-iiB7wnJ1hjLTFdCYCbuLfzfVvshn0jVPJQpkfVAII_XLc-beHoTeLupeYplkpm7ZyT72yWbDkOL3yABgizu8royamLC1Vb5FI4hjRNWkerBnLgqkfp5UyrCWeV1rCci4V5Qxo4GkyeiQ1ET0fdOL1am0xVOgwwSsYTU4EltraIJItmXCDRQr_g1msMuYX9oBXapYEvC3lPeD0iNfsYxwQTYkhg29ZVx5EyzoYL1vjBL_Nnz5BOE37b4vke9gmx7kAmvHYuys1rq8grSrDPVKsb5i3_Q0vyokeLofnzON8xkfTQC60IjOuFzSeGmR--5_jrwfCGswfGWBVH1Q-qVtHxLUo-TKeNqwhcztTG27vmIIUjI3LSb60oOor5I-VWdQR9SFKx6qCEXHHiDMGHxiHs3z7fJRloBsJ5OOngixWxRwNIX-qzb_0SdZloH0Y0J-1GIQA4riP-NFHndnrGdbyvDvB5bTqxmlRg5C_jA4Ie6A7mvofklQ7zjZ71GTqtxYTqEesSDyDmu_OpBHMWxHzokO73V_vjsurZTfoGIE297wsTypj4G0byVQcBUww3tCOt7i1nrbnJnFVgYnlcpHtDMUvlEJzWvZLx4rdc5hJtOLcTRS0iQ15lc2IKe2kCdw-nT2JorLooChoO-edZzEuU0p9epofmkz9lFajm7yEehCdmT9Ql07zCOCpuNIY7-XhzCtowWlbnnx4oVwholcAGb0jPz9VtGBg69MX0kyn4AiNNtveZGXRgHA_FiABTW2PYqhJq_TT1G32ciK1FPZJBvAiu1yULOM40WFDSi6sbmfk1Mwfw_oIu2M81-R4z_n_ul6xeWNOAjA6nXRURsWh4lUo71gMZswTRtlued1H4_S9sq9FMQiSBS34exUMYGX2DiCXaXnadFeXOhZIhJCB2F4CVrHNoZ9rZ8v4VUcVYM7D68xNCt3-vpbI65ljQJbazy3ZpIKnhXAN9OnIMisqsXaxd5A9omul-d3Pc-N3W8FNhTm4lp9sXi8S2RiZ5FCvtCQymInQC7Ty8WrE-K3788mJEXjEc-edbrTdCUndRJmLwaxXEWUoOTtNjvWMKCqdW9mpJl_6EuvNYi0TnLduQUrf5RJ6L8HoIiLrY3abCrPOP1cIwySCvgo0lqrloG1THyT-sZ0q21PFkriKWpbOjP8FlWWdKxuN-IIL90ORMlSYiv11LDukEhHAPhfp6nRP58e1Ke5-1mzkeDLsbaxjopF2SBCO-Ul8yHosCr1QLwuAtNnMLE0qhWH55Ep4BYO6zO2RQBwiXfuwvsUw3zVgpQhnqCpFYItSCkZF9dQg1g8pUz9meTtNidnWdYyojhLm5l21sWcH0gim6R0bqYnl4U9kNazAsMToqTLWh_p8RWzOKrtvQtoXhwIpmRrtIqTvz6pSl--NpR7ZLSbP88ALoHMdumBQ4FPABFSyDQvEM3cLncatGJbV4BnVHwafRA2Gs5YVhczWlf7FQnbIfHHfsLi2tL2uFpzU9sGbTVAx1ObpeRp3np7bm4dFKcWRyW0KXkqjWHnr1O-9JPzIs3GaOLbW265WDkOlIFe34w7oE_ZTKf0c_D2Chcy8EtrBe0MMXTqBir3HwXnsedAaIECJ4Llrxatr_3212YGpVSEioZeh6wkJiy3KBOIZLY-A3AvV2XOR8cV3JifgFkuBuMa994zwfn-MFe5WFcydTWWykesqbV4yy0M4pv4Rg-H-nioDkHhPkFCRrSD19WLMxxBZ0M0s7f8YWpBJIbBzRwLcPeJf-sOOxWUuBvceQJEVmL_zuLWvHxhPGkjfTQHK1wwCTaxDvisCK8wQbwJGyZDC_o0LuRXH86r6Ma8r549sLfaQBI3FUMh-hpWqb9_wklxIZw0-pVdX4MXD3gAiGcZsER3DcFCD2J51qtJ3oGjtaQvhPP_DrXMcPWtylHGIngCiZsB5eudyyDwpdMQAklTfALLPivHu4oBEHBO1pCiKbMnPtswpCwxHp6zvjEk8Umtvf04DCLY24dA2nzFT7wzn5C_Kr-I88dxRmRTJaq_9n7brUFaI9yvlucJwqD3xaaWHWVHUVlbqUdPgj3qClNe1nuDpp3LNYSYSiNLONwmIfoPKm1qjE6lgW-yLvWGufyZNoOehWVl0mNKb5OivJL5P81b92oIsdvxNT3wmIOXR85bD9ba2SiePPIuAHfWN8R1w_TbuC3tXAw7v3xzYrp0OJEtbQJ9lZKu1lMYjgCXHPwrFTrYx6pQZ_cbQBsHOzcrW-mTtHWDyQl3wlCr3W5As84vZ6J3CH04rWvDEbPXx7tNnZnAddLksgsxfvFcFS1bWcHdnwkFDIgOqx2Cusrln8oxRBiOXF7NiLOTzBrk6RW3VsadN-Wv9Xk_EDcPL-fedNVko3BccEYCEkUWw1n833dQ9DMbtZ35jJzbQMGCPPd4ONkPzuCje8p_NWL-CW1X7jfttkGsWMK9jT4v926X1D5Lq995ANDi-LzZhDFMtnUGWeb0W4ZOAojKHuNXl86vNsU_h5mN6CzI4zowYgv-6rOblk1FkTvXRnyDPNQwMcjaKNc2wXqZOJtJK1zWy4ocI-Gf5BVHa6deKeaBqM_V5i6aATniUcrSbf2q4YyacdGn9UjDWskCUYJx5GHcKmjcemz2v2nQ_UPX1WbOH1CmdQkiPdWyXiOAzzhuz36enSLI2yAjO-_OtGkE2eXx1-5QMfDT7oxC1Br-XTqb6kPP5q04PN078-l6lsWRAvvebirHQgm_VxTqSckeIb-H8EKizL7NLlw1YyitiHePkWdyorg4TI9tDc6DFjyUKpbXg2PM8dR7ig_66F3AN2fE5YLxRM9iAYlJQm7UzJjm14uKUc5pEhV9ec09uSBeF2OjIzXw2_mqWdozQRNAXE6YaVwmQRQU1n_iyf5aoAxV5Qw3X70ldUKgR69v145uE5uf1G-qQL-uapzjjXQ0zQr_-IHMrmwJ96aib37n12R_wBDn0gWv3FbDCaaL1qdwDXgVQxQs_Jl0xnKisnILB_TYCyWuP3UcBsuO7FmkYp95TPOxsNqlaZYI8QjWiCxegR3ZM4xl3fJzHCIuHiQE4fuKKuu0oO5Sd2tK5RMEsyDrOgiNHMu3sV6IsVkQguJl2fVt74ZnNBO7GnuMaLuWfKMiqGRFRzsZnVfj9joB1KzzpicujMYfNq4KEiFR5Kxc1hi2kQfsjz8W-ND99mzgk4MXRx0cS8VpQF3bw2Na3s0XoG8au25XHkeJJVOO1oVN7nfXePQrHEi6En0kPYE7jDQ7s87T-eeqAxQSnXYSsQVY2AEiyUtelcsaLDG5w7vKpXqCIn5vHyCfY-3SLFEuYDd3Utp-8utXaZ8dAJN-tISmnZuBui9-o6VKabA8wEVR-UwtyMzQmjJ7L1-mo2lZqeRsBLaZUZvMKDLGyqpn0-x8RRr2BEs_3S3Nyv9_Acl0EJmgJbbPNOR0t2X6ZRNWD9OfkiFk6vS9v8D_h5LM8U3j-5sJKSAXpNO2j-Eam23jSDVqqyFYnqYg_VATZ_lCpTcdHK9AilW7i2rPI3tpRkEByqp2MKu8srOGi6JKBxl5Dhv_qqL9_g2dsv-WHviNCWSzTksXfW5g8SNOVOo7bAIdmRTfMUwrFqg5DZsLFhUQXLiXOu8tZgeyc5ltajdPMJogKe1gDVFB5e47IyINPkr6wPqDmDx2KVAmXgwpbZQVWtDEKMMn7KDc6lpC-Db_ZmYAN7e5A_REI9oFWm_4Rs3R3GYWFnQ4coOUZe7044JnvEFQLjzE3x0KcJ-zkNwvdGVZgQIMZlADEon-gz88EmFuu4NJg_kxaC7OTLZp_0TDub6Bfc3dDKpF1ju6TBI53lvkB8FAq_cPCo0yb8BbZbDF99Bpjul3NihfHlSVBzCx36580MpCI7PGm1T6a3kTZs1rTjnN9FVDAUnmKToA42LfEZmuxQaJkh2p2HalVFyb4O0R5QZHJpD5wN9WthpoAirOlZQnrrBWb-HOAQsBI0fRy43jYyCnQ4CWx9t_ckBK35MUiPRQcYDRuIlxI7rRsU4zbiWKF0ZgSWiHx42YIj8naVPl4tnbBTLg3SzxmhOJ_FBvX758sx0TE_YG8zS4ZekyG58tWCo7Dz005dkK0FGZj5EehsSxZJCTXqZbl2qcidoktsgoj2Aj-7XYILwQ61_ziJJ595KAPWN-75_NDikHI9GLWbqRXlIIl1fd97rEkHnxw5GFSrIxKWswoCispKvE8Du1fC54sK59vmRD4INR8LShbrY7Acdtrt86ZBgBLgiV3rM6ltMDpzSLD5_eUJamjF-NmMT1I_soHM-EB2Y02_8J5cYbyjApyGfF_4-rn7V5Af4N0MX1suRiuR0YTVqgBjZ-0jdauiMYQtoUYHIAieq39VH2WCu4dZKloBtcbjatL1gQ76djH4I6BgKGXmfGSzX63UECxBIQTs3f1RQ2fUIxKYIpd_N8h4kuvnFpQeGumf8mGSwSebCVdB9HAlrgPUyHeXhh-nXRiUij1GixvWBbjXMBKvW1nHlc0zFdeqoNVbjs0SA1uu680nynvJI5GQwIvNYWCWsHzUDbp2HOVJsSGph8vDQ3tX-YA88aOTNZyrmD5tK7ruM-0Wq1FFwPmcxjVNb6hBClL8yo_szGGqQMZCuU4evbQ_gYAOS_mtQbWI1Dl7Kbje4K409vljELrT9GlcEfKij_BLYTu1YZJ7fLJHMQKgNqwYs9EXKpdd-xZ72zxIFaJYXrmr66dZ4c_tcyw04M9G8UnumYIkejIBSAeBUZOmrmHu5JGHVyORcPPSMw0BFh6lNXVmylHA8N1dOpfI_RK2E-EyvYQFo-R7XJsIUNxSo6HEZSOH1Q0oJiYKBORs8Dc82zQxRgAdQ3n4Lk-k53UhFMq0fzJdjSzljkSR6KboZpN63t_UQm1xvofOGJ8CGPdvhzTaCL0yikPtzvd4nld7wAYvqCdOdgpqzOrERMC7mvpK13Hx4jb6lnC6rySU98wsLr7-KBa9cYAL0SirXi9FEpkHMpr8KFyxJoyuBlXPGC1jREi16pNY0G1JER-kgPHQOdkRbP7xHx1fYgepiuaIKD6waistGfLm3V1T8z_D-nHGxpoYkKGT_s7j6cUi47pQSqIXaTB2tsj3MEyVgAsuUGMYMiqX9-fkw3T4ZvqwReG5NDEYYx1FRI3ufeI-KBOsg9C1RPnnsb6gF5XJwP9s_t0kKjs6vmgxeBgiqsMLQKVPNrC-fxy0fF-qaT0rv2lMuco4NgZKN9JHVPDUQzN0z08IpOqKZDtJm70mGVD_Naf3j82FCxNtkp_MFpgidYhXn7k834ADN7uFuxYj5wLid1mlHZrZzXshbsLgNph0d_t_eITF8cHaXiAbDAvbE2J1ux9JM1VOcj964ae5tn3LYgQu48T2jAoWK05_yRgKrqbI8sMsPyWdvZLybpjeozZhz1QEF5p6bd5kO3A8ray95GZfmgwRfML_nf2WM7I4KeZquVJilUPk5m1OJ4ftjNjNspxAk0WFrs4it9C1cQqt1ILvHPt6uKSC1-NhlJPZvixkGkrngZFPWS-MQmJOWqd7eOMk3qfbouuNEEeppAFWgvJtrqTCMg1R1Q6UDdjzMAbcj4Al0L4coZ4lZVOnalK_O-qACmosnfdL1uJrayoACw8jkA6dTRDysIU_vmup6Z6mSfKUI_wTn64-KPEj-nlapWn7JPyQ3qIWiWmyFmJPjxmRN2nEQG-Y_aPohGQHg8NBIC0Tmimr4xjDIcxYCjhnFCzSIIeUGy5s5Bn3tLCw-SkjSzjUMDrOSEInAsh2vuaQQFep9T9q1N2D0l4K4-Il_ABHGkbmBbqHVCPhkccqqn24rzaf30XRvuDjRvPlfkYTxYSZJdjx_qF19Ry8A4Z9QaAfIgzjer7CZgT0TQyZZOvPcL4GYpVtsCMwjNX3GrP1mxzKzvx2469zEebOm_wnzDVlhbCTAqfiMd9N1vm7PDjrahL17IvdGsb9VqQU3CEDVZgMRi9Z_-k129ng_80zHEpOx6YcYJGMbfmEJxrWO0UQy41Cs3cvbRfMk4vNBrKM6OOEGKgKwL-FPCGPez-KqS8Ui57Hpy68tg_e1d08yB2Ax7-mLOLfVSLcxw9lL-BdXevfmoFLbCLd422c1HtkabW0Va8jz7Todrm_VRHo-T-YzxoSViM9PFOsvB6NasxfPTnNLExjDBQXA7CJAXm_oxKUuPVP27SaLah9QvEGDsZpATe1NjTGw-AJ8uielYRKBqrhHfBJ_DS3hOPOSk2qv9rHgLyxj3k5s_DxOb3MlivQcVV5jmLpM2Uu8Rc4BJUTL9wiG0Ti8XYFKUZ_W7QdDEUu10RT0BGCZ4swB3rbOyRciv2TzS2WDAGhMn9qJ-48UmtMrVu021LTS931Agv04bDCOZI6VYhKPIEZyvNH4rY6HxXNEopJFJExQRup9emTAUVJxd5DhvYwcMOag-hvbNRPbVakmc147oPaJ1TNMjGuqWcmYL6yMIeFUJr_URcZh9xHSTr1LKl0SEIott2fCMyAQr44wiZNXaZfoYURHlO90c3hbHVd1WucBbwzfg5L4Dc2veiZC5RueKwsQjR2QfyBgeHnOVnnyDFFYYj6NvpziWC-26QsW0_-G0sS9tZ8AnMq4wB7V59hrSMFuArYX-_a7LE7QnrdnTnRitx5YEqbIOZ4L874Uwj38ZyakKH06DkmIlxi71Djy4MJzRdM1FG1yCB2ilP4i3_6xrj5g--JJyWrEXUkAurIH9eFZLJdwm1mw3cwYMuvZeV7dqxtlsX-PEk2MAaJbZNDpew8UJu5GQKnZ5sa9nQEQ4Kh2gPyrBPu3HlMNXVcpzS5naOkpsAYKw1QFeIGQrUb7DZjWxpObzjyxFQEeKgBFAGV21X8kmn5Aph1NMZ7H0gNYGphFId6gpgTtaTbmvSNp2jvWesYDNcn5tSMbvpjsBoB6LRzlrtZG4eL9tInHQUf1Weru4lugtahcLpn0FgxIF4AFauLDNgeyg8C-0C8HeZ52RbQ4qoy6s6RT4vmVJEVzyFfVCNEc4eYTZtTTSJ8pTnMR3CUXSSc0ml8W4c7YWrujS2WxlqPF4tcdFBQXzaEda2sKxj7emrYbgmfPrRc1BkGKpAGXy1NATbtJe0OEC0YlFpSXINYpjrM37nIrVL81cqpmASkFVZx_raGomYOtpKUjsn8W13bwAs0QSKzGx5CuJ3tME_XoEZiXSyVmlhbTxcKJv8o4cHRAM_iOFrjGa90m19-lN42OlKXpz3AniLaeIBz3lakONjH69-u43gXWuFThJGPavq8bMpoP1EBGscL4PP1l6xVahCI9ScHhGMhuGPWEIhYkUHCj18u736kWpSDiptSYnpcr6Eq6EzK3ohEcTIhVf6GaqDwtct-vKiozMnnDPeW9-uNi0YO6dOdxyVWf3OqmaQviooGC-_LHfDSnRKLyrqUXS8LcAEAPXS_irvkR8vKfjRMhE_19iRBCj_yqPWyfJ_0ND8LJ9ZwU4OAbEypB574w3iPOHYtmS8YgNqotjn9cTsdyK8-e8u1QAbZdobjQ3wa4aaMomOQ9TewR0bHEujpLfhuozCR58cFOwY1E-pKHCTx-4tdogUf6H5bORai6QI476OJLmnBXF61QxPiH88lu2ZCjHNiMtcRCc2lBC8i4mjnjNgcq01Mq3peXRjk5jq5Vut0cw1f9jl0cW70e_YoiewNmNu9H7ZHbWUzhOx8f4NAlfe98PhoEVQwHbMvssd6hTASF3dN9E9YJoLI3v_sTtI9c1nvivZOSXOm7Hvpm69z1YEt0dqz9l7nB-Xss-Fqa1qBUsNs_nk47dFgtUkZ5nd9dp6q50QZfDVBRUS_hMkz8MErpulrOIpiOqKhhnOhs_LYy8pNIt1mvFYyQnegBDEsuNDN-hWVhVI2soDhZ9FXybFB4JyIuiuXWSZXWGSc2cP8BHM6biZPez_hgQytuHYdm3helrUD_oy_pNUAPv2zglBgnSlQOHQPO-Gpn6Pfr3VmQbuZVx3wgG0sSIv4j6rYQ7YgEj-Ug7l6dcv_auESgq2u8fw7Kunol73Q61jGMWmcTPAe2G5JyQ6MZTKeMtOOzRmCTazwE3mR-XKAU_mqvNa4Cqius0ILzTS_NpGI4OrCdVSN_SsWKgNVELrN5F_nOXwPmikJcD_iqxK-_uYSQ1u5deG0ne_7yKUslk8dL7FOZbb9Y7TexW9BMgf-A0rImJwednpAo_TbswJqf5CjIWd5Lc0qzrIg0Tszrf_taCtNc85BAyneK0qZfj9Yo032HIrGsyDACPUPgipwg_GFMvtIpXEPbwiW93OH8COuKQ1XREFfFHSRzzreprmF8Xw2EzXPZnAaGowQsZNEhnJw5veF-tSRXl7_xRpwovWz-Gu-thPMr0e8dzqzGhsRgD-XjGHf1BUU2q4aVIhp-bY74X2tlf9H8v_3K8HHdfmbRMhyjBrx2QinWSz-FsqS18qP1fAjXlrtwooYS7LURfZLokk-bJyhY5gAreIYg2qf3cb4AEV4adVVo7ZwXttYFf7UDIwFcYamtULRC02Nz3qAkUwuOVGxyG8UOghSVDa6jncVZU7HkuVLoOX-0O1XFYwYOERoOnsACA8whtONFrHPEgN7upq8t2tg7oKwNDUEJEcILhpYmErfG633EO7qGvez0I7x33sWMOe-Sz6e_1ns26QMRUrBBEZ9ToTHBfYx1fBtMJi-qQL99431IvWLaVTeU7r07tAqVgt_ZRDy2HyqGqxTFL7_xh3udnkoXt5ZjNg7q4BUMdE8QnVwcGMwiai8Nby8r_jUu9K9dMDrnQHBqpeo6qd_oBBaWAthXNUPLP9yeAgQSTfYL6s50fnu-mYwEmRaJjkc0-UD8h0NtXiPabSddWKDUJpZPIM2E5xI7tQmao9plglKRD0ciS1bHF2_Rjky0CDBX_Oo-fwy835NL1kU2XiXMex8TBgiv0J3_dHVuBS3Bo6ptJOmcaJMDVSLBYhvPa-BwgMzinGOWxcwws7N9sVOrCfZ0rpGssyNgy_b4W0Hy-RXxf7qCiWPIPkpYoN2XVV1fZbR3qPKe43PMK2QQg4LtJuLfEMTsm8UtmAtdLA1782bS6Q0Os8Jiuw92Ohyo8na28W3It3btBGlRTLt4k5b-hBAxhdT6GW0GA2tDfl2hYDMwURX0fo7-CfUfC5fHZWfJpwv1_TztuRx_9yU7CNcdYhHi7YL3CGjK5qy-nCLyhfIagyHIgkI1mss4hZFDFLmQ5pd7G2ozGNIXfsLEbO1Lo_88WkzMs9kprezy1y6VjSQ-hq3b3J-Bt-hqlR5auGlkEfDnHZ-bgwFVC8QUy8sIMWAwSWTa0eOXrgpRLhpFdsfokqSUAD_md0tB-7yCvUBPFZIjCEQNWTqh3Ljr-8fc52wY7tycFio8Xt8-RfRKFWt_JyEF7_grydn5iZx5ZtkKCmWI3KmXqI3TClbkoTyolK_7m8rcadA5FbLku24IbtuWlBAiEboAfeFvniDb5pcfYPr_hiaEWNV9ApTG_Doz8WgOsDx2LXKmBYPDORRMl0LpQ5_Rct2g_KyspI_6qzuPvcE8Qkggm3ysMfsN5etP91Wd831A7A8xVrER6olptaGqfPUXNWlCv6EBaEjE35ROk_SBUFMbEJpaZeBK1nG3rkrAVr3utft1iMmLptS4IN4s7YgEUhc1i9Z0ML5LzKVWLHNrZEkJ1xmDGfI_lMIT9D1US1MmQRhzF-f3-XVIGT9LmgBhQBjfXKh2YLVgwuO6Mc0onk7lBWo5xFcLxzHkI9IR1GTWZm1GZUVqTLmym4hbS9JPafp2HvW_o5XaT5BCaU4Ujvn3d8VeqBoLCmaNRmiwIo2hx30IozCs8Ja_37WGEHpIih3ALpXoKbGb3OtVvZKppX1JOpJI05dtV1jqBHvtaUPbU6jhKgkheOr76fwZs-2xzOMBJFD-TDShIqyO8Wn-s4XFr5mzDShnKDQAYvv9dp-k9JKyMjt0qqRLxMjCHm_0PXozrJu1eYKF70hmlDxvyZd3tCvvqi0NCSSaXj3oMkMDhIJXG38l7rSJ2H9kZoNix-EffA0bEh1b7O8ofA5nVQqYTCzhYUiwfNlhfqsQ4h4QdgbVf4p6U4a7ZFLTt2D8K9EDTRaifuDwF0ZkJi5-w4CJXz7yqTFNQAdMq5H4ynNMQX1gkrrUJYmFZxHPERKbSmJVeX76L5HLvT028G_rIS9qFHYJXTGe4Lfuq8hKvLLIvuJbAkuCVCXFRKO3Iga1lT9loVfNTh4NgZnuu7Ef_DBVqGLXBMm0fx_s_wJki9oWBPc9ga8VOMt-YC_Z2aDKZS82jSO1wI1730zd5C-lV3jOl5On6BUupFLdfwzMI8ZCwtFFWrAiTNp6JpNj_J-ff4WJN2WcmZLSOtBPIWtKpcro4ZCBt2iwItWGet8BnIbmeKzz4-j3SFPfiZ2qC-95nU-T1GXxb3AcWcMpSB1ibSFFSwZrT9_QGdHV05HsOH9BGhVNMQmGSBnDnGeimjt-XEGGYKpWd5ZqHJMnQjVWWCWckvbVKKNlD3hiz1u7G3WKPC5CBoiXPsbXy_XH_oGrOvPtA0sBcxSa4LwW3mAQR90rbsz3hRuP1rSm2MrfIcqISmkOPE64P012leAItDLLcvfU5PxqRsCh9lCbUQEqKOzITOPKxb98WUzM0x9hU7z-LgBI9Yfs5-nWz5eUbsldIGSMKeyW4r8lBYlIqyAk8bsQRYrqft-6Mk3Ow5xktcOmKS-0CjJst4SQ0lWvpLc3rAFg2a-fYEoTK7WwfBzGTpFClnWDarRNYM8qd3sHW-FfA5SRg7RcMhwf8h6a-A8-NLmTq-Ay8oEkV61OdldeBCUn8dlAvsIyHekPy9GPyxB1_08Xk8gtB0y__1XKLN6ornOSxkMAwTIqwRSuhEwjRX15tzTzWeNATWNmshpV6pJNOvbpQBJXSsT6lLf1a6Wb4WxiMMdhYLdpuZs2JJgR8kwm-hUFvnMbs1ADCmz7pn5V_UVFW47pj3-M6cMhmtQcYvh_IPQIT9Du7XwsrXXwBqdB6eabFpPcnKILZIyX_Fp31EDV8KgH0GMKzDoYd6HzyT3iPK1HB7NI0D1bDK7MjL3HxaBsr_KdYEdoiETsQB735jz6P-G62EpZlzX_N5Uv4pItcOzXso8EmeD7RYGKIlPs0Knf24QorETScOspyESiJU7EmJsuf-g0YjvEa35L_zY_0JswiDZdntrvpqGqd5dE9uNmD0V37CeBB8cyrcnA6Iku6cOC4BjLEnh70G-aYawRg4svOHHxiKjt5HaOWPXAZoLtEfdT-Glb1PpyaYWOhs9IrNvXZI6yLMu8aqIoagqOu2uOH5CfL_37RD2LR3pocjRpUy45EY6Q4daqfDTUd4f1zMSX-FCjynUlLp9ei-2weYwjnYFj_3MQqsXs-3w_QaLiV3BLkA0CZ0KbVr1j0_bFn1eODe6DZJNdOc_ZNHDEjZV3Iyh3eGjiHybP1121jbSykmPuouvxHNRROuhplq8gYcY3MmVf1wNmm57ge4PGk8U7smVEbrCSuhqkagIeAIrN6jBDvSRZsuXIO7UTr-UioPBJH-p_BV8dGd4JueOB7ddx32YC45ho__c6YfBMZFmuES0-RkpKUbrdg7pVkJkjVWDVDB2B_upy0PkWmQiqQBh_BrF07xJbQmDwHofpUpGNEHbzP2mIXYDssdfqEwcQ8dXuktbNWvcFFJMoIPJN3b3BEXz5vnNytxLaCg461ZGAo3TtevfHQ4WTxP70DNZXyqQWVO-j-fJLR5KuzZwIl0SzcdSNwsW9u7TO_gYKVinq0Ywfm2JNy1GoeAg9lHvPcZ_iC_TOHRCW1PjuXPNKnWUruOBGPRxlPYD10TExmnueEd70tGPYACwi8sUZMh2dxR-EDwB65FrSi2GP_HU54_tz8mciZfH6sb3f4KSLy4braEkMnZRE3g6ycTorLgi5lkGUr4rJWMhr8Y7Hj-X-4AM4aj_gIe9UnDDhJkTQjZWG9dQdzRQ2hVU62NFHfohEEi22H2-LojMVaB1o1J-zez20xcOFP7SS6w1habPn2CDuGsOIwcWMd80cIngwwOfYpKqg7yfEMF03OKdsba_sXZ_jp0cuQ_gWgMzMLaP02nReb0FeNBmeamMw4lN5NkXecBw7rGtZ_s_66Oc2QpU138WLmAKFasJEVR4i9vHKL9FymxLkHe0KCUVR-etlmZOwes3UFYfn3-VJBsKHEa76HjHdkoH-6TAWQzklqk3JG_IFM2NTPDL4_pcILq0wEc5IUf3u55UMsF9rUWilw3mJfrhl1Gb5yQZMGa6J3lCPecFWNKiYy0Nfp6xI4GxoKUGrit4_qNdVfaL4La6DrSlVFZnVjWSJg9APSEAdYce6-pLLPV1DUnj1rFgjcjFg62Tjfjny0laOzFjkdRO6g1qlSlCuylnr7JVJ6NLNucz4Onii2jft91RDMs8MeGvaQiAz28QttN8CUUEtTdfxlWy8tGKY9s0aLo3dyL73eZr3R9qPZEMIIYgCuSI9AXy6W_emUZwf3BtRhm0cq7r60r11cIdSPXb2heYdyBNT8eTNnBx8hMF4JJpYiDmGLDYiGif6wltQYzBFnrnOUqTHa9KjCuhR6V-ulK3RVnEfDNXvXf7lk28QNtl9c9057OVvYkUY-yMMHKDHvzgQnyFh1X7KmFFa_WnMOmNX3v-6aKzLplm_PaQdUvN7t5L_-MyzwPvuYNVFHsU2Cjppe4O0LwP0ULjiVJ2l-XY0pll1Ix0g0MZzuWs6MBBDpylOkws7bVuFYwrNbHLDnhjhO61ZVr-HmunYwxtZzYa_sJY6nl7-IuaSqgPTcGVdsVIes3PslEnwMSpkLd87mVoZfA0Hss4mPtLYrGTpLEigsDE7kG5VP8TWIOdQpL5RGy0Wv7w20e1_hcZ_Qm4kHGZIvQlQMBViMQ9yr50susgcm_cNW2lpjfx2m4ciWKTgk_w4vNdpt-pC6c5caenKXAFMCWDTQRgSWwsyirsQk9bFepAAg2PyxVs2aBl2igTinGfuTjhtPAbOu6cFaxIxUejdtZKsbuCX6LDxsebL2EGBcJamcRIMguH_-hghJn-70Ghiblf3HZ9v4ZbZFetswDaCLERIUdESI2mxfRsgjJ0OhotieTto_K6cv4ougpE1PwsSxBVsquwHXhILMwnQyd180J416YiBBEs_Kr6UEDaj_TQamjtYTlxKfaqpB6AE-IcJ_cf9JqEbl2mPqFd-hbk1qgAHOCuUjM-PjXlFdLqNFM5HluyWY_w1eDlhZQ0C8F6PEi9mgNqNSQiyC-4RGINi-35iTa7kkqgZzum_WC6NMPS41h_t8hR9gYzbvLXTKX1MOHuxrM2Wv7jjsd7h60qnnIvOXy8c0grkNEmiSNeuAE0x-t4TUdVXnwQMX0oh4bE3sp9J4wSmiigYqXXbVdZf-3sXbP5ZrhePA6N0IARKD5zGoLWcJgxK9zHQ2SeU7nmTXJF8moLs4GL6qgwy1EvH3VaCFZ7tq51pB0E0Rv7BNnwk37O879TvSFi0uYbAbvRiQ7Z-tLCEB_swW7WKscIo_jxMlucodBYRG9cgx7PiQ1hug7vraLVjiVgjNgmHH7y3_SVEFZH0DZHQyuj8thrPdYEV-MGxPzPCdPpbvirMO9x9UF_SIjbF0oJON4339xXwpUo5KFJuhgQDj7sf-ScAaxwRutKJo4LS12y3vQZPqKXAA_FmlNmv0s_dEoJpRDHxrKEKDq2Xeps9IVoQdiOHWQJYNzOe7J72SNbTJ0U3uladP2ahiWm0jDLhlrzXkMD8qPthus73Er8fty7cj3wetwWCGllxZBGr3_haxEsoTqmltT0EvNRmOR8KZ2MJpnNT7dgepPsENB0xheRWkpdXXOnuQqBZ_ehs15DAnRbURVpH9EaC0CCBkSJZ5aHGiOo4gMukhOEqij6qgRYHSex5Hh1VDyVXwOcvspZDF18g0xxat7UZtAIbcFjJD7KxBrvnqnijvw2zVK8oFrOplfgr3IR1os_fwtJBmNXpshsivzPVuR0B5J9XYU12e830vAZ3ckaSUCY6T_b8kI4MTYBO1Xo7tg-LrKUSq_QWQ_g78Gxr93WW9uXOHjeZ5rXQOFKCR0DnjxTDS8HHpswrIUsdj67c977hYwVHP9Q2vqCopz0yMGGXzBifMgHdqKSel20XbEqfQ1glV7C5cul4yx7W7wy5jLlFPTeVhkO0C9sj5xy3r-oKdI5_WTZy8iPoOj8xpgJ3uofEb2F1bnnf50Y3Sd29XXGsmvxwcsHbJ1X6Q3Z-0e3pMW0eTNSmP1AFwLUCokNvAeSZ-bITt2KcruBV518-st7EVn5GZJUh4qR1CYw7jbasjL4CcNkPDEYg7eY6KN_VRVXWWDBxqbqzMPCo7-m6f8hBIIfqdJaGbeWzTlZEdnxKTM3_JWACaIuts5aBPwQ3onib6qL2DtxIO-2oimL0eBkSPkZogJcrLgQPqT-lOy80jf9ynMy4JVu2vcAKXfANhFP1IV3Slp7v0_HiwVJCGj0tWmPR5Zqs99BVsQuAdNDfdYr--UDcAGZYMveZAySFSpJbspX_ZgReh4fnXb_8WMoLIZbwWDSDJojctwhFNgQXTcyq6C9eP280hg0rv9fuqoZdl7V2ryrNG8Nig-qQrlVonUlzr4uooAy9cVhdtnLMiNq2f3M57khxvjxUfiJPaZIFNmHHQ4Y0LOn4KOMaABbeZNwC4R_KNi7_aVuzA8iU_BcoVGDB7CcBhjl0zihvARHoUVntaECqxhw8OtlrUAOgKzXxrKzEA65Y7hoKQr6XPHURitV5CqnMdlduSewXe7wlrzRRF0Y= \ No newline at end of file diff --git a/backups/backup_v2docker_20250618_021107_encrypted.sql.gz.enc b/backups/backup_v2docker_20250618_021107_encrypted.sql.gz.enc deleted file mode 100644 index 0bf332f..0000000 --- a/backups/backup_v2docker_20250618_021107_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoUgQbmM6CbLrpZWWJWS46GwA8MKz6UJLdyrLS1r9xJusBmVS5JxDTANoDoi_RvjrL4LToqZWMrHs-bnfp76ZwzJXhvAWBzCImJy8RnQdHXr1uW4u3iGE7_7xcktHKC2kR2FRdvCsaMMpj2ZGmtb3FEcgPZWO7jcCPFHWv8bIVEUr54A3ZOp1Y1tgTa3aDptFqaY9ICWrJ5sbXQT2ZOhDdXumBajrNmyQzNjgkdU15TB5NHh338kV9uQeOzKcRbgCPzrYt0h5rClhdmoxi87Hf17pknOL07OtCdZNpFJ9_shixnkq0v_U7wpSLTst34590WOv48IjJ1wUSJC3zeahaQoQlo7ZJOy2hCAykCU2snm3TgQ11dlMkMhdJ8-i2ylOdZ7Fsc2b48uzdjDo_RsL-6WTHThvnQwMnD78_wPwDkjg9w55cgGUTXXwJPJ54TjYuxES_Fb-OZePZuDqg8F5lcDctfpppRm88SYSo6BZsCZ5QzKyML0aQZeUEwV8ok4jMEndMW-H0g0TmBLwHuDRmXdz81FDRy_PR5Dex3QFu3LwIx2lMpB67sMuv3s-TEl7_cquJqexNSjvy3XX3o58_SltW9yoxnc1JN5_TkeRJ1CUC7aeVHZjy0BbtEDVyufFjBLnW2P2L-Uo1g445-5hkBZgpVwwebruOUam8K5InUdJC5Jsrkhz734Cpg0VEU_60h6Y1iWicMd8kXy9jFbG9GoeI1ERz3Cfvam2I6crsx9fOA2A-0TtacHX_G6ATE8g1W-6ZHAkcBdlmoMkWdVX-2JtCCYCLNvXgnCnJmi4D0bIflZ5FYac9aUQm78wLpDB2GudvoxNte1PxNvRZUYKCf4UaH-C5XSRSjWT_ooj6jw4LyICr7nnB80fMD5g3gB7czCCzx51kfJ2hIXiEN2UYrLnbwV6Nu3PZb2QtOqWBrNCxmfs9GS9Ts88G7hq73RAewoBjBWFKnT7GJX_TlXdHA8Lv1bf65zK5o2VlFhTDZjZdplr0tRpDFJWHonG7GCWW_8X1ppttVHXboKnfcXzc7OpmS5I5LGioVFe0-FTtkiC_VjW8BeaQLdNapcf4i6GLwr_vHU1yZLY3IlYEaVtIW8FwguZZF-M3CciFxJRy02Xp-3IMq6hW5400-lWys8iEtYj2hSS56o0bDPJxe2tA2Ri5keMABKg0fvSnI0qm_uKjIiP9XRo12q5g3lg1Ifw4lz2YQbbY-QXz3JK7dEEGVD3Bh1FE8gN18PYrbO_lwV5IzDwP-IQjgAHKAfH9A3j_qRVs8KrgkWW47p_gpnSskATBYDjeKLJY3DuiGg_7Vqu5ZLP8i9P-TMAWU3A6RemXYAClSJLc0aNIrxFGIte1HCkoFYeuv9IBHHd-cji8CyE7PS--gRyKaGVJojIwYiRJIiZImFc8QL_iLjtebhLEr0aBjM5BnDXvNtCx2VaK9KkIkPzgwaic_MVL1obtKpv98ZMjOOy3YG9yj5PmZAUCdFGmdzDK4sPqqb_-sP3glDSji60mX0OjSuHyNEKZS1FLYjFB2IHCaHdozDt40syKF5ntdY5S0TtIxYQYYXWsXeQJIC-kgBVwas2RpsKpJJHSuPhk_n_Q-Sh1GDHpZlm4b5ETvuI93IGxzefUReUGAPzWs1hSuhZCfkTP8dOigTIdQJ7nsgSy_PTkoieRfluoUmJyihTDvQpWML3YOuvbtTZF_6xmf_JQqZjwemljauvLBBWzxrQtQVfYEUnvbfFJn8ryM-XVrvX5CDuBUAV-QQ297RNTYz6iPtT08GXhRCaQ5ANXlBgLtjj6dhTFkMW5UmbXeAQl_vVwUjVB9LcK1nkDyTnGCUotR3_oozsnXI7i63qdWLuDE6CTkn7FfIlBaK8CsZtfvV6iJ7NEezXojaD1_JHH8Wop3iO0bPEjCljaSAJLAzkjBdXF5YziT_8QuV7R2I7-HI4BVC9y5VIxVsSbSDDkXKwCt6-HGo5OA75yeRi3TS87mSs65y6ryWALa8u6ss9SnD4Tl_AEOJW9Cj576T43LEBbIkWsjs3aN0QSw9_XqtStRZ3rG6HMmHHz-Rdw3Rrylcn3UpZR6M6Pz1lERgf4GqWOi2goSPQkMs481Sb7Z87OYMzVpyQkIg_KIKfK2Rw_5xprHC9NJCfYDwe6kiJLshMMzvITAxTWWluy7buS0FEViUmeasySqGONs8bMMtCkVySgz2KxJgOxJWWDrbfPeGp_V1FAAsYoPhvO8NWUZXIEwjHazhjzT6xb3AvYpWDB-NeudaRdUH5a3eQCQ7n95zymMdCn4f_07oLTaOXg8WUJmS-hOZ1EWEv9Zy82IVl2EaeifgYPL9f7hj--o2zGNk46a7GWMsVWtud0Cbda8Zsj1OiU0kBX4caq3goqWIzLzCVN4zlZJOhYeNTjtnbHQ2CXL8xJg_2XJdmSqeRWlTFXtESl9RGby_qsUqdiCCWPtURqWDl5XVXHBURlPFoJoLnkeuN_RbdNfoq50VItRyJTi10XJwmrACjkU81YXLICPx8YSBdgo-VaiewjVq36Nm151CbZBE8stWUNii-lVwSZxpBLoadMI7fpfeG9fy3i6mBTiMLrAw-xKkb1dSPC4Q1jLZLzMoaEptqn6g2mAkAOrU3m02IjDk-XmdUp40onSVDBPLR1K1EKRrrvd-nwFB4raotAWAmhFfKV25g8JxmVYy2I2kIPE5CNPstedMvk-H3m1SUBHPr6ZrzeNHvxYNyzFFBsN4cJIGKWPcbT6cJH_qzQtkmzoiW5KVeUG4VsZjgUM-8pC1bgwRLoIrPDzeHm_NxyohX2yQPhtjdPFWel4PJBudXlL_aYEvMNH4i2QoJomGFcm6QwEWt0OjdX0FlIPgn8_i4nAuL6E4uvAeHFRwIXOUd6FRjuFQ3hT0x6ylWY6CsujKtECP9YKDkGKyulWBzbGzeYNReSOqcRLodMWIBwAPCvrYxHEMmWrXnEBVVnMxpDIYNtqfzvpS3ncolYeUFLT67NZl02XA1OGyw4zd9oL-K4PtzKICU5v_ag_0EE6tM5G4HVGNlgN0cVsUpmzZIQeVKTkHKihURoQZvXFSdU3naLZptYryEVjihrMoKukZ5pz4PNWc3mitpiocr1nrMh5OgsDkuShxaOGQ6PLJCpPkESdQkWCWoT4-SI6H3wxUf_nmJ8t0lBXJojQg6XAHqYXFuoOrHJ04xupBbIgQwj-zwUY-zgTCg5Ecc3PXcFiOSedebsAQhMKLtClDRs78_ST6Q3brk9NawVOU190z5GesR20a9D4nKrrWrzoVbXkcyFzMTXoi118ISDmspU0E7KTjrJ3oUTISGlanc8ODlZNJ1bAv4fJbBj3e6zW6SUgwYVG5ULvJv9an_tHBbUrQdzfvqwW_m5bgXLveJzeaXLYW10SnHgsFksKYJv2uefnKXutuMJUK5l9xSI9ZwrsjEemvaq0aVMzn01yD1eybej7BF1hGi3oU23Gt8vS-mkF0oOjI7mq1QfEVyKbUVL2MA1lfY3YTPjyw9escuBEJjjBUaL9G5mObdfxiBmeyTF7BdY3sFp_YNUwdx29N6DB8iTyVUobd9f-sTbNctLt5mN4CxGsLw-79ANKp1wp6bZ0gQWCiBkAKXhBIvTdewuGSsy2w4kKm6NGaP5dctFezdp1loAVr1mMVlXLBo67GPmTofjOEsgydc3oz0xB-dRnoezyU4slpAVnPqP_K9MZn9wSv6XfjVCQ9Cr11tS-_zPcWXlx9R9STXYy2bC-6VbeX4vVj2aRkdtspCVlgpwTVWzPcZlrJHaPPbZakQ5oi4Airv2mfQfXqYQ2-Czp3gY6WjRdDTO83kiwSiaJ6a6G8q-6ulk72kG0VHu6DNWFHvMA4Ioo17u8DDzOTg-UqmIzWrp_rdcFyVl2TlCmHDhzOPd2r16G7zeLCHr5DI7w1EGhjHhx42F5WQ9DycI-O4jjh9eTaRuZ07b8xeRo9TOtkyndOUFE7NpSgA3SxomsLDdn1WoqDe3DewHZ-CpkbXOpGqyXP-BjY7CbAlG-CkUnTdg4lz4vnhZdT-2REDbUW89bWlYOel82hhdDLdKIKiNATkz9F8KE1K3JkCvUkA2ze1WbJuyj7z0Jz29yROOo9VsN8SZdtlavuTK5ZvGFuDoC0mRuxd_Q0EunLrxo3PsWSLxSLbPLP4ze1xxw43lE2VDpG4VrHOnEYSMvs47E7arswtYWdQ1zSnEJ-OrgbXY6aKIfFV17BG5XdpswnCRC8UpLw5oh1Us_4GmtuBhb6U0mxvYx5wN4yPs_ZdDBWhB9EFdNReUW7Q2heCZEfVel0LYbyjxeGs8c6Nq6UNWRfviwhflJr5CU2796IDhpJg4qKsscdkqcoQUDWvVgqPRldQ3rEBoHNtI_-7mymvkoKvQSeXX_FetpcT0RfOlzLmbE7ZotlyvJFCyE52sbnhvT3GlcaZ4Jafkzo8hqc9IdYcZmZkxpGrrQQOK6umVEHTM0NetGWclvQ_eO08hUujJi6W6oEhomlTHkA4eYaELvrvucdJ93pO8ft6Xjwk1yGFdrkZSLkohjZsfsJb7TA-Hx9sOjjJsuh35N9ftnCAlRWMVf5SsNrrz1slpi8gmF5t-7gWksyi88JUUFKEBuLblBXVffM2LEnl9ovnRz10SMSxKN9zmu7PSYsuXBJ0L5ZYHauRbB4p9orJNBUSzYjCOO8mFBveFkwf2JkGfy9oZ-BL6mOR1Kl2NcWXdmNaie8P_7dfHRwiGiaRB_OFkZakJevwy5j08pvSalAm0Ov_Y8V6-_7SddIv79K5lC4QbcY37shTfuTMOAaWbN-oBCfTRTe8ZVOAeWrWH52hPzkoLbQ3-3NGCXoxmmwnqU1TFDzyC-idG-Xhah7RRUBWE-UEE2auAxg0d9MANpYr0Z7JGMrz7MbhX9Uk5P6xiCgUHFprcW5Oy9i3BhX7dxJBUb8cg48WgNqDpciftgbCLdbNthql7ZsHmvEXPpdIF9hOxlfe1LCrdWBXrCZp0cIuHraHdqDhcQU35uRyTmP5LGC5-PHVZmwwVGh_71X78H6vpq774yCviVc_PlELEwpa-KxYmLOiqRyozOgSyNigU9Owr0oSWPjZHTNuncYyFiwwrVh8zi_Dj2QDK5KQMVFPhO3Oil9UUfNESvwKaHZ5SZZO7gYOiMpeYWxM-Gwq9EccmjeWo8ELm9pEIxZDbGw1FPqaRNYnoOoFZJhp69aqwZPc97QiHqwKaTrhASPl6jcz7W3vlHx9kM9ZjkHRQBtSShg0PQTvo70Mxm_xhNWL_2k2vR1lwb8b1_a1JWuEUbJidhNQq_H9b9yilI2mZSnWuZwKylfYH3a--XNTVpFH3piJeTfODMu4a_Rmzk3IIsxTs6bHeRWNYs0nCEYbmEfT-SdPo9Yf2slKtt0XUd4bG58SZRNYLD4VmMKpa1iv47Ao7QuZt1Jb5on6eyhY2TlnGZdRUMkVY6v-8xLVDcWCl_eftNQBmQI2gK2PDyE-3WJco1YfYERTCUuWhVS19aCQ1GRPmWhmDIh5NbniyANDiqSabCfxgy9ed8boKhqqx_m-9pLeL4MuDlzDajHHklQaph6TqFOiLIDqX2nPXG1A4qv7z6yZWXnVK1pYRqvGYVzoHAqLuT48hHZu1kHkkqzXgxQS2y6HwIFF-FiL5GzN_5rdkGLE422NEcLeOHs5uEg6os7ACU3PFBAFEojZIfP7hZoUfV5lvluUT1wLVSyMHMn5dFqiDLKDUUKybHd85w2wdtW-V9oBfTxPFq8E8JEtMqH1npTXUI8ddLE-Rxvf8kaMBV4Ew1wlpgoK2rFzdNk2EQivDW_pR5dgxHvoG9gAU9Fhk1QQIwN2UfS5pOUAjHnVk8cvHD6ETCDMoE8LnBdXPRb7syKuJjliYTTz-Ngi-0v5z56ftF-e8YyFCUJLjCROLeH48cn_dxf_JXWjYq5e4Os4hY96pB0xCcETCJrcbvq3V-hshzesCoZpw-vwYLEvVaCX9aQzpUcpEbr_G410BxOp9_etqXMj6RfWiW4B_a-UcChs6rJE4mMrSxaaqLEtv8U3qrQ4G3m39NpxY7HL_Gc00pqtFu6Pmmh24f0UdV6DzAvhC2HHoMDhDiXSM9nU1C5zlgtV44a-bw2pT9S9c9Z7UanuyQWHrPL7IU9cTho0JY1Z3zLqZ4W0JkTjxQ-QZhRP2t2rlVc_nQ-fmtGSSDvnSbUHicaRtLVGh5ka6PPdjkzjOLp1kgDLVRUCzNLSzW-zgEa_KVKMdzb2D0Jc-T0ZXmgzCHNavI7pLhjRN6riDWfOg3vGxIovWwV6OW56j_SzJX4nmPW6nB_dxZXbnw2StWd0X2B03G2WFVDWzc_L5-PKkp2yGNZCUAHEQ7oKLyqpecrnVdiu5vGFOVmjNKrZw3rl3JK6EJGE50CJId2O2N_00OUydnR2jQqRyd5wlIFexCEMllHP3HxX3pguObBJJA9B_Idr0ot0eiYomy-qJ6uQ_lQUY5tWKd22c2rZcUC5JTuko-eymPYSZM25nE7DkHF7QGAVzkVfdNFHmM35OBBv323M8UaOSaO9OeQfs7CfZ6ncHEIfeja7Bk1Szesar1JUWjHJ0zGs7-_hL9mJ1mxtEcfPUKV-j65_7X4CxKqjkhcD03kvM-pcmHrzIZqtEEaWXYiLlwl0ttyDgOBzMQGPRqnjkSwvU1GC-CeNXGQOkSWVRc6GXkyfmrgApeg0QeePEnkeVuQ-whnaEQYLdpOJAv0ps_889N-PomscEtL7pQVe8_SHsC7Try0Txqr5q5cAC4qFKx6xDNZ-zQGyiMrRObtiwzelPCN5QIVMWFsixLyQlk8yu6Vor0molwFQlwXWK_8mnbvU5UzjEz8eXfv6qkfRLuuXkGUSGHgruCy5_1Q01yNFEADcXx7iKS0H_4G3IAmb7I1cIgNOC8JlDkKhRkFqrC7CBYawml-kGjPVLGGGVzGyFcmcKb5xl6xrhmPo1SP10T7G4kpY1UXmh54GLDne0XSKlBKgbiS4AdhlN42GmY96YiFZ03o3P1sK8vIc5BFTtkZr5rOu6mttpJ24pLPnqvKZe5O0xNVbB9coggB_GzqFLrpnXzmB071BpxLj2BePEFxHIzFVo8rcpLTtHUkIpbceRWoLiFnJYAeNnBqxX7EcB_R11SC99yqvRu0odJ9b2PChUTc1ODi28gwt-MXhNdyCZqzuvA5FzdVEXkzGYQp8UnRNVjgOIniIhZzpBMs0y9tdnVOu3u99lSmMYDp8yhr6HXAMpU1rh9IDtLq6SCYxpcSyCQc9nlWH7ZHg1JhkWB7rWzJ_mHpouZZp2D9zpf0GTFma0ooS6nTXFBwxj9osANTIAOWUYyyw-jHFdEKw5600NqLvT9Uh6p_Omz7Aq7am8X9S2wxSMbZ7NyNmUrmXWaHHhakbrTYKABNt5HWXMJwF9YInMWiFw0PEGG7nh9scOJ5NRnuMOBw-djcD-UtZLvwDAzM8Jridib8JgCEKHZz0RhuxcrVilYwLtp__G_r9E9jghqakPKtJ4mKVgTnFQTMQeAtPRO4vI1ag3elyPxpJPFoENtYl5zPhxiCnx6gS_5T3RmqBu_6HoEqFhPWS91VKaXkuKiY2wkCECzuPMSGpB3sssFNrZHsHRQ1U61sAxboKSOYFDGsIlZweDmw9-1ilUs-g2TfrWklYfcGGUDo1vaJkqcGuBKxdQE-bII7s87IXXMm2QC9RFajalJ-TogUi3B7lzbVo_atw67UKGNUvI4ifeaQ-yfrP_m4F2cZwEzp-zxd6qqLmbgrJ-t1rvKInfDBfx7sKTiFXAUP4YBhcY7TLxeu5JsueBGZxXSmiEhyAJYJ6wT6FrQH0gXcnsN9XE8gI69912KDH2sn_nAUl62n2KRlbQNqLDqpx8VsL6ps6-oCGQVqu3NnQW9Dro36ax2GqAoG_2hR_KXlKx4XVW80PJOY8qFNZ9fk3jQISlZFQtX3M7v0pimrV38jIQ00CGdJL1OZDC01vUhmFbSSw-L5wkhpo0pp_zjDlB3sAKXOVWLWPExPVExGt6pChT5hUdzuUsbj4oP3e5R_iwlINvEVMiWbkC7Gq30IJGqkGYaaOBHUE9K0-Zts6nVRoY4dRR6_-lKf6oYwfZVo3dF5IejM4AFFYueP2uhyNBKkzBdBs8JZG8gYK-UIeFSk5b7aqQ-SGHGPjKd5etM50I4wjIj5RIRprFoQcaIHa7R17nnvwgLzXq9RPbKVu9CtmF0kwMVvfkOtyJWrZjP3NdXJZeCpcZwujm-QmUOIc2vdaavOXi5z_BH9f18V5BgldkogaTzh850Es4R2Raok_ta1uXVUaB8SeYoj0KZLMqeZgvKIzKX18-FKJF5yzZAUccapGRxxmZZTvay2PxIe31Tow5yMSoSKWUjkAQOZ8cSyjnkHSexwcWmPSKzmqsab62OwzqUEC2LIYeidOOjUMs8mtm6ozLW1hbKsUtZP5_EgjhxAfVECcKCUtmR4dVYC7LQ5rbL0ucLDcZ2L2h0tf0jYV-lPZZ58PaKWLO0G6dx2YNEODwZts1NlX8Zx8CwUwXfIdxBekaw0wk5nDVfEJU68eqRCzdETJ6c5JzIg3BdTg-Qc6CcIawJP0dRj73UgJd0kBf3c11UEgH62Xlsk51Z5yWnUDPc5MoukdAhxALaWedFvz6fXyyqLK93UkFzOigYAneYzaN1BWCc4H8HcLGEd_XzNiRKcKZWTynwqP44tZ2PGVckTWiqjseraw-AZ8XkbT1t3jgRbhrepCT3VkrO8-mglafa0tY0thdq8npd-y_U_V8Str5EhARckUmLxxTaNE9JNpUXLmZliBE8tICX_fzJ6wx8IxiVAI7e0sKuVSs7U0WHsnONbAOIHhEgLBQDP6m8mqixWy3CpnBKAfbYVFwDY5kMb84BGxyWKfISY_BJcemfsbY8HABeIp9VXLjweMTmMDfkrS3SrRZWKlcspHq6x3qhdpt7WQCiLrwX8o01U6Hiol83kTO2YvVvhY3RJBstEwdl-C0G4Vwu7OdejK-scbZ8IxVCjnE858ryr8VGkD88JzuVO9TxMmA8EA16Z_ztwvDWy9W3ptKh96ydzEZrnGZbw5R1Pg8ki_wglMwXO3PsG_VMCld-gJQNhdLH_4IBeYL9mB2CprRrY8AEFlPaMc-MJ7PpmyRjoyoAXfVXFH1bv_G4Nl5Dpq086Q41OW4kJKpl7nwOsD7ixJFRCFyVnFZ6zzNsea5ooo4VRTEsMxJG7kzO3wdZJ2c9BMIODbERSS6rpzz9N7Ycoix_3Jl83fkoGR-1h0Z2NKftSP4oTlw8SRcPLZwegUGYiMWsgvTKqMPGuV-mYgWYkv9xXL4c8ye_lFmye37ir6YAuA5PCYKMtS6CS7QTHntULw9-IVqMPW1ftb6IjVEYrVfOZ4qdtgamV7IBKAsy8JkxOOfcvVK1VsTmBSZwXxpmV02nUCBdcWDXExVb7Fx-_IdunlSBt-3hFIC-gSO4PM__6jftILLBF5yLezDaE_I7UXBzkW2EbnstbV6kJrHHX205Q2tHomchGS7DduWA3aofzRHN7FVQDRgj2_gLHoHYtlvTrPJuQbot6jxjDachA_JQj1JeIpjqxrUBjadWOsuLkCMcg1msG4rkRaORG5lQPLZdJd5dvHHNIA2RKB5jNG3RCoDchmFf85UMoSpAzAip49bEo5O9I1WE3ukAHUAvlgLB8s4KRNpAsZUaIFWy2lPdwGDo6m_uYkammjYkiXAAXBTamRATqdxTx0duhtPa-4aRKS6UM4xz3GSHc4t_0GMQq3I9i1ozjOxSnaQwuoqZRuqb2c42v5IaMGovRbtRj94Ukg2gIUet1Lt56YzIxiTezYKe9lzn1ZkhhWaO42ME4sTes-_DR1VidDjJTxdrYtYbiB9_AN4q-nyXc9oWrKO4ynjgBKyXH88E8J2GqcnBomHlmLvOd3END5AnPbFvLNqr77g4vIRMNVBl3GOYrmQP2gL78Z3I6LnTgSngV4-4UQLKBEXtVNc_MZ9LonFInL1L-iDjYHnSNvVaqm9Hi7_CN5C0xD76e8hAxv12PVY9nDsT0S4s7fTY_2PkJv3yWjmxpXiIIPr45F450e0F9wedpcTjggJ1spEhySNNRGg_KGtI-6PJlKQ5x3HbTaV0G_0TmhLQSJyfLm2S9FuMwq-_5hSUnxTQqoCgUXINmFwlh7yQw5E0suvfVG1dytYKiwe9ai1KL9U16ZSF5RBecMUVOWuSEIuWpGMNVZoc4HJMwisnDWIaoOn_n9Z7ZZ5VnjH_KNWfX_oGPhF1RYew_rHKrpi-04Uasjzw5mJRxQF-WKGKqIVyfEcfkhDL1Bo4tV4vAZFFd4pWkTtijn3QOZqpBYKF8HLgfLENdf6AwZ-9osPKWks5RMHW5ABAO3Q4lus7KwJNQbiai9u5iy3qtL8x2PlXHbuMKyYzoIJdYjtZ4w1ZEZuvBL02iGSVBJ14hNaGG-a-wKiTvNDvaXLpNYhh_4y0pzXvaTSpKsG6XFCftymBk_2pKwnJ_HdaIqqIUjvuQBrsLgI6He8O85XvGWH4Yqt0zvNrJ77goyC3ooUqsghsBAPY4IBhW6Ky8i9XEAhEQK3D-i-Ib_QQCmazHHfA2tlSRabJ4WFFe_T8OtotfGzkEO5Dl0y9q5rl_WPvZlYXic4U-eb9QESytLt4bjaA-yul9jVBIlSvnSp7XE5KQKQyq3aY4ht9m2cZ7CrhWlyWr9C9Efdx8lkw5wIJYixaDtUpAHJwUClz-HwDF2pDAnLZXvDfducZ1GZJ0Fgjn-u1dQs4o5vh2ZpB5D_bjcmkmKseHv5JcKv9_bUCztXu_BU_D9tmwuO7sOBOJFzn8PFT3_fu7X77ji0lpRuEhulYeNt4Rf_fDpHxORUp5Jc3SLf8V-sdxK321uLMYwDivDNCjcT9r687n4tO8CIedPGdto3pJIG-p0pC7PMmoxyDNXOHUzWfTH92CM5e02vG2a6QYQ5cI1LRdZ2H15qCWcDxVB3r6GsZpEDGjRQQX1PFPxnPQGj9cN-zIj3lk7J_N5mv98TEqPJIVGldeGocH6axAx8L54yaj9Kl3kCEv6vCv2IFRvznBJy6TaVhnI4sDUrPsUmN6hFflQhgsYOl7AEgV4fKEBGJTw-exH5u7CcWzVHHIYYmRM9C7riGHngeMcjSCGRXAb6XDupXoXQMCujAv90UQAAjrNV9TWJZZMGU3VslaRCwTpWIzXXLhYsMcNqeluYvMAxk_mNQqOHqhFE42H1fnZ23x7S6dh4mtd4Iy3P2dozLK6Rrx6IvXavxHQG2HbA_Q8mBAkiOE1_dt9_S4qqleLfsKG036c3hLeisBt07vihR0KylxoMzADB2L9O4uU8HaK4CAT7RTjkOQ8wbpv9qGrAJ8qEUG29B4XE00FCzWUy0l_VvPZBYZ0O0Et7kbs6D6NZt4WzUmsoqrZoWIn6kDXoVFkN9nc5jLZ-gUqwRJ9ujnn7vXSUgbuQ3_yRNrvvodp9hA4bZROuQjyfZ9oIIThjmw57lz-6zdeO5Zjinc8IBpdRrI549_7GzRGhqUOh7xfdkA4VmYalhcVxD31Ei7_apTFncjDvOXupg8aiQrIMgmPjcT3CspGh3zioM0tLwS1QqUwRpJKuvLvtvXxcs2XDGRaV5n30L6ydD4FPJiLcCDnlIzettqmq3c5jjTSnYPMmZruS8xuGZ2Dz63hvUUv7G07H4qUSpOoqQkj4661HvAoC0Pow22gcrinrLiNAWPjUdU_DYDDXHbiuJPt3uzHlNZ9z7450_yLn9kWpXsCLSoQKHzZXU1QQb-lE0AbXMmwemoLJKn644vSmkfHk97i4RuLGQOyghAMUzz3Qkz1Bq8O1ss0rp57X_OD8HF-xVKOnf2MnMVdQJ9ABli10N-CriunZoTsmpIlRFIwaIaEgXcxTULg1YVrs9bcy84d9LaUxh7whD6KOek0O85CSqd6LkrlNGoNY1J8T783DrOc5kt0Hsb_anXqYTMHa0KbsgcUHjMJfFMyWCi3cNjpQBxx3LqsY6Ajih4m63rKe3ZVq3xQWNfYB5tRIFN-VSdkJKPpJ33FPoVjAfat1QUkcXwbjSLm_7wV1Zg4NTrh38xQYAYfV1L7lfBMlrwR18KgvDeA3F33UVP-tHaXMgdDdwT6QaRPbg3HStJYnNw2YZqM7fx338fwPqF8PV6UqJ5INKtsdczBKNy4Gl7cpsrXUGGLaVPtb50ZzaVpeB6EwwdwNcUzw03IRjVi0xHi4E2QR9zWI_ZGrzPoAOmQpJ2aTR_UAR_F6w98G3KvkQQAMGTSkVz2MjSy0aTKNQgibqMegR0jzVZAF4umdHZrrJmsXAM4XxRI5OCwOHiHe7RrWJxnWoxbHjGQpr_L1K8piU3QD_ewO0UU1Itt8fJ7L13itzh5bEkL3-z_rppo1UdIL_BJVlgD7SSxRp1U1JucTNeXDVZKHoea9qQZfpfCixIYjY_20VJqaLP4eJbdKSsK73-nDKCYMiftiCnctq178Fp3hSvyZ0icUWzkgJt_nR_yMEG_p92XhvoHWVTgrju_IkH9NQpoyyB-zWpYtgTDntQ69V1Xfvb8acmrbyyuRZsNp9V-5M_XVvdHOB1w3nnzYVSd6qh__1xWkcp19eDa53SEOgRu39qoG1b3kHjriflRoxKmHqVe4L1q-YKWlhwWYW0LxXTTW-F3RJavAjwNSB_0AjV3-ev4lN95JIGdQ_fX-EeKc_tVngVt94Ivx7lSmZ0DfYn--o6GBpo4InWj5J9Ty07ORKdLMcyJNsLcJiwVjKcbT2-AfAyTFgFKYnOrnxDSLTim_bo_Yh-Mx0YG4gfrtk-AO-yF7YCghd2jfysM30wg_dgUTv5yw4kp1oAmP43lwtvEcaapVB0PWjz-YUETlsauc3ZelRWQPrvMmpApHNWSsSUIK2dwT36ulk594HApsYgLIqUVQ1k8FZTKFACTiRPDtpF1a6U8-Mzd0iBvnnx-hRWDMPClXmkm1VA9_nMNv4byhYpqWHXlTQhp2y2hGsUpzhnbu69Y5t0LvY-4HjBdu331lM3rB8SROWAlLR7qHCjqJcyCZZbh_nqnVogJobTA74MRJ3fzXhVU-VoozR0ccC73PtpiOz4PNY5CPl2CJFMmgU7sLqf3RTv5TercYXuQqxnjbS8EeuhTnqqXPjd_zclLaphkfZMTeyQ6a-Za6oQRK5fg9Qa8j1D-ATj_bzpMzC0BDn3L73YagFyED9g9WKHAxAEtgNDC-k7NAMdi4iefiX-9tBbpjYir5cbwUWhhD3QxShnviHP8_7dNEnmMlEYshQoDXEZLi-iWVDDi5HxJgD-LUxSSW0pGWvISL14_x3Y1xrF9uROEJulhGULTrsfvvw7-DVbcIpW8Kh-pSmkfVTSYkDoJSSQrK3FFNn4Qb0NTF3RxPCiV1mw74uuEBdBRcx7yq1MABgEiXLtegAyNzf7bb_Efyyi0-EyrA_CFNP0OqjObXgYRhP2yQUL_GRp1Ah7CwXMjPu05u_zsFBzcSIcfgAo62RItHPNjlCOVhEpmA8jucqXE1hnKkR2nCgp3OKHU36qo8ubBkHcPslcu6VsY68TPasUoJwYLvJoPCy8xN8UX22F9soIaEh295kUPKQIhrACmlUr5eRucoEVMrYlFYS00B_NqBDE3FwUjMe3BvRwtRrIAQkIWrfh3eUKRal70usFAXIEk4Ru9gmNPcyS5512N59iYvm_OasGrzzzlD1J3Jq-9yJ4_QLSK3TJQWBs6J0Xpy5kyNwID6f_4mhkdkY7omk9tifTOEXZ9L1oFekPtBMjt7nkkaLhtK1fZSKodgbMBUK1JjlgqeYnOqTYvzm0XnX52LEAQGMRwDavLlC8yHCtsYpDKDzxWDpC-cs9HbX1_tA3_qegIAZj7LxKpTBIZAYg0_HAQm21hVAFPzSSemoMAtLN90IhZraeSxADc8qOz9zL82g8sb7aA7EML0LkDJ07vGsZN-79wu-6nl5GUoTNQpcaiRebG2CdwTm-RAbp_WzrHUBtxiQUt7O4vpdajUTlIu2zvKtKSBUjgSQ0cwvbPV1JVxPFzm9V-shmSbKayHq4dJgfHI6PHsFleBlgzJuqHP8opCDR6t4brRhTdw4Yc5yZxt0RoJf5HyAmTLJ2I7n3Va-NKvLgLTLpNnlqciIewTcy02glFeDybzv-MeDfRBNp4S1nPZbm6kn6gG_vVkyIAJv16fE0HNV5vpake9VmSFDjhd-H5SHMb2z9LcykvKfGYouScXm3OKH_RIKlrLIK21cW6ApmKgSgvkDkr58fKAFF4RKD_U4zoM24tjNX2AqNNthqTBDGuMDiUGr_567fmBOTt89EKW4xOWC7uusHwdICSQcUgzTfT89sE2OeBmAGLi0NKZjxOYdQkPDTcEF9Fof4Vt1RE-sCS-I51rLjWlkZW8NQ0tfGebQeiD4vGYHXnNo1uX5wR-ps2pHGjYIoNGWI4ropuOx0YvJVS2B3OapQ6emAU0lddT_S5Mg40ATC4vZ1TaWxgbDApkAGpk7OBcElvsYs65aXvhYuRNLNIauxrT6TiY9rPRLB-GBJZH3HOirjP5efxxwAxEzkBNB29UkeqNbeQjys5I15rZi8hqe-9GHAZ3atN2HAqH1UMHc0Dp33TaD2E4bt547txUxHhTEYYzuOsHUGiWIXFgYu3jmNrbUaY2ASXjvqKGn8y3Ujr-gSDjmyi-T3WVkC8y-jBUMHl84_bNMUsXAfQOMoqHsk97QT1LwDz2IuW4DW0_DfFOaXDtcnKi8HESU-rD6Z8j_hQU9FqTLaLC134gcwBYa26SR1HIPtdsFyYy7o8CE4tKAoUIG-Z1V6stizxZQVMhf8zRDyZRmAg-AFwXnG7EJzfPC_trwDOzlirnTJ0NXxg5E_azwi_Qs9eSqa6euaV8TzI39mRIkQKdEVFOHQHetzZRKI3ZT6T_S7xtTioSnraFDBfJn-aAaJbV31dnidh4ku3XXv6agFX7ktMO5z-WVn8pnlIsiTjDTYJbF9SF9NRq4NYuQewZig5MDD-w883sA-inM9-1VKjtYNJCE_fidl_k-WkxdLarXCHF8d9Lz27PJ8Ettz6D9rZvUuiXxaaw_SKG3XLAqoIiS9Ev9NpzsZ8MOwFzuiot36Ejj7LPD1MpJaU1-tYkjfC7_cyC9L2V0cc4Oa9RqC5kd15OhgP3PUTNWBDqdtT2F49AX3CBMoumNeDnTCN1Kqpb4zJ8mD_HuQ7so8cZ-w6hqZ9yTBLzR5wQ7sDGrci37yjql2uYAumYP2xC8XQPw2YqiPwHWSxS3W79OTd-RPVsvm7HL-eD-Vpr1jFR3i0gGJtDGLIa5fJZ2Rd91GC_BRRwMmNgTfCEFwzqO8DopKvpQHc5I6bklezM6x17e5J1F6Fs-iLgs-AarjQByw0uGw0x_ydZoFpYe9SazBliFm4aTRUlyOKw_IobvcwxJL5g0nrxYbTg05-m6VqcU_KGN8rQBjOEfP0teIH_Fg2WUjgiOytua2shDqHNWMBXFQZHewrrjJ-AzUcE_OwiiCvsT3_m0vt2tObxYFzj7BsKLjGx7NwsfLc8794INclvALEeDpZb-c4NmvvbIYY4_JIFYkGhs3yi5XFn0e0vOgkr0NPEtM-18R_DbuSDQNwt2k5vCTPg7M5M6KKSlbwQ-A4EGK8mKkJTrHjEDlBWhZ8tLzYL1W9QkZacmpZwiZLgcWe1-0YyBa8-1pF6izaMJ5jahQXcApstbXF9Sr2_b7kFyUCFviMhOt3sot-Lhk-gAttUxPehvs7gjl7KrhEDhNOX5oLR0hr5SKfFZlgbcns2GWgjV5shClXIyKWh4SL5K3xAdpSQKobKse0mz2EIN5f3xlM_T9jy-E5oORyAvSyAxn1sOdbUA4CfmETfcKPahj-1RQliXqXj-hkLd6x0NDPh4EQbOEOTlIBRgh-2VjiQwMF_CQlTavRBiNkLudcRSe2g5H5bzJQID4FHZ9rsEmMFrxND_M_jVDpuOgvoRT5A-T9BILSTDXvudEH8Uqsijw36CRy6ErIDC4Smg_GpptYr-CPaHBQpUMogaE3pbhNk7V9jGx37fUZFAuIvSWWUKLNO2tTA0LT6bYZEwMj53Fdrsr64lE28Q_rXA9xjy3w0BnQxxQNeLeRhH1JmjZlkkT_yqjqOtOmt580qU-76I68MgFoJnMegvxi0kOeN81fjK0J8lrrDEmkXC9vTkR0f7E9asZCdI04Xi_z_m7hMuPCXTc9ADvQbUX9qEHxzSIkeSqlBqNsKHyZ2EZkODNC87NZsVs9MKDv4M4Uue-6BiROromLmszRdGrrLPPpfyvPpmHPOfgXC12_xp4lS6lNx7hWhhZ1jbBSXHtLcvYRC5RjKli8bHqA9HjFE39PWa2NB764ndtQDRWCRiT0UkHlRYVAOd7toJwU7TsuESLahZu1KjxrG1nSlxfRaVbGS_fLnBRS0B9Z7WRXMcxvPD9-GniN6LK4MxH1i7_0rOoPQwdZ6FSLBxBA9kXmi_qiT8p1m3rDqiDmXMBAolqUxYGnwsAL3KkfA-OtSqJcJdX2g9Ncbzoe5KGI7PP_bb8_9hh7npaYExsSRKICkFaR-Yn3x1psUYgpY8C_bUoB83_0UE3P2EAnyQMKta6OexX0sdSUNKEItFF4cYxG48AGtuj0cL9_o-QLvCG40rRsvMX1aKlSA9EUY_XsC0tp5izMyp3oxWU0ZhRRFbhiv64qoBzoeYvNH5osIrwNiCQcjh-O3PpBRDR2cx5QxkjnOF_iSs_WXrx1rtTWNfTjQ4HPur36h3Q6r7rSKHAtYO8cHbYAxPHduSxe8viB1SNhO7fdVMeuIducLYnMK4bly5ZbnTfpi03g8E7v8-UNSQmsCUMKBw4cphpfI6soerXIOIK094WjafH8m35RjJvwH7snuuzFgFJT8R6CROD1l87bC34xih3DxOEpDLFs-Lf0hZBU8VZWxbJbXm4tw0HSNOppt2nCaMqJ34n5_4f0J_0q-pQBbqjQHRNurbdhzvgvhENA_FnvaZnUS9yWGw48-R88Oof3J_GpJvOb5aDhVwmSTb6I_jKY-RGcwTjApZuvuoX8CpHcLByvGvg8yHVDbzZk80rVqLZmRCWnHncFTG_AfupsJqihBzKPnRzPcas32Y4pyHwVzWXnnsyP7dWA12sVqRJtp0KwPUJqcKBnjxF-vkFNrTkAtRM19bD3Dat8zFA4HPSWcylkZ0F0Q73R0PO8VWbnqfgsmn_u7mNz4q1__PR9zRvnI1fIrt-TOX9HKzzoKBeojya7vsRWIHqsvphRTqQy0ScFwU5ALxQLXjr-zGRiGyfcb_6zDHqfID1pc5ZW7IoSdWL-UeTvHh2thueKYClnK3_4Bj1Hzg7OERgasLtkINd0qQ4d5WhSeWS7AX3taCeouOShSRZLlqWYW0iDFI61NAfi4CPZOfzqBnLH7CT8A_OCb3DRyWkkd4VXVwX67U20JaLpq9df4_F39D_5zm-uzBBn-AK7Eop10vubjL8umhuYZtfLfjwWwrU7HvsakGuM3TxObHEWFS66OIf-UpvCo28-ekLyjwH7wB7rkD9sPJtyT40M7j9dSxFP872TLDshigZ4Dt8vXYhTVO_MVeFVI9G30M2EtNqWCznf1aExcVeHaW1Ajbwovz_eLhweMMxwhbQjmhyB8ij278bq8umXvdDliJrSuUApGAmahZUo5Hw-bLz-lMJ7al7vJDDtMF2S9IeiK6OEIYcfAWQa4xSSEl0_K7l4Pfjmbem3thRJqhDkE9LCX6dHn3vNnB09r7xPE3UFfkKrwbLgOFYlwMAVvij-xBpa9XsFmRVMj-azai9U90wSOzot8OQbzz8sbhQkww5mlCeRVPVyeyoEYEu4vUWzi9stnM5sS-v3pS05rxpqA0Zy4xRorikG-50LEHvwvCsHoO5S-1AuKnPeGGX8sYXcDl7g-i3ntI0HKuig7l369XBC6xKqfm2ouxDz2kvVMJwHCV6MLqSfYEFawkxKQrZ8PGUxL4OlC9FQviis1zo-IQOcdB0CFESv71p7KIcIzOJkWLpsTwVYcmbtsGkApXpdTyCXBc3vnX_4CvYTbt0eD-vz4IWsRuVLZ3h4M2iy_vJ106EGA_uO7IclwGF-jo7p_NXS5jSWyRY45zq038IlwHe9WlVRZcs6R4GwEtW2SUI1GUB9g1MycEhlBPh0y95CUzwZpNk3RLENEStTvvaxWIaYgEtm74A3cS2BKAAtkJraUT1Vj_SBRTdX9YU2ZKd84L0ONn8eWEDM77970As-gHH_q9ZxiJDra-UW_R7wNppHlhgpAP3jjyLywt299v2pe11RUXdT5kG9feq5Q5nGCZhj4rKdb98l0-yq7UWlmYp2zAWcvlG914sXciMu9hwpCDBq9hPGUMDRvLmSB5rFmcqrq10_i8l1ElntP_-d33L1XYO-lp4HTGONvaJlVSLAIC0Uwo1v-s2I6o4gSL8RYq-AkcglMNQuuPvzgQ7hXMGzrpLNIPTanupX8k9lcBw5pLKJM0WDsyfu2-E_EA9mQ7qFebWjAIgXMIgTrrYAQv_gwiOnHp1aiY-K2xEiUFNoEGAOmylcM8DILx-F-RcE0FJopn5zJIM3G_rKiOarkm1buTKPHokMch5R8xCR2vH3ukLgGga3CNRI6h9K_GQR1Z4XeQBiN7b0KLIvtCcm2ceXC3lAjobaO8BwVBSMJjOjSW2QM6_-rBkCdQKFjrpLowRMTJAF1KZMIgswf_9wbIGDyzd8CQMzB_numsOpBkEhBxrSbFpcsohvu0ob38IOr_fHQUDo9D9iwJ0KjspNSWokWDMfIBZHvoWyPaePucCkHm4iXOGH7FfAu3CyRcEtt8NwcrdC0i9czWqV31Zjg_9g2K0BOq3rAQJBuiNdwzhMjsZGxKo5ywUF32YZ9m-UlPvZljsAlkJAD_N9sLjMstnJvtdVzisv2yloj7B77M2S_XydyiTF1Dcv0csqOQNvkw84SxRjquRt3zybTeeHc7WDW4hYgV09_JKWvlW6JQ2x8VxMhNvWOi73iwcrHjRzHeGYSsoMkUThrBHgE-dYE--2a844rUFoubbQmALzF3nj1BJ77-CYRv6I2KhwdFiMyO7kNADddFkR6Qm2GFSrFLgJNveZVa4kG568m8WOP9f0p-QwR5Arq5nefBj1BQjHOdWkv-o2oxOQPIeOzQ_byCOKt9zjOX7ueJShlfHvoQcZtvk2U7XSUHDm77KT7ZF_fbHQ0_awJDqip78JhH6GC9ekNRDljiTorEzFHsFPcF9GUS8az2vfUeFhB7B8C-PTX4HV3F6Iwbmi_zlZ_M7vX2MN0BVqLG2D1IYzQ3aeRN0iNl__LDdTL44Lr-PHSOeuGxHC_smTcKhPtB5sDi5YzViMI-UjQMcUxOM-2U8Twh6te0jTqrGCPJ1gkdEt4ZnUEG9k1zxClmrPDVqCHlgNvBIyq0gyOF8VX19CJ8b0yQzX_zenZr6-c-NmdPthaiU7wAz138Fb6C-WePv5I-Rx6YVMcKtigOaaysIHSy5JMHcWIej1f1XzBt1kGGM0pjr02MBAb1bUpI8syp-Izvvze6aYl8eZOjvsEoDqDa4viJ51CUVte9HLRnfj3aW-3QDr1BLcYejwJOGDitaug-rZeANrIWV5FcXJVG9CYlxy1ZxbViRTckKGjAgVQTvLQypML8IGIv4ETyVN7T5oAkVi66Q01Z2XaMR2IpgT3FMq89aAy8o5ZE2hIHRirTdSgvHsruOjLHXWx920V5-uBIze24uokCF9daoX6jOhy_5RO5vmSRCmxXHb3FCzo4TpWLE3BQmraZ8HTF-3_zqkYPjL-X8ZcbWqZd1XewIbokTayLpI8i57Ec59Y_JlZAqUwPLvd1JZP8rjtKxGSFxuytFCAdl10d8k0QKzqlJkdTWRXbAljEcBatkqmS1u7eeeZ_WX2HDIfKutSC_vjwEHMjSx8wcmuyAp69VTenYUostU5rkk4_tZx4g_6uAJjecMsuVOqtvVoJLoYU9G7J2fvKx_BH1m0IWR6nqv1u68hBtnMEqr7WB9mGPojF2tJnAffR0EAa-JXvt17nHq80jB4WKcOMkmxoGLxo5nWMLYcubc8DqkIWVSSUaI09V71ED4tI8b9Da28Iiti_-3zMduvldFi64xWaKbX7FTGCwYnMFYE099-ZAcq_XqNj1EHHNH2Ezm3Oq9dwLRez1K_-CwEagX0Oe6FMUmfqFWT4xYgL9_-4OvcM48pqWQVkydpCDIYS9eD0KXEdo2cW_DOqjnS0qDesz-FZzPvKHvZIWwxbC_J_EekNBupi_1JRJj7Ag_vKW1sl323EDZShHr19SMHubsuthIUzcS2xc84fHhqVj9THnWVW-RyiFNRQF3bP0vmiHSYHokouB1Wa3ybsxeuIJzRFjzpJaGUHHwf1rBQqlrvGUTx7zmUruBQ-4f-EZ59D6VVmLcetIdwgUtTZFTY_Y7GZP8eilqm_BsXAG1ocB7dxiGkZDXbrw-BmDI0CRJd80mlskJeqbMt48ACwUfcWrUmc_YOMcO1h0arzXXFEq7pike836O8mqrXi3onHceBD5k6132LFbLg9JImd70t-vP9dd80vD_Rm1aHktmJareRsB-RIuNizVkOGn8UBcENX6XoC_3jj81ex5p6KPT6imydvQKQoJsF74SsWNeXaYZGvKKOjfZXsk6aKMv41zypI6453CIi7ud5bdb3W4S4SIaZP-S6sFwLAeknRwl9zMryBz5m8XuL9ueU8GGnp5BbEiuKEIDmzCNb-kyQYJNoQv8O1UYnv3OtnEbrhjGo_7RBPGG9ackSZwxygSAU2gftXWteemfZLJL9aD7CgZAKocyvOhZcJFVPvnBxjpV68eo3XvoJC6XdVbpAjh__pMW6FHFwEGZaQWgTI80fXpmFpKaXcJClS3KxLthf0OkeRE-Vm74E3aZVFv2JI9pZNoE_A6tpqdv9gmvxsYmmtlprjrU5oC_OQTGAR9D6FTuRIFAfk6-eNb8TtfclvdWN4xmh0APiJp51RAUyvtw3SjPdR7CsxeAZ-oZ16vkMFDG65n-ZlmGMYis9MqszVOB4-Im19yDu0ru6eoIuUQmycxpdf8Iper7z8FhKr4ePh8lplsURmaOVkQYSGCt7JcuvIJxO1HZb_OwJZmaV_r7pDeCPVG4TL1BpOPKQUxJoSNQl7spFKf1RH_-xEGPO9RoJoHnGdHydvyBHB1qhdqY1RYh3C-12UqqBBDlVLE49EmUaOzRwSxE6nXeqNkU8rzSPUaWqBYHoTIOYhRcGnDs6DdUVV0rTcXBR54SG0pz7-KZovCiWT3I0twq8OH0ID6WMDjuzjoXz9FxgiqUJVzIXhQtEccCmgjNmniTsm03jV-gxAKLjgnJRvakECqxVRkMkWzIYqK4IuM4F5pG27-n9ebUjFTE9wOdFwqBlMtMX7H5CD9RkDrvFq9t8JW7mO9FRJrUSOcVQJDRp44tpYdzVAPg8hu9upvwvgGWgWBCo86QNtMkduSQbqnOwbhu7Jma0UlvcNuJm1h31jiRRdEb82HW_8RdJSqRj5vbBIzBb228Ervc206umERIC1Byjwe0yN_AZAfiHwfhFllzHxn55H1XTc2su9FWwd9PjfJ9kivjixfHbvbnPa32h5JyJUZs-gjbbXYitejh8dMiTjxohFAq8qsjiXj2MypMQj7NpGUC5ALUlsdbati1K9SM5Su8n1YPTbcsmi5MCO79d6IQowJ2gzsMHIZBAImQb_hWHYi18ItsmAKNM4mj_RbVi6wllEik36B-p2FY1yXRHBKWNBHFOBTu8B18XVlv8nNizqnAnkxX4nhtHnIR7EB3TOXQa1PvNQLtTOuW-AXgW3jfi0LzrZk_Rz5cpO4YVRvc06hgecNdNz4uXgOniZ6k9GMkGr1KVwXUYZA50ZPPLM58mn4GD8_ZzTLQM3EJJjnM3IiAlUFR_y-iy27f1o-qCOUJ4ClcVVFHiGqakMlUdA5ZVIpx6OvtbN3AW285XR5-JkFfZL09gvckzOa5NJBwctoJoikB_xBMoa4NI7SbzYaEpl57su5Me6yttCJSy0xMKIwZLKewCCUloxU5eYuHgnDPsa1UReBUYWXIj9-j439niRvaGozf3xAYxIYt7b9UEA8WaM2VjZ8DavAjzphr0mgD6VZuzHzbJezHBOJEDknG1NvvTCjKma8d9_jVcBFAhOo30qfRdj45oXIbDxpdBZtzBqLqV5Aav6ieANo0LXImB_avETlDxmTVGGOJtL9IrVjGY_u6TgaDO0c3d-a4Icu--7zRnZJSpbjpYK3ot--GE_REpaLihjRejVwSda_EAnMBuA4rVFgdBxWe0JMJ169n_Q8gAyA3g0NSdWJ9mu-5cCxqIyyMRcFRiYzaCLRpqqjhaQXIL7CCTRWKSgzZGX13Gf71uT_l-J2A-6Q_MgvyheY-4Z-gR9Fsi3kIuVFHAr-TZHPuOHJoA9vhyt-GTMGCK7cHWuiKhMAAxhs_xITFUvEPpA-LXzHC5zYIeby9yZeuWyitSJF3Q_-EClG_GQ_jGeQUPHM0TU4Rv3RKgtT84Z-QQ4qGF_zHPQjgxZrHZ1nsVSD6NJFdmBsI0tSfDew0pewYGiK1caamB8Q5T9hcsaranFY9dbvEdQL4y2VyDJSMwBGi6fEsVcvnMtbJuTraA01sj8K_JQuHs-C3FoL9VxTBr-PGqg7ACnfArtNfTI6B2IvM0EcyVRhNUjN4nH6Vy6mIFvhkbxTKUZcqZHt3PIn3MRDhYONLDIfacZS2QcL3Xwry28dS29pi2wNTbNzLHS2vaFfVR-zkfjsUCM7_1RNBADXXl7dgmPmhzCAkgbg-RV8UZbgq9xOwaQ1474oscNft2qbe4l2miTkTaIXoD6CW-0U6NfUjqDP6JBJd-PNYROB4cln1xPJH185JPWG5pRffexXapPqydCCpeqVVGG6gj7bncAjYWbb6V-cnsu2fvat-1x9vnzbdMkEIo6JMBB91Atvx_BpD9mWBk3EHgBv8rD71osdswyNTdfk-Sxw39j2AvQDk03cM7b68TLSQ1iJCmVy47PhJakR3u24wARwYJMRT33dEja8tTHg6C967s4I6JFPOfUxh7H4hTntCZrpjn-gJE87aYK6m0ZaYbZ3IHqZvFs99FBp9SZ6UW6YHhgmebXE6Id984fAASBglOsRZ1Tl5X9wmLNuCCUw4Bj_3PMrb-0ya4cdws5ERF7ZeGfsZr1vCx78leiirGDq0a1JHnrjDPPvBM1jgob4bTmHwVLsZwfO-GsFgSVO3o1wTk4R2fs9POurQDG81j_HmvyZMLsuHx5SOCaFVtxju0BIT_pfsq29bJ_J6gZm2NcMxDN_4DNEVCRewNixRZ5cbV9ZUDSDocDtGp6m4F4wSJPTLk545ijoAWHSZzjlu4eYfM5aRsR0bLcheFSxxxpoIEax-PGfMMvxRuG70r2spFXcYOCRpHEySSpwnSwSJeO2UaUeEIUiLX57ni_KGx8qb_MNobpAkH3mJzt1qab59llDnn3_HO8H8w-wEUIpEpxpQAsqIDK1BLUV7sAm-PkVSuiBGw_ybnGLo69wD6yoHhM9XwuYSBINxNGGH1bU3JTc3cedMXLfvc58611TtaC0n6O9iQL7s-JqIREALLromPNSuo_UR_wVT2gOEFEiLDlAhFEl5VF1K2dKPc_Td2rncrkqpnLhLCYDP6lk55FI6xKlkBoTQaBEPiLSJbCzSywqKRePpL1DuN1hADM-1-wSysW3qRmE_muTC6sw3MTgY8F8_O3a4MHSQTxFiEwAz7YtSjINS8-TAnYDPFDPAe5OzVxy8YCJQ5UGCFaBS4Jp-MAxHSWoqMX7MQTFHQomJYCjnhs3im5B4W6eg6F76Ysrdy6TU5OrXOaRnha_UgbK_cJueyTy8hnV9uhyfVT8ZdMvmYXas32C1LF-f1Kf1k13YVLk0bwCdpadRQe-IetI6qhPBx_6cEP_XMi8hBHnweiah0VkJ8KBJal7hhUtN4jnX3OWHs6HCujph8g-Jn9SejYOH4LFAh5DcYw_0bECNVH7z4R6uOWR9Pjc3vKRYfYhE3blhSCRJbDrIoD64HU5Z7l1Bnykgr_-ZmEj7g4IZ4WIGOnJlV-6adz4wLap45uOkcPgb2ZK5QX-cjkmBFk5BYEEomxK_0NJxSxhWYJaTtQZje-S-x0i04b_MupyKsx_VvJvZdfTDJnMBSFvSnfjTo6LNJf4ysaS0ePlP0jaPXEn5hPdXGon_bwV_Wa7RPI2TpZyaq4tnQVAuKsi1JpNxTSWCMiiaOxeR3VkdDZAX1Hep2jfEf1AM0gd567CqHqxYiP2-_wzjv6lBSM9jh6d_bi4dDjGAUL9fwTN5gn33O7hwG9owqoQy_OjV7IIh23FKGDPKWAwGU6qtaueP8n5-b-Mk2HkhHBaDQgZzakuyZi-uxTLKreGlJIaZTSyimoKAfDrsOI4GsZauGI9EDF0j00JEFXRs= \ No newline at end of file diff --git a/backups/backup_v2docker_20250618_024414_encrypted.sql.gz.enc b/backups/backup_v2docker_20250618_024414_encrypted.sql.gz.enc deleted file mode 100644 index 84313e0..0000000 --- a/backups/backup_v2docker_20250618_024414_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoUgveYYWE7mqZoXDbph3QFvP7drgGn5zMSVx2MH-k8rjvEl1rmHvxtHb3p6YpukxDEj2GhfcAAAqr8C1Bj4MB6IK58awfVmKl7-4XKxnQZ5Kq-uhXxDQNZcxHDK6PRm2mHTQqJnAXpJBseedFVvHoLmOzrab9PJJPmcFh6jFCE-3js6ualsBrok_VQWfx0zMnbJbkS-q0hxW9iyJ13g_UnNDDxmvg-HTWmCXvia5x3pghMGbjz2D82RiuR3r06GjMESOGq3dw31OuKbmXSMNW36Jg_ok_fhHgsB_-sJIHRswMKKUokFYalczLquIl9V3nO8FQhDm4qo7X_FQdZQJZA2Mbkw8zkOrRjkEdwp80UDyumklfB9tIMfOvyb0oEErI5We2oHIuK0qsBKS86HsmzsW9n21tgQgDr5dTZ78EP41XBLTWjhZ8H77kSknZWJL3R96-yoo-_xOfBrfeROnrPGgsbCDTHKGA3sT8KTRxaX4R2uzHKqeQ4GDeeNkItxR1J_iE1CwdCLIoX9bd81LPTpImLvJQkiIACBs6s6qsi-zJjd4xPhxjth_xFjbAKLs4gdlAHLjgnd2oeaWeQ_bOqspWw47-quix2v4J0awpW8dcV19HALqE1kHUM_xcXhTo49J78VCCVfqn-3vQLP8k_AyFdgTGbeYUMBEANNCkL6p1Dre0Pt1YIhoSxOAt1GMwJXUaOkbPkh3S6Rx1LMDXSpL0b-VeJluSmR3Pqx289toLHBqU3dVk_F8vsQIuJi2gjScSwfa9pFRRpWpS3iUUsxvl0BtZJj3KaXgl87XXbCs88p4B6DLj5uI9xz_RDWprQaBuDVf6FZQLuvl8X2_sVK_K2GN8bOBn0ESPsFt_2AjdMfyDSr0mO1uzhQmjhYLIdDPuljYlxHAe_I8sBXP_JlDqWSLlMtBXVx3djVB4Lq0Bua9YII0fGbLHaLhmRwRPHPqSPFOHuIogAcK5nle7ef9t5M5lK6-x8e1alsVlGQ2HeGSXjRo4kwyrUHXwNqtle5Nzbh9lrmSbwMnnEMuT26wG2AznjMH3EHiBEc7GawywlVA748wDx6qXk2gzVkSEDNwvH--OUJdjloXLD_USG6GaQmtGRxYGp4AihYuzWIvl5N0GMzF69LwlyNhQeoTldlmdRyZLzHSNh51cBeyiWl1uOIWI1FXMK473KjjZYYsvzYsQ9ekqyDiPtCx3jPcaGdA45l-br6zEaKK6F6-wnkdWzPGi9isMDDrJ_d97upo3dAFjySELCotwQWeDRAeUcZ1yJzm1W3v6LfB-kCdur3SPn0_oA3TGc7CK8lc1Q6yR4kvcVsuaupHfnI2LwOt_fHoRzyxRRQQ5zsKKZMls-k7DDXXJOsaV334-7cnN7dvJjIrsUmL5aixf6O5AWFwzDM1Dic2KVqbJSAeI0A9kjgmmC8tE7G8Om40VtxMYu4oPbbXDF4VpKVtGgEtnwKlEjMDGH7IpVRzr9zRBKRyMGKZm7pNCsHBccptMm-90xsO5-q6kldDwMvTL8lYyjLalwpCLap5aHbb-aPigdiyMAQn8UNvgZcRzi2TkaZCqUkB7Cu_f9SR6hKG9rBdyVHNyOwJLzE-6Axof3L36jAqDTZEYb_rvOKI8lVyWy9bYuKxoL7MLYnlSea5tygxtd5SNZdOfBP0J-G_PbNcxEj2I9sNX98pMkojDcktbQIh0M_Z7l7_cQe1pTgH4f0Yd-_ol8p79QhJPJMy9Rvku32p74psK9Q7Hzj__MoRkwVa_FKi5BP-5iwrldmHoChsAiLIF4cu5AVAbkhuM3pPSI9q4s-abj2-n8fw782cZCG3_e69RYa4AFkkTp6-tT12Ty2jEmSMGCeqXjm3H27mFpW8g9xxMi2Uqa1vuKJODgSF0qjSydqK_Ys6WpNHtzGFi8t0davUgvUStzRMd-5bLmLz7f3Bo8-dYJ8QtbPDTa2XQIjCwhiYAxiuNnhm4gB74h6qSdOOInUBwutAsWd2mZhHQFYyhiL_qn6g8QPNlEoJ8dNnWNokeIpWnq-_FkzBTyVLtjE5G1ljER9-W_L8UQ7aLGJ-ioMWqMeZ0QD41uW9rqAseOwmZQjUIylxCF6NXeycxpp3hBl7zVBoYdE6LhYTADS9YzI6cdL9UVZUT88F0elhVzlJ1UM3D8263zjjBHwplGjwMMZJgNFuaejYbYR8_8Deh4o3wdRjtxXoIEC3pcYosB2a6-SLeMQpdt-EwXh8ZYWgQFfjmCuguWeH5UhJJ3pfptCRBTJJMkpKa4iWzUlTsKzqUHGmsMHBNCh89C3mPP_33eawCmK6-YTbexNGChqxfGBNyJKTncUkDAxMW8qMJNOx-08Nd0Hn9ZP4VanG963nAxlWPfpKlRkmZAXukwXbb4UOsIXlR6FCJpTM6sQhUE4mB7q6pN-p3FQFOW0SmfjbiOa6Cb85Z8iX_GEbUOi-oGCDHvDnzNpInulzwOEs7pDi0V-_gu5-sp827I1LSfA3H1Mlu32fjTto2t3YUjn4n3yUXPW31kIleMz2UFOgfBi6z3e85-cykBu11ajSjiGm-ntkkEuLmw5jbrqfpffRu5ZE65qgu_b_9N_R2q62ALakvzF1rR0mpXF9kjCC5H7lB9gQTf_JX6v3SJEsy5Sc-W3M_lQ2RymOoXtlQ5B63pUcnwQsY20JJfyg1vZHj7Qrv4u0GHdPKjHeU1zyl4cqXyfHnxidVXAPp1A2AHreMMWvO621gcJC1zxJ5hhiwYf87xx5I2rJtokLYQPZNcSqcPwx7VzqtW0LQHhraPI7dMs5yIkCz1O_dKpE0WRKdf7BPXqPaBSj54vlzaXwiKBhaPHDFitQrZ6naBH1Iiil_YAid0TH7xIpVQ0IGsmtkC3yzEYUIUncUXpcm05zgKZ01v6O06Y2_VRz9DU8V73t85EpBAB81et-o1ngPoUw2E58A-Z9_DcnIHFRs1NX8HrJqeax8rnX0ZEQKywfLxXy0v40sw7zG4NsHyilWXESpOLPZksTMSNrY5M-hHV9SOLuPhjNntjdohwEHikIvB_C6cLi2vQ01pnSx94aLkm-VpO8BB04c5dvF_SpgnDdfUtyFAQz3hBw-p1gxSdmIYUnEG7O8_2lUfXx1EDFQ7f5amwezVsh_i_yz2gv9crXRdEUSE3IGp_n2W_zsRIadP9bve09Vo2XGnJE9Aqy14DJ02BE_xnWLkTkdZ0MD9dqUth0YRyl2Ep2v3om0dCH5bhXEgyb1TZ3-gX7MJUxi0GnrQPq9TdFKp9ToU44VE627KoELldMj7C7WZlG7df082KpahezPBnv5Hxq3G4zwVnRjWRSUGFHoJjsi7ZI83ouFmSPOPgRDInYztuurfTyRfGPp59t_m6XZpcUafJKIn6XFSfJ8DRwZkKJLIyVt3ntpRYeyzTi-c2fTfPE34jhsst2TeFmIddYyj5zkih-c0oxSxi90gPH_iecc-YoTiM2xC-aErYrFkwQcuNtzXstC9HaVvIwpDSXpcrv_pi2lvzvU0wnWnKRgMM8zZSK6MKPEnYLn-BuX8-nuLUXzEVppxenpAz0dv_Ic9PVyVJI2-w6S4FTm8lg2elJYXyDb7jkihiwJ8kmcljEKMRQB27TljTXjctLNl5qNlFHv_mWvGNBaxtfFRdfmQeKBd9gZ0J3bKqzCBEm-jGR3a5TiGeQV1kEyAgE_ZSnRIrPX48zsQapMdEOcwzFdUM8x1mWsoiVEH8lxdyJVmAvv2TS8WiTzcYfWNPqD21twUR4VF56B8K3pGs6Y63_PWAJjkTaiuMy9T5uOrf3QNN18k0CNJ8rZqeJe6tmGda0JQ1nQMuf6vRAfy6h4ViWEzxfOSi_5JSr2urN4otbcsHnp8On5hwWxKBT-QCThCgMf5vXq6z4uscfMZj7z3hwgHIZAZB-G09lMk3sA_hZkzF2n_tKhACOIm5VLQKKFKxHGjZtiCJfbQbdmd_Ev_PD6rqnggMpw3auyH3rszzUWXKbDTLAMsUJ2gxleZt5Qd4pWC7_-TvSiBkTAGkcaCQgTSS7xSlfrRyL0BTmhXT1MsegbJ70AXL-OWwMvQEsPZyqaL-PtS-4DdF5GUbpOnDWdUmyQGXdzC5QusbOMVlHws8bhF4Z4uBbkTz3eX-bimwVVEussrbxMofvx4QORW_uWp3o-p6dIQg_7Znibl7j8mXvpHltZmIuFZW8RCkDQdQOMXjHouvcP5FkqLLTnvHUWgVRGj-YX_V-kfuVGN16sQaXEVozOuhlOYo0suuu1rL0R3bVCkXxRHhH3qzuy_blHNsP5m5eywuGLDXVx0m7XXKeNqCb-9KjuDWDOYxrVbFbEv8YS3CcW5lOOyvLrlDnWRLrTOT145Iwdb-HhFhamvz94RmVY3qLJ_KH-wq7X86gyxp5jOBgV3ygGIr22kNh1HqgWUXNOP6eBMp1a3sGtFHNH3yaqz-CL9tuGUEAhLcYW60lBT64PfMLBj24tUH17dVFDm56fbBYwjaFxQcAGncAev0cvCIEMb9W7s4Ep6GHF94OB-_RBbeJvjShDUQPKfLdSm6je2yUi2yWBgGwugY7JsI6pTUjiGMvhGhBCyPaS6DvwEAu7nV1af1g5KWoLJi8Gp2n8CxthIR-nZO27MIXHHd26_Uidp_46de4jbpMuEUwEgrSEs5HK_PkK0cFkIkYpb7rFzDaReUJbxCOxQDZK0cdwqTmcM4r1qKYCan5-aYUgHsxILYe65_4HtFg8KjfUPxajBUfmg6H50E43WRaXKJlpgwtW5k5pG2uhsUb3Xo0Z5urWznUzqQCdrry2WF99lkcniw8bLvnhITuvxMqcFh9SY5Z9FXQH6LqX1KjNykvLCFcB-7B8Zk_xygzVilS_oyI-oCp-EgXceK7urTh2t1qcE4s_McuPZ_bR1fGZJAFkzEQFDeq42gawNxvlv4v18xCl-LL4rUHvVk7TRJnp7M3Xta4MKdPrCzrriJN22P2plFGhziogx1lxD0Ay2Et5uOD5e3lHTTLanRtRXFxeD3vYTfoVYI17ftkg2s7GUZj5K21md02c0zAYgPdGhdz79s8gN-vLBzgznMQCqO9ZT7EXYqsTRmJPu0UkqKK2mwyVe5c3YhDTB7Cm6052Sgv1Nd2I3Gza7AhHJWpWup6hfEAMvO3HoiCVJr_7jkZgz7HeA5bFoIDP0Cn90DUmBNBiK0yyJiR44tAcBMzQWX2NBu-mShpSUs980aXXNugJQTky_ZGwkkUXnynCIh2FBq5XQz2ruHzNBirPjk5fvuX3ztfKp_YlsoHXpQb-JkJd_9MA1jmExD0Ln-3fpcboQkA9-zAo0sQzuehqhqry3uF7HsRV7QHPOo4zbiRvH_iKdgQG3WazKevtjLDKJisxpmRztoZ9z5Svux8B7SuNHFMLhY_mUMgFiCQo1TUBROmPoUIKkYoU7blUxfTs2VGR4KdMQ_BOR3IZh8Eyo8Ij0W9HllqSS-oBwga7_PktHyPnlmjNrmxBPhmk_AqxbXZvMmBckvD99ebXJnHcaNhLKPtkTYQmgrn_pmpv9Uj_e3F1nublp9X-mWHN0kNMk2w_6N3NLO8fkfuPOAYCOr5DvNrmqQ-ALZKMeF95dXhXBphnsrETPrdKVek5sWkj44Kw4kI0it7polvEzyK5dts7SugJ_-jc-WqV45ZusW_ucJjSJD7AsxBUNR6th_xw2-f6jt8tyhfvyFt3rE3CfSKteXBgXVLFyxUEHXP_NDLTOeZfdR3Y8-DdqI9gjGLWzd4qcaZaFVnATVq45MGM3c-0hxcy168fSdGYFZi8mUmrvykacBTrf22uW2gCVVT0X4SprfvuBB28JkZSoefNDw8Cmp5CPssxiiPoBXZrfn6z7HM4-kMAIbgG8AA2obTCySdyTMWg2xeQDQXJbxVikoAJhlxVFweErqyNDxmcabsIKbj82cSGCTHKkRxhAF9W9nyX07dAw7ruOOXdWnh644MSImEfK9i5XKjnQ2pXHmzObNkslAYgjeppXPztyU_dcyye8Uj1GEx5IneHKn2lGk1JKDjMkqkdGfYDe7VVKJ763pWveCxU2YlmcDJ6d1LrofRETL2Pffxj1ld2QBZE304BB16CrfSamfQmazc7q08EJKkdsEcQJp84gxXwDlXKuuSzqJ8SliFc23sMkFYL1n30139cTxnLcfQGDMmyiQZYSFzFJx7UYN9KnKmqHS8-gTQh3BEeMWWczTeuo_hEJeO96A1MLOQZvutn5HQDkdtybV2cDuq3zbKbS5Z7QCSLHCDTgmNU3bbConY-edMpFJ-47T6VpMZHzmHf-KnH4YX92TAY59E7NICLjnZ7D3ewJCxZcpbtsCM2ASqjRYS2_YJPxJZqGV3hiLM_87oBW-F-R-oZ4XeUurUvJjYyR59UCV4FmxonjTO1G-MN5TqcoV1T1ZPzabIAHzaXygdjo3ZO2-D6cC7aZh53Rd4te2ABHcXoZqIQMuGsPUZplpv03hojCTj3w_yOyVJQ3rB1AVVBiMhoOF9yzMlLhsUs7AMo6DI5VdiX40ZserJi2bz5VpMOUNy4ywXHlwwFBy6gX364r1DYO94Nz06wOgkbPL0FIPKgZK5TjXWi-C80PSong5xKv3WTfrgVcJf83rzqTJ1O2hIlLOpbA6MpcEoq7o-n59O5sdmyuc5q3-puWTSSgE1_zF3OvBKe-AeBJJJQ8TjkWfNf5wk1Tqv_UWam62_mBVcEXZG8wK7Xag09oaWnaAjvU5Tq3FKpcpxT-R2vK86BKiulB8Rz3O2NqXKvvyOAAH3D4VKjng_YyHLvCMVccFtNLz1Xf9CNQXbCX2t9GXX5WPFEh4NyFoXmwU2-STbodJuGgAB21NA-GntXUz1S7drUt5tn8JFBRj9qu6kSTSfsoGdBfOkiv531USFsY-vW3hWB0BnvyJk5O5tQyCOyafxCwKfSt5ktUWSUeqXeHlbV4PHuSk2P2DoIR_zEhNuvYLZfOIrN3t34nnG9a2evrN_YoIX2prRBQlEIRGO_jD1Cviej2gX_dqFaMQvNDCTPUR8kGD05hj5JIrwWt-z8p5MW5SrdeKpNd1mNIuCs5Dobb_XO1E4bfbotrYqvUVzTlHTPy7h8kjSwJeWVEo3OKPR2yF8Biydikvu6tUtyadGBtfHVV6LqgJCvAhwOyDeIL7TRLs7WiVMojnVMe5j72vkzpRUilJ_EPyM9tl7ljitfldSG7jdk_pkGZmTVJSvT02roB1OL6G4q94hvIxT50OwtsXiOGcl2A6VEqrrHlsPVY5682BHqyebFDbUJpiM745oJMuxlS0nTsAN1WeLu6JApYzyrEkqx-ZolAgJwBYhN5OJPkM3a_krtPPgkJNQRc8cUB2I0pWjyvyj7xtkEnD3v2UrfB0qRLSXRJ4_-GNuYS-FoSBo65JcL3raJNXTlsyA3o9XvJlJoSTaD3YRXli6cgKXD-9PZjFDVbbGmBU3y4RZBOp00VGvczglsiwxTdjcUFPwsJzbJOqUOsq7P4CAHJ-iC1rTfFfgz9vhBE3h9Gn67c0SksaWGScaeW2EvCXwmpqZOXdEaswi1GSYds4iK6zGcQnBDkVBJq40HPy25a_5wqvRg6dy7r87hL9SBNCWnujPLI3HjSVrzelIfutcLyYkb_oJzeSp6YOYjq29zNKpfEGqzblCnScU1cRqxVzbn5LM0YjHDwb_ZgQqi6U4d9f0m742ev44rRR8XgwVEDYQ9hXaPCEnHnCYySX88eSxaVJalAE-BPzoD5EvTUgqpkIvBmO7Jd0RGTjRPtA0NEO6PLDzb8bPWxl8gvoztYdYIaBQh75xIVsMlfEdmsG8g9ZldOyDIoQ24lcwxqsVhtXguD3gDOI-i5iuF39fy7Zf6J-15l_7irE7674f8KGF3YgY4_VLtZuELh7b9VAiTNY_jWziK-E7ekbtGXwyuI69svrt1_UAL9xmWwfFahy9PTKl5WacWAiHrkPCWjsBtPFXismqlMjPzBhBDRa4a2_YtaDR9nmq3uT6N2t81EGbkOTBhLE7Q9LAWqOBAvqg7qR0urdn-98I_MtbQz7dsgM9o7E_Y_gsauUGktcT8pnI7x_x8cmx8SNeFXmSPpJUdiX0CbuxspY1p7_vCv0GLziapH3waV3TmTQ_xIxm6fratN2GJACk2ddJ8HrcRVlFPLzd-sk_y_h6AqX6eRdtppRS-lsILHbAKaXB-MTqZhUEJqOeH89080aJw4t4pqiOL3XtlApgo_w4ixnqCyqVnZqBBmbEAd4KRRbNw3Oolvh9DvM_A3vy5eWfQtFBq7QjScjAaTkENLLnouSjGnQgn6TAkEFGWVegyJhAL5OKFS17DL756D5koUrxY4xvkN9ryNOhlDhMXU0PS3yB-a1VaaYt0sQYLUI-yb0JLuazQ4aJHG0sG_Nahqu6CfuUuXWqQOrTht-uQm7WujQbDfPyFGB4W4QrMVHkEEotMDdMuSPnAaPjyRmh4PdxdaYW_pjOyBgLoRyuSntNaiYoB42vc2nc0QFQzFjhqvlbcf9cyjPjt313UAJhrlDbgOJEa_1yHSjS3sFC8j1p_fSyjK0lWBMSt-zYwhif7N4N0HtLJnXMM_qfS_0WR--t1ztck47KBxlXM_hwVyumbbmrn37jovubsz6rSDqNUGPkYtks6KY8Vun25wN-vv7az6gH8f8MsVZE3U5mxMXNZS1TnwbYlUHJjbeTCz3N5pjC_kS2RxLiso58FOgvBis0aX2id2lNY4q1BpumlDczWRjtMfxfPKLM6YNVGS7_RtZAkVJlCchiB0iuz2mGctHvL_1EuZlo2t2zlf9O492j190IbhdwT-r5w0G0Y0MeEO1HzElxrla7zOGcrdLhsG5BqjynmoOT_g7XMGjw8Wsu-fyVpilJLBYL0xVEa89utwvsP8LZ1NsJU1U2T3FLy02yQHn6n4pGAVFpApV8vTKCQhzAMxlWLq4XyR8lwPc9j1akiCyNeO3Xb9CnOC-e-0r9RkgCDUQ4H9q0wk4BHRFHgMhRBbn4Pp8lZgkZ44D1R4mDRG8MlSnuZAM8iuEOVCDxNTR6sKKjY8gTqkzvmnrfuKSjoOFAQVpYplVstiK0i8HvQtdAPuBrrAmYjSyyvUgPHKFv5mOW_HmmixA2CcQHKJKCmJxZOlLObYfbdRG796kpCYpHbFM2R3Ec6qh-EdLAC_449RaxZVMe6sJUO-yw2cl6v-jvD0GFaDITk0acVjB6PW_hXhNhMh78-OlwBv7rKTH6mmRJuytVnIyThmUXPKklbAf6rk0B14220QhMkG1gq_nlPsNjayQuW6hgHRiaVkk2Ep1AjinIkxAwe2CHfXt9hJWIAaKtadggwzw5kNxKjdYqik0CBPan-kzJCm0NY5WB9BJfyfJcILmiMgdAdfglr5XobtzC2Til5IV732F1sCAQEa3kUQ_gMk7ys526uH32sOlk3nnsQ36zp3fy_QEL4kNeBeY0lp7drwBwrdjeZ2pwUYeKgyOjQILBzo_s1DkTGEdptOILj5M2LzVR-j3ZM3xYlZyabY5GmNRfv_cPJw7xYFj2UQuQVRJOJ41Wmpfz6iak7UFXlEqyO8wOKn0vqWUr0w9MlzhwefkupDjGxM8cQ5GVZp9Nxo4i4S8fYnllO7LdZv-03TSWjJ-RrXq7hryFuii-Ng5IiWc62AIrYnYolxq_r0zk5crs2DVwfTS4FQNEddJ1IAS7dvo2Bgjo24vSA25pX2N4Pnmh_w1X5Y0c3_VetDuSMwtlXSnorObPM1vD0qxOgveZdf1k0ZH72_nZKMnudF4ZyaFPiYKFK1OydQh3C_DxSsx8O0XH6a0JeALIdILSFCFJLbCMtA6H1ogf9QO6k0CUNakveJRFkHbw5orGF4ACh26yYmO2On3WJaPPT04Ix4tB8h2aRJoN-hOaowiSVTRU-u-up84kYVntRmak01CdRjWiXsbySZeblITHh8_CR8LawDAQABuvxE6nhPAhj1Jd40002ZUNUDifngJuo1oEr5-o2g_1ZMKfRHAbUvPxOcigd3IWIlGS5coHRPDgrWKAlelSLroFwh5_FMbiJurnyM3ppAlBxbGCG2_KqDJS12z-F_fCc_-lBwW76xfzRs7r4uOTmAW-ZZViZfy35ORuDjZx5KkDZyYKnIgSo2woRTmlv6j2A5OG2aeXd07G1fujQXVYHgdpS46zK09TfgxsPzTO7Yml1V6hKpap58R86rHtYrd0bvwGEQzDMpxX_vOF62nt_Wg3XqRDM4QR0ZagrFvfe5jQmjRmix8SuM_lQK5qjdPMkjKggeE3wEIcxELk4looZ0__wRNSYBDib10c9PEIPGToSqDvgsFUgYLRW7gomM4ZGAt5TwMWpewNLRPxa7WbawSMDeASn-apf1HaJ0760qDQV_dsgAX2KrSRFf4rU6FknLGLxs7mVJPrWSvY6-flYLCcuXt-VunRi6JNCl11RsuBe9F8R_fGKzVjl-KR-o6DsJXo3Hi6VZ8MARotnpYwHIwnEPHPojTEv_2_4DjXOBw8BkUpwqDaKBt3RbaAJdj14MpVWQMlM6o3RQUWMghnPjVzMT8paNHPS_vHzbzec_YieElbaDNbZhEB2JHWBrVwiCh80A1rAnjtZVNXRZ6cvasFz7cU7dOlt4aAV3TD8GO6TRO9dybU24lI9f0OaKb2a51z5F3OzZiCjHy2wHZd-ZkzJmSGbklPII-Rwqh7jDrTXRRSRf7GlIX83l1jD-RrQXFGOGEFxpLhkoV_q95o_s_ChQPYRIazEv7wR_wbQk2A4FW9LsVmDcTkqavQULatJ9EbQswZOXp0lPad4OxwwZ4g5bS1gYdNOVyD830W2cYvX3e2Ny_t4_rkOisdSMgCHk7DEqaQlD7Db-dLvuynu-dsTm_v2GiiHEBvtpu24SeJ2SvdNQ_PTz8xwcZTJRuF0esA2JKRL5XPZPaXUNacHTyUiqe8Ztq_YnEMmFJ3mPsEqtjjqevobOGN7QJkXLypFWvhdyUqhyIaCFr7q0PJY48b-4ks6HNYf4i3fMtSy9pnfjiHxJPp7mmYI9Iwt9KgWgi-40wERkhtkNnrVbuQVaQr8GstoDFqPcDsUxABK_HIzZ55bMWCyI8fwZPu-ha2EgfMp8Al2L4CGev2D7Hju4UW1Xe92FbCqXp9vfzfiayQ_Pk_81YTcMmB_VhHWkGN_hh4NUi30pCl5e6VQyMMKKqMywuJUks_BvvWrFQoMGAG1BrDnYZbqltf-js1cuVowgC0Q6KUOUwUwYE6YCAH8DMQHcr2yblxkWObTPTTYhiMPckvtnk1KGXv-nTmFArVNweuf4JR6GaQrSXpBvw5E4Aksc7fMWbwW4_3i-FbV2aEp1P0ohWuW9Jd7yzRc4F-M9WlL4RKAxKf007-HrHgpkGh3wjxAWKpNBKmcpLvtlx5OqQaRRswE5Fk_GGD3F8jNgM8YrWnGyxGKAtzTjMRjQAKQ0j51OUHipnOaKgoHgNF1Kd-YWQucdQ6HLjBOLUHT1BU3Zr9x7v4ZTzDxu_e85njxI38l0gbzW7pcucEMuSHepHGEMISIdiKv8VoqYwBmOigN2Tq08lKXIXYMOzRJq5GKLTuQ7Nv3D8yhPbXLdYYtaQeTHgASE47pGhvb9-9zKWC8OC3-pd1Pt3Dq-Wz22b-dIa1G1mTX-7DTsxcyJy2IZJtVG0AzhXtoD_F5kcuSOGoNmL36745aHdJzL3CW4FfJu8_pxVVAAyRzgXIgCP2jr7TGHZSIM3wjHwZ8Km4j7gItPx-wiPH4C36N-QM_Vx-7Jgoa6J0IzS8TOe8i9zBkkjFafceGAqg8mfQY2owPFgGxhQ2y5EOrjNjoHtg-OqJvYiCwWs0XW2tuVIkn6udnWWzz21K2B3z6xOo2nFeF2gCmWhNTs03UvchdmcMbySxzZzN5D_JBAoOXQCoKfVzm2ixpIbahT6e_bowW5FltpEruBQr_dOCLQBMq8srDG91YubNYW6D7qmE58vp_0ikE5Rvp189kW7gLraI5ch31NnY0GOCm3n_D0DHq1TrNmIE6LmEwBohHkhxNvLwSdOSs1UPmgfqRfLJwabCpSFztf9eiQjtet9QJnD7iBZrbZf9YWfFuI03BgS0IVa0Gz_EUo53O5m_Vhci0TLjMGX6IibJmJMK0w78kghcCy5tn5hftKdkIeIhUonvG53x4CotUcEj5wM4TVNQeXK1hnbRm_YOKjvGIoZAang4-p21DRX5WAZ_aS-cufaFCavFaooPH38z3VObDcr7l4kRWGanE2EI8PTHUyBXU2DhQHsIOUaEY6RAmtCPgH5rd5V4eDA4sKeN9U3aju8XpDSClxQV9a83gi8d8jQZPa8eR-pngoLhxpalld_2MQ0XnzC8SwrBWU2xw0t9eRNQFQo9lVs9QXxqie4fadn_rt-yZVhb6YfXQHFPb1yGC55GTksfmZVgvbZLFozbr-ofhXqncEV4vhw7Lco8WuuvYdY6Tn5KmRQtEC8n5OZlhwop5D29QblVaC866f73yCW8DmnYAR1j66DbrYds4UrXiTSu6J92Vk43aJco8g6iUEvQrXZTymb__o2BhC8OEXNlQXYZtvZR-rei5K6gkr7BVh7B0sO4hgjlU7vwqZmZVg-Xdmc4L2KmQTnRiqaIvPBLlnguHBMcJV9yDksVNDuqBdLFLOiQ_g9AJi2W8vfsM_0XHNcpRgnyqOhnw9wiA8HzuAq1H-s5YgCRTb02sEIKn4HeQQGgcY-TF82VI-zdTMtzpVGQvNG2iB2PpFJLkyIQOoOMLRh5Vg3XmPZxYxaizD1XBqlGDGm7hnZJOebBZcsjb6oQHd9LXNAO7nMAoqpkW8rn08ddZh-0OWfs3Tu2REt1tVUPGDTTlYDMmnVgLW9dDARvl0DDV-lOTQQMdhkN50AXHvlZLILwOME-jO5QtqM6Y-rSWbcqmIZ-vUBU8NEU6ChHe2Hf1zjEYD3JYd9hw-Wfy_gruv-WxnnPUZABP7jjsL7KupkPGS4PP4DzHkPMmA0uUKkySi3k1ogy0Q8mh_JeEatmNeeX_dmkxbqQYOL5fgYwWGJq-qJYP6hygEuB1wz8leHPqGuNSmGllhH4iAeOu8O7OCa78nvXUwXlkqh65j1hrqeRhYXd3y3RNo-kqVFQVE_vPNWs8FovpS-gXGKCpzINnn2PfPCojuz2XAr_vMcvN_AK1DbeQJr8oCGpPeM6p8iUsjTrcHsbH9XHN5zdKGis4MIpa8L5dM6VTB8JAhnzC4JC-VxxasBzW70xEpP5SS1nMdQ35jVLnEwwwIKifFbwFqslteWt9GDEr65e-uQ7gSV4SwAc3E481etZwbFLLIw73exgC-YsQN0sbXNqQ-ETLDpyv5gzzPvvlaArJz0zxRxPblicji5JaJxoUuJzCNCFpfu056vxb-O18si-9OGc84BtdUw4tR5o3irutltw1oEhvQA38HLRH2kCqmU9Eg0ETmvnzvIWYjzMCANyC40ZkLaQQHY5gNTg0DS_8XqFySMLEWivje_hI0lavxwUSGgvczuoTVNEVr4IdCUHAtKJXCTitLC-qtUb9IubDimY91O_h9uQZzSrXbNRxczUOTSXAjcNYOoqlO5tbxWVXrL-_uNWb37CRNYWKB4ebOzr7iKvtqgo5IjBxRLm1uwil1fVJ4Nt0FtKmnK8QNNFvMuoNwQp8uuv97GoErt14drRbQvKw2y1OF5ND9Ov-L56J2vrZV_roqDDPbZtGGzRrD9knwoYqrjt3W4dSJjO6Rh4UhC6F-J50OgzFCeAxfiU6rQygNA_5QvEt8ReWETtLg-YnqA-9fBrXLTwA4QOO5JDF4oOrKby95EkfYvYNeD_MFB2hs0UBu2lK6LtT4FfC4tfdy4k6a9pYhT6UmNKgGhcHuEOTRi6qoOZAOCDXS8xlnLa_noKwIMk3GRjiq9_zrJwnwj5VjIXVOso82bLp28PUfJF14QAGOQ5FkvrLLu55Bt20RsqyH5lXBbvZJq76KzTKWITm7s66vHLy3kKkPxEedc-qTdwy0OgQ8jxr-b2Bg_VOaay0rkTcT9pToL5b2GlVuRvQL3F4QR7tccRo3uWAiYmlztydlrAs6poQqxLmY-ChbhAqbHihSoGdfrfa0HhzjiXtGyFoIQGbBOAI3CNq1T8EA439N0CX34GvN6LBq0NesIKeSIMdgrNqPSH3MRxZmey2qIS7G8rgK8SEtDrmTVM9BPdUsrp0Fwsx96wqCFcsJTSXUX7MmFyt4m79ddNiOUQdE-7Hl4IooxafRCC4ichmHAjg2ERtcZAPxDexkheuZVzt_wyjHEqQU_wlEYXaWsT_uZpiaD3CIJwq0c5qybvoyXWWtiUHUNOgNNTwOWTgSE9dbw04E1aVppSzEsZ36wuGB_kucyIEWkMOuZodV3aQnirFVFh54k-98CLxRNx34udQg-YXzyAQgna9n0oWF0l0Pe4YJGDsB3pnlphYqQwDlyKmwA3iZX-7ArfVBD8QvI1OVOZIpOtdS7lBgegOyiT6ZXjKo440w9H09laRQxf_r-2Wm_Jr4aQ3TV0j8Ic0vc9_qPqqB37_dYx2grsPzLdN9qMlgDQly0P36OZ9pOaDmdsc3QztyE5NXwG3c0GTGM-2nS5jPHfqGJWewDxQrQ0pbpaVqCCqFimKz8d-o1gqDDef7DaU1MB86OWosc8rphlRAT7bA8y2HZ7SaSgm9yMXL9uKP965d8TgD0wl2BdVU2luvfIBsmvVY9uCLS2wW5D3D11iK-dxjzTqib5wKFEYDnxprSQYABsUlO39-129C6MoKq7zGRokc4qJUMHEvgP6CXwxGj8owDQfSyKVUc4VR8wewsu53Hb3jNZ8iXFdXZxHT7C3Ngpk9kesOy0vrMhrxJsn4DVXSZSDbmANDFjNyBwQL4rq-bKcSocDq3kj4Y-LcvRz3b6MMt1XnnUgq7CAydZSibBL0QGAs-UsGNSSq0gZjS5ry4kUnuAnlvaDJeBGlonf5eL42284kh7sG7-5PAQJB5vPb2AVzjc8Ne1VV76mQDQD0XT8ELst8HpepdxwJeBWW3Lnh7r6poglmSS9lgGrnrGApvj5m4-7QkKhOcFSmELfqHmIyBL8hZpOCz6H9M7Thk6OwMGrDaJpig9uaokWUg3ffGyi9t-NTU0Gs2mek5ycDydyCA7WWVlehiGIsItCG778Gf1KeG3gNuHYncttFh5V8iSMv5X3xhebjtbQK6r7kk_pCPdlOCQmZx2rFXmnG7hFLVjFoocOrZiHFD2hbjWY_uCoIX1gPTNtDQ2soc7x-XrcQqiyF28rptnKSD9tFEHEU2UO2PoAQFNa0n6W8bjkdP3i1FGXa5bzs9Q29aHaVeMR6EqsEJ4621azrvpwUGhCimYaABEGiNKZm6OeGV4tVJWWpmNJ8jPJgXGDcyi9n-OKj8jIdMWpusjWbe8iGgS3B2okGTKw4yBBSUQsbgklV-8zCbAeu3C391gi1rJd6PxEsZS6DiqdMxewpBXPC9zlQyE6e5vEGQ54wTJwYu0IpbKvy81Ndl3NgtlHotGzOtcO3fw5yvPmYr9mdUFMq3R318lm-pfbt5kTJ4P8XIsBKnwiBdxMCD-WKcbgJU-jdSQWZLXJvhUQOrcEHFD0q9G5f2CSaMtkv8r6GF4LzzdbKaHrfoKxlE9W-GChElmvvc7ReOjCk8ZNyVkURaQCyhw7WgQGSMebI1SLF3GwrgV0wGfPirjsoKB4MNN3Y6QvBK7tYIWWGQVcw0z_v0Z_WsSQYj98kccqiCPqw0Ha2JPxemu3FSOVJZ84uC5eINVFM-vfz-CHu2Y61ViNXRMxpXOitzKFqTZkJPGJXP6WOrWSzSPuuVDWkZ6LoNSGwP4bWwd54h7C3mvhSBNg5yK8xQXVyz3arzmxuqs3k5vBwGztfKjB6KsjUhj66gpHJ7u0YMhWGaDwdxz4T-_BTJA1QHW3xobgauhRR5o0p3NmhBiPlMh5IyTCTCyTI9CQ9kusV6s3HqwqAqeBkglL_0G99vp_VZ6fWrOyNdc-a4sStDwIUG66If04OkuXKLxVzRdIB_Jmp7redVL_rq03JLE_hvVdbq-621ZCh2q2869yJZcqPYAJBw5-J9uDfjQ9y7cSSEgVQofRmgnAfrxEbeNsuS-uoUafu6G5r4pjpwx8wvYdEURoK7ujgXAHsmA6fqg-M9UwyRVkA8hMX1ZMOtP726B_3ZUu7Ey4FvVin_vNrcqjwTkkVTRzoE4bfTLEBVSXL2gJ1BoNlQMpRvbQPwTmUl4OnsZ4dRO5i1nLNVLBRIlQAJXLwqYAbYvCIi5jw0oEebl5GcCWmf9wSFF8xXXOXCoCQXY5scUkvRdeI8_a3Q9tbhmqlWAv4Zxaark8iOLfpHlyUxbsM3VkXlaImskKWeLpeV6yXyXQ8PTwr_6glw_sEn8pjvXWiFKqTJEa49l4tST6rgtL_4zd1cL6cd6JZhfuktQD7sfoP2WbpJ4zVOzJ7Csr3DvaGOpaN83gStrWtkO3s90cP0IDYYeSVr7PFFgO1yihMRKQHvrx0P9GcnhIIIRXGVkpsSguWaFFJ1fkessqNdH3YFa57KbyeIDT1PcZxHTEOKElwcXcq9hzRZ7xYk5DyOmibvcbd7SrXXGiXuWU7bUw7PQRv2CHKer3MUT5NYWcu9pfsUBGid-ocWJ3HOaumvGTRmsy6bmc92QHp4RFmtpaEF_j7FHVZ5QKey9PmEH005PYaJKY8IoMQWE4Xphz1KG_Mox-AJ1EwcKKScuktBb0lhxj_NDBC1hvrbo2HbXXavuW98FdFenn4GkL7d-jPyGo2jgjt6l1DHHU7IpMOwl4w-T_xL_NRFNM4FT3DZeguD38du5UCQ7y7E9SNgkgMitngxwvj4kAxd9Icqpe1kpqqFp6Nx19qI8G8LKEZiB0uUlAhxxY-7F1spATl8IHXUq1qRVuCQOaF2JUKtyMIez6oa8VOOe4WqoqowmPHC7kIPqDTcdqHBfrZ_5VXyYMTo2WXrJfuGDLd7RXiSiVJCDZ8CVENTf_bwXhsHhyWMFH1zYfYyIpiTFG4DeBozbBf2qpEa6nuikjPJHe_cGKy9yMmR6uN2zKSbJ5k8SuIjxKsnpd7t8M48RKnw3oO2m3lxmTU3TLZQA-Kpw3eUlwGlUdb7Re7PnJyS64ij6PvDgmO8Npp2ci1KuGczz0YXWGaLkOevbtQFk8qyO4zjdgacQoGEZmykLBsFEMYhXy1epKmsi0npchtNyrQCHGVWHoNQYFQ-ftKju18E7B7xaNTmxziVAGSFqTSRm3oJun5x1RGslbREQXmdoG2XWuY2D7fm815wVriK-3NrNbW0pX8Vyhd-cs5Za0oaV1Nd0Ubi9WFZofyOCbGG_wqfwYdK-PwjY5A2yT0ayDX5pdFB-44Xm3TNlygm2oNoLuIrMd0SqmiSANgg-K9z9oo7jxQ9PF9_wwJaqwjPj0gkJWPDuoyWmWH76-Y_iKG95WWugDOj-uRdh6HZ4F076ydxIA3S0c49nCR1Rfljbuf1QUuimbUaIR2x4MnC3VzK-NQhRPCz3DefobvIXK2GN1SfE_idzW6r8sYXOHrWZWOXGCOTwCLoeCoWWx6caoTbpB--YNYDL-6NeWF_TbbiV6wSI38oznYLBnbzL7V7OtxhPKBBsLpha3sQSOvUZvmcS7QZNj-1qEEJIYpM6CBu6JPesO6a1DfRE3T5uEV9WiswFKEKEvlbLj3pD407kN_vuRcpdTcSoOyje7ag6nSTpwNatVUAZUsjHen2xJjlfEFRQmZyLXUPs8QI-TahJDn_oGHTi-_lamxmGRSj4YYDs2xzc7EnD8zoO8rI8oVJTCYWgd_chpkAxlT8rzt2iVrhjvUQowQIb-JW7dkgC0ydJQZxuomY6Uh5nFjlV_LAbV3doYniVCU-7FPg7MOQdrtU5Kc9N23i5lIn_knch0rWTZNdOlkryubJQ9-wWBqic9S8QGsg6nD-sScNww0QtOOcQnqBObLzEeyPTsTzj_HzvtOoMAADCYym85d-p0nzMrwS7Fl-a6iHAGDEZGSa0L9tX4o0y5AhPk_y5oEX_Q8aVqZd46Q9C8ZRp-lFmk0AQMYS6afdJDohWO__eki3-R5DBlxB7hiRQ5IYyHVO9tzdwEr3LKqCrNM0NwKCEQzzbN41GTkg7_KY_hQ1S3wtlM0-cEL9UAKNMkKaDEtq1Ra1VaMFza_M6WFS2ITbQSCuDw-bzf9CkHE0780u1GQHKMcz-UB3ArmWhyCvCiTYuPvjoSyiYYJVJmciXpXve0cdfzgaspAs9A4459EYT75L2hrS-iC6-XqXN_Pnj4_R7Mj-aFcO0WtOWLK-lbQfVe7MfVKenG5mOUiAXO5y59xDpmp0hMcf8hzNG6wDYgDwnx_34RWgnV0vYYeX5ZKSHWGgmSpygPnu5_DjQbD9P8qmNYZZtCcCOCVrHOXM_xN8Xyo5fSUXLrJFuJoHpWOuQX1o3hPVIlQz1BYp81_AIlCLHB5wgQ_MeMcwcxgQOsXM-B4jjV40ehJUV1VY36_yHIQC9wFPg9bSweRWK8F1Mjmy6MIYOFEq2UTID9nRXRCKjaEMUhJgCiTWTRHuWiiy5lu-tLJwszgjdwl2m1F-3FFNqpc6nfeu129o0dwLbSdPZisgXMg2oCf6n4mKDKzE-AJbxS_Iqw2aaTMnzbvdsus2yuZt3EkI-B_4EXHrfTxZxVhZO8yhrV5k5kasMfpav8XM3KdVFnwsoa3vQWN-TCd0rggK9V_xQoMxgAVO2dNYOA6aodRLZiEhTb6eCl8TqJ7hCuxrP3DZ7fb2YvhV7ZU43eWt4woQwqvtCTR4dA_m3whutjk34euVyR2MzI9uxWFhjIuomsV-ky0yj0UI9Xq9kUu-Xs9B6WGkmoXvsU2bkMQTye65POJjrpWSh5vrWB8LduyEzem15Gw3hqpnpsub7ktvqrlUW9LuuKPF7_TwkAIeiF572X20_2hiR5Jjmgpwr1UT3aHCONSYDlQ3-Fzi9l2ASo2bbF_b9ncKtPtxtGOy1CUvBF53ADDwzwOQvOYdwl6MSctaZ0neLIPPE0IzRRx_KMb_B_a49E4EzGy1tbwXxrR678RRPC5unR4_E35ZLW6hSYqO6GSZLl8nlxlZtN4k8PXaTpxG1-WKyucBsWv-NNTYQ8aiGu_BFHAahJbYY60KZghhzFmjudKaLQ-ppvUFeIZhlzsHL0vf8aCgYeiIHV7q1yQ0g0jzgio04t4KfBK3J-KwIONsBpAuOP4WdyBiAN7dOX0RYdX6NRKTL-xo_RVTThQ0ZIibkFsNpkEfNEwRVmFmPhl63jREdD_7TQZVt-SSV17UrLVG8kPP24R9aJOW1PdoUkMrWozvrKpwY-jRokoatNS4lPNdkyzo4N15kosG3ph4t2oVZQSBRvk8mcpoTG0C_13V35CC5_PywJp6gC7rTdvU7cD_ba5a8obcytiJ8GLudDhk8PTySXOR3NJgQ5KnnBP3zrjSA572oyWZS1lXKg6fYIO-lUqF0fAw5Sd5G_H01fFfzvKIPp14qNVuh8KlnhP96oeHJJs9nTVmdU7c6qk7KXS8evaua2ln4p9p0jl_Vf_eFP7OEvhyEssgHvneyxcgqR9qepfpMLzJn09PIuvpKMmq1bYX5g6ulOAeaIwf4D-1e6NwgJ73phrii50Ebyz-YHouxor9pDTS3dthCkMsCg-EjsRO4iv_O4M34afoO0YYVAO6j8g804y7YJ_S6LtQoah_f7byfXSQgyOLa3BCUJsm9z5foMHZQsNt4Sj_ofBLl2_3kicostvrQQKx_q66zK2TwpXy7INbjObp6HlA0JX-TIB5MJgb5HTJcK9phF3FnOPbSxeYIxtdA6sYcsTEEm8prRLp8yO_8j9Cn4o8zimgRRroz7O7kiJ4r81W_AvB5x9wmwI8Nqd-5Jmzjlraeb4m3n3MRd_9gCvzV7eMaVhFSdUN3IGkpiLW9Ol7cVKJZCwo6i_hSaPj3BGy18frGdK6XEeOtm-przBy_zntKdbAhHXW_Ti3_zJvnYxTFSz_qTcnZy0N6AYCjpTjSu6eIIYbuCnYUMBDZqiuErQ1SgAZ7YZwFOs3UNkybnU6VPomSBP6S1T8oNZ-oToLJ1fakLXxyCHPfkX_igWyi_XkLv1lKO_O6F9011UT8M1x1cQp-x5XXwj5uAbSjr1yG630qWAZECZQCCdKK0b0XLO6dflwV8vB8BYO_nJsme2gEh-n_vVwPUCYP8-MFjwayLA3jA4ZhxseNwV7qnPeYcAIwndEFn0zWTxIEoWYRbgtG5Pd1JbTTy4bpATHBqf4YkkXQ5E0ysKlF0JR3vzBku30bgr0x9lvj0mOqBE4FrSwFrrTTrFxiV6ZhHLhgT0wjkuGFmlmI33yo5FjnrSOIR4LBZgMhiwxvozKb3MYW2HHDLH2UK7T9EyNBzIk5K4Nh8fDzKhKURKYqE9djzF8_VosWSTXcHkkX9IicmD3dycW8VkYM36198zs98NWMQKvbmQFqfjQ1-Sj9zGXdVarKr1-Ej3AfgVIrbdGSmRz0xyy0E7FLI6c1k6jh_JUWZeHMmNHHE5GKR2Dg5ZcQzYEVmHZV1IoiKO0jOzWZJ6MemaENt4Lxr5F3J6ksJb-rdQqWAz3Wrk9r47w9stovh1WF11wzCx70wxZF9LzapqSJOY-fOWzkqjpYg_SuBP9_LlMbFveydetxCEEWhEqzhPZ0mblU2vuVH-I-Z1msHYnZ9BQOhtCwBG76RJzU6Rf7kyrIIbgDSfLCZrBl7Z9fkmO13LhcoPztS45emZtLeKS8RloFDYPldOsHj3lSKJmfCzAW7_HULvL1En_Xh66HzTaKbH6t4s8U-OBhP-J3EVMD_nbh8s5GtYNBDh_Hbfd9SrO_-0mYZAavWWn5Q64jK7R3gHDio_cdHOB2MnLtpcAebEkeAdvGjr0kbJixK31t7OiDkr4-ll1uC_FeHu2itJJvzW1XP1kituM_EXktFnt9qwqVTP_9_2yLis8klxLXLpn7eh_NjrrhxiEMsP9qRIztD5YI14kdUYjk585f-Nwxx6ckQsBnORpZm6sAT9B-vMiyUVClV5yfK3lzarKU31oWZx-YKUjjmVY9GheubfPE8G8WsykBuTUHMMO2QNWCjOkCHQ6ppbVLWPVcuWBF-2SM9caI3yNvSC0bXjXP1wK9EKSdWEn7dCynxJcWdgkwbjjBk9TOd63HBDeCTpDryJWvLfcumZEm25iebbK6AW6GSbgaz5lwUohRGNAmxkxLFcXd2Z07chNm6Qr6iV9Tl0U6NJ-J7-ciDfWcSzcZEPSoFQOvJgIIp2QW6V3A1p3k4ZoJwgQ2GOoILyhhtyOGjhjJQUNFOYIQgx4Fz8Ypp2C8_3jHvhjPzQW5JFLINysYOFAS8uUxXTn75ZjCF4HI7VZZKd6xtDY02OnwrO3aKx0HxDZyageJRLf8KnucEkjparowY310IQNqvwzyOE7HvQ45M59ef81kEfxmEVzmIeTxAOW8FX3AtFItEa9ZiAd5M1MtR9Z6vW9VrR9mH2kmcKV-IPuA1-jSOiOGDyGIah_Tz7McSN_5QSpLZkQ5pML8gYfDYN3p-p7-VYauQHGLi32airqXsM2plf0In1DEKt8hIOj4XJyHm4zwNzXUYDzXpF5ppfP2eeFlFWGsdue6Hu-jwXWZnWHTdLSWy6tJCMw6xF__lG-WY21ubtFOTlQ01JAzqNtXX6fu5gV8aS60Df-OwD6x9cB2WQpq4SOizjupQ0z70bjTb7GidcqoO0OFYqLJaAoVhTafX3Q6pbqzqJ5f8LK7BT7CGcAKMQmXrlAkktMRww8GDU9EXxCKZqoORzH0-TqvI4eYRivch1K_CTDVT5g8BVRJ302dNv9beMG1Gy-tnyjfy-YMkIhlPFfYqBmco6mnKpxZEpHCSxW6kfzQkTZSzFUmSswxaBjslY0On8U-soNpjCToC5HR7PqDlPxcNGxCDUVHabS4Kv0drLi0J5DZKegCgk88vtQywnviVN1brs-vbV0PRUiG93f7ZdZ9WFlmvKpYtTgUGYp1dSvZ0lrNh_2Z1KSFYdA-wYhAGEPhD7Tx-kR_iFA1Cf692fQo3Q7ggLAeSMkL8_bxHRDmJT0vRJI2DUw86gnYMd71h2XCN7_gspz15NVlz36dN7crOy_z-2e8SNzx63ENcDltc2fwncZV1ceFONrD7iUaVmtpoTafTfrymbnY_hUETHCdhIOaF1n0_4joDaUA2gdpVy_gMeqgwohITljEDZZPyKX2nPZeo4ey8ttuAcOZGsLzAk8KcsJDa71vQ56qmI42NT34v_lustFXufJDqlbMV3NQ6cAnByL493I3yoQiRIrIysnKBmv02mEYdNyqxuAd1HFRAOGhdIeL_Jnx6FWt5gn6BoCTJl7jEiPOY779rmj1HD73GieAvlOkRljWAIdJeqzOCuHozFMw7ADoWS5YuSghXCJWQdSn1TfZTzA8YrnCM1GKnQm58Be7-XtMgzi7FMeA48GaGMJIuQCpZ7GLgfHE9LAELbfTD5vSij54-Q2yo-_USjXzOmaY35pSl0mEhnlUkQwTs7wFRBXHHmLdLGY3wAVqPFbV_xWhILonmYYvx5Bx_UUtf7J6LFbqzFil0gUAXYTTgFV_pQMTgeqCAFjm3IUNHWLRhymAMYNV5_T5eXZWmjtz9bqREiZuVNnSSX1Qn82z03KjCrBX4FrQwKgHRMj9eBRbIGeqASXgST8kk0c2f5rNAEVYODPyjZWKrqCi8tSDwvvNhyuBeD4V4JhUu8r6M1yvkoi2zofWCIzuzxESk3uJCvXJeR2QXUoGw2oqm2neAJNiBQFstrJA9oNhTeBR68ZuZfetP4HH3bbf6JU0TsoAhdkVc6PthtPPqQWBb-qWO_YYqgHyviC6w1SuICB_3vL6l2XS6jAEMP03PLU6InC0eSHJFSwB_sSVtOThwx5ExARYPZE4w9w26LNTmYpeI_E5tl2kHIi0rIwI1g0SbnDVZLBqTu_jkKCx1aGzdg-THMImDGnId4YL6apCa0hIIdP1PlCh9Q4QgB6hzu9XZokPQcRXolVKyMv-_hmWs6zAaG3GvaDyltbmLAj8s_jUj0KxtiBfYwwEiRb7lmLHTx-KRiNzGKnbCtlOIbTm5OEcP9-DOnMPj-P_3kMWsRqOU_OXlw1pj5bVUS24Qf78-xk9bKg86sNILfwx-CidfcS8XXdvopAHk1adyKlcKS-pAs9wNfgtWg_17tifL2-w7rgkvzt5ltNfDZYQ84-7AzcDDjzEY6t1vn_ZcByJqEfj5D-LIXjUz9KskxFfimWUCyg1-gIhSr6iyC36nvDlGwsLlS1j7y2Pm0CjEZ-Aa_cGMFue2IsOjFNfHh6sZji2u6MG3p4c-1qEdY5XQHz36golANF7M5sEzJLiMCpZzwXpaIkYQLd56oPckXdAO34yfgpn0_Q8q8kGPrmZtHKdm09qXRUFC3axBL3UoOYpI_FnLXKZC4e557ssmbnaLC0huW-mK6sakHAQkgeG3q8xdqxnWiCz3pIxHQurB_yG6XRMXoM9IbbvQT6L45oInh4kPSArjmH4SIg11HWsglIsGj9OSsShvV2aR0VIFhwCIJ6jbC2rRRVPwckaeGg6G-Up6dVobwM8O-_4oxLbRf2ook69tAH7lE1FPLI92OOu0yup1XWadpo4hlN2WS9EKuS4GnhjYL7UToLnj4zlClb23bAxdPcX-v_DPNw5ekQk1P-WOf_-FYd8eSv-ER60dh5yXQq3LQh7bUv4yPUHCs4n8daOm75MyoERvE10cblFunAvPuXN6EeNlseksa_y-IGDq-cqDS8e0v9yaqGDdebEaZDY2NPXsnfth68zMZzGTXRQjwEBdMzSSil1oLKTQ_NI0hB15A8eGPGWOWKkWlZWJE5oJN3AeWf6mfAGvUAA7wMOqqTbupnSD8385sBstpVWzZlwWpysBWBZFs4LWWUWnvK-EPoBhTu3TPVsJkPYkxyHEOJRRlC1gewZ6juG6AfAk0PdL4J2C-4izwtnL6vmxvmdggAOS6LrGBh02ItAn8FPNPl5HMCgf1Be4hePfjlIjfYxwMD5PoRbB_BV56XVJ_JZIr3048_vQnxWhatKrXzIX0S73U8DlxIqvmLqTttL5Rfrw7tsn0h3dmU0aAOtOi2zQzwp9f7UQljETfeRcxi4Dmy62VYYdFDX9eVkgCunzJMBrrHqIJd2WRQQpA8cDOqscaicvXjsIJT3LpT-EOuAKeFDugGMyxDkBfPpJtxCfFSJLgYWsSmYDZd_OM2pYn6iAP8gt7_mNkxfwdHrzmoBwKCECnUR6A9F_b-6057C1WgDjSMbJK6kJL4RXbzXp_g8twDRkog== \ No newline at end of file diff --git a/backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc b/backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc deleted file mode 100644 index 3e26d0a..0000000 --- a/backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoUg-Q6j_Fx3e9SJc92fGYp4w5VGSbp6uq0fRNaMWSjXdpCbiVVGum04UWox-dJlgGOPRQUY8LZVViu_SdgoISHHwO-Kr18ZbchnmYioQ96TSc2jqIW2G32ZV12w84hGAFCJYFfqbSdVCKwyV9qO7xYYIhF8nLuI8cafF28EWgfvwMOGxpizGPH0qCBlOpEoEH8BzAsLQ_SeLrGx394rGWQaObAfBZ9PrAgLv6_0aBZPt-EminEYNytGieDeEsOLYKEqIrhQk3twwZ6rgD9eLKeaJ_b_wUQsOUPh8vTb5WhuWsTwC6u7vLFzXwZWm5Hvdc8BX5JosiNIsoRJbeHuiJbX4b5Y99er7Yui5tJRVBoCTlyK9SblfaUW22Kzfc_SFCtw3qsqKBps2PyPa1pohgAfADxwtPk6vUt--QHOjx3EO4xiM3dY7Ar8zO90NZbAcAZd6g1WZ07vPkwmSBvD3X-ZtASxstBAw3Vr8Mw8H5Fa-btrF0oZfxLR5VUXUy9B2O_xoAldyckCvmn9MpEUUFGK8fZLjPZ6wDr87W8SHyVNfilmRHb4snHlCs2wIUNvku19fPXw6prF34lRpcl2e200IRezJnrpUw3AfDIaKTog_gChnCzr8AIAGnfscUuNQLIe3PaNdzcN59KPMLIOAnhxIxlYRz8HMIk74c6jrovY472hdEIQm72U6w9ou788O9hZ_hIanAcdsbuAyyB0j5-Y2UGWRn0i6spnBVmJqucCH6fX1OUMYMQzhaY6nfRQpRH5ny2C4opSf770o2EStcfjXy0sdbJgIM0Mn43a_eZUJB8QmyxJXiQuso4FnXijvtPWv_ayW9-iPH_dKJWWklooiYLMrQSmAk3CwovYjk_D5bjv3pO5kiXz-EhWMAgSJKqMhgcWFI0KmyO0g_wYyRlIQ0eKinNaZ11_T1OqywUo06u9E2y_2Kd_uh5cJpXgLod_ED2W0Ol-W6md7WizwAVzZt4uKSaOmR5-YrEFPtz4IiNiNwLSzh_h-wt7sBdF7R6hlQAtrw4Dy5h1TJi7GSrF3ALd-sKXdR4k1FO4UYQKQX5lz681EK6wEkvWoUdfwO_d1r2IoTrtdU72-ZgnXLOHL_cqzS3VRKIC7NmB9Qo3biAI44LF34OR9To18BvH3t72exs2Nch9H1qy4N1DlwH9jW0BcZNlXU8B6hIi57yuskJ1VIHhJPRQnYAyY47gzPF5m7sW3X4Hb1MppDP2rDEqwGm2arNDB6fvgRO1A9fzVvVtxyOWUVcLqvC8L-B7XqL6nZ6LZ0Uiibr2sLPUwypMB8q8kL2ll1j_xd5cQAddPynupu8L1wRHvIzqYhKQuDz1chw4bLy_4sb3M2suUAZX4lxh-VBKqg8KwacScLivqvrPmUOYj8W-3hmQf7VNho6x3moOT0YtNDx3Jnkdv_YuR4pCJp7gPTuSsDPK6I_z8-e_mHTHQoJA2tfjsDIrL9l0V-_JRtkNiasThMb5Q3aNzP73VeITBe6yqlpVq1gl1tKFMQrz1v29itEo-QpV2XrVLd9ASXGUPv1jQqCh2a-oBiGgPpkXoqSAlwjzqPxtFyVdRU048iTZBgyiY-MPTfNSCS4ewKp_barlLDzBOTEsho5cGGCQhBJjP3B2LYhqZS5rL6wRAUDrRaQbBRKBCg2Fm9vsbuMETMRH4FIUSRN6x5--Lfi9YIZ3Ir3-Jjs9aq19GbQpGusn23-xcTbK4wrE68xsazuGTRr8LNAcez6U_qnggUvZi3vrmukZAZkoNbUS6pwxZVCW0AYmALX-KgVnAYPwU78X_3Aq9iIunBcmc7Ff1rtolXB98lBPCjvS9jDrvdQ1VV5DHRBsmk4FYfFd3Y9B8Oj5WraaOhg8cmZ29VaJYnqpVT91tN94D3Jq1skJYPEEywQg48uWrpFWJTg9KcFBR8NAOZuIgpB0_DgZFVbEB9B4i4LAe5DvDfaZyhXABmpGiMRrQh9lIa5Z7B2FfvEwFDyH9EzUWam7xHszEi0BFB-58g0AXyPRH1G1yJtPjIN6nAK5sCG_RWGI1gzOjPmkU-H9Hew2Yol2dSdnGGtIVAt_Pv5Cpnj3xEp-kqvBpGrCG6VBANQMWbecKnKSu6bbXmjRMJGacz6DwXRjYkkNSWiEJ3PSe-hVUhnx1MfHc6HnHkFQGcfWf9g4r97e7BaJQr6yG0V0X6a0wZ-1JWajsiAASEWl8G8KhVDz07zLAxPBbBZ2q1fpFiNKx887xLE2208HILLuAVB5a_bQU8bfZ9w_T5SBgrueBlxdXlPP3gwXjWa6HTIrL65Rku1PvOGgSCSwJREaA9d89QrrDtrbLCMqHhOzwIScInu-gjuhS9DI5TRb-Cy1ZzlaQg4mdSbDpfcQlxwdbV4GXUDiiWHzrJaqK8ggy0MyMyZVrY891Y2gER_iQJn432I6_hQMGtfZjzfzmxAJVJkZ3eLcmBlMb8rnqa-kq-eZ9chSr18FHvy0Rs_Tb2P-UlLi663ZZToOlEbWxkT6NNEywfe2C6H6HFPwCnH57KenSWo3gnBfncV1BXzGOcvjhml0cvVVyVMHc-J_mJOdBSpX3MM8JuAqRvMR_PQZ9mmRqc_JZLsPrtVnj3dEVW9nhNjtDXUKRtJ5doB3pplZt3-jO0AnTK0XAfiXmmzbBRsvv3pY9J6481ClO7MUWJjZGwCHRXewHQV6gKEyOWNVJlSZOSmXXcD7CoPmC8G5R5Ry2k0eu6s126LDdEvs7jhbdyA-znTjlFZrK01R-qwUJDzgZESi_ckrLlA9GkYVucS4o_gjHZxDbsTMM2lPWR1zvVkX-FM0GIi7glWS5Y2sk3K46CEmlAYHtQ3D-4R-0Jr0lBzFtguCzpuPvUg_IdD0HUfPcGcI_KdAoq3mK5EKkfC5L8fkCzjx_6-MpOweSpOCBkvf-D62XuEuyyQ2bMEInAWtuxRCt5aXB8BmyAKJKUWXIaOoNHIWB8tOV5td93pnn1eqHLfXQSdmFo7__RmwCCHNRKa9vHRYkTcy1n96GaG-gDIB5O2IxS910ZzRU-7qC8x-oROAQ3dvlCqfhNL9T51tdzMCs0veGPxg1ogKVctocmT5FPflbKe_crqXp6NX6xZ30WcluA6CWKN_5zsl9iSggqvZpPaLGrz8xVJwYxdQ_SlCRRCwgndL6ORiafTWxD2T4P72q2NN7yfFp2IwMojM2DiJO1KR2JuT2oXCEhv4pxsrq_rTgMqmMxqEy-d8464YIIQJN-FSaNNiklAZ9Pf47OUk-CE2S2nbjojH4FUISXbKY03EvmFZSUjtPmOrKgSbuD4abOJK1Mn724239CkYYhhuFoy8D7yH2MOC4dwAhfdWN8eml_AVOFGJvT_nxDV2nAq0pTpD7yCNsIl2HFUZzhpMvay9SMHQBCrxlsuw_Sjr4Ctpk0XVIb74iFxlnCTMcODYbB0nN6fP5ne32fPPunuH92GZKsYEnZrXQwOHox1i-OtylfcPM8vUfhC5hKvg10buh8qETiXf5hS61KbhVfJdcWnNrYHl58-zek7rK3tQhNqf5Et8RNOsBYOqEMEsTDuTstLyXr6BBqbOaE3xBbn7ojNGJHUKnoH3PkprSXzFPzwaNwD_Ed5AefJA_9GVOaCJrymprYiGYNk9W9DugseaZGCyoqG7U76FlFGh1UHlrg4r7vTi7mQ9fV2SgxWI1Y-JqkDtv_hAFwOfm1pmpMofvfyPqUa8qiG0YD-fr5kAi4LikNCWbrELUI7An6h60hZpUlcpbPS59qp00dNVv7ALHiVkdNrimWJIQOJCGKeKMysXE15QFVl2sVB-0NtGdoFpizseaZ4nJDP728QllBVYS652MmntSA-zbjwvjHpXihgNn0I8sAnxnkcOYKzFlfeta9UQxXRrVsfAjJfC9UDyslos3jAcrllTYuQLRy_-3lGuESVSj2hk-wUBQS0Lm4rmnP0cWP3TXPOKJ9849C5kSmh9LgEwn7FSDssne56_93WMhFnxgzn8wwug5VdSxpcLpG_g95CAdL_nhYGGciMB20nDAKYy5PVe1n3Ass0xwqQt1MpPfOV8--2_0cOO8bSMUOVkCuDt0M9fpvaJpR2N3A7ooyjaLrWtToZPcZuLopRci86yeNYwzaPr3FreFIaLPwTBgJQGPWcjsaw_0SAKj2xbkNQMqSyzNnm1I1HMdlpHhD8Ym7GypEnoizU5RZG7dDd1DJG3ViCDP5cLy9v1M2fepcTXN4un-XJUw4qjydd1LFcp-UfsUF2LcN0a4gZ9ERN3-oaqYFQL-6LKFMfY0gRkHe771FP2TPAuvPRmypfbigTsNPStPKkMlK8K-FVjK_v2NYEX71pZ4eAEpRdNnVkuCcaWTJvdiDAelx547moyLrOV-uYSnZM7AGVdBGvxWQvRqrlDxehmZEIfynlRSvNN2ofG01W_dKTdPFEv6-JCgUt3ZDAYWuagL_zxctlXmqhbwMQ2FgFr8yw_c_IgNeeR8x5HMRvU4leSlF47bkoZPrDndGKA7BMaS5IaFwV12znAtq0DREnCZ6F5M9d8mpbI0VmAiAGi8Sm9ast6c_86koPe2l3R2E1JWYBqzzrHb-lpfyU8gFk21EfvAQ4-a7mve7qkaeI4f4aoNdM5oBuYTuFUcHW_y_PxCpc1p3ClLs658jwBKe0JQxxzAwO4lNNMqKQx7bgRtI8EqvayEgrumSeIDoU5ipebtjAlPF67faA4rrXSyGKkzwsufTdUosoB2Jgfozg-yZwaOmfBARY0q8l22ipi9w6-mVhxyanPNaPF92SPD6cO7r4KVmazvh9jD5JTeFkSRtOVX3CQ7VY_xcsUxKyIM4t0EVfb29YRM0jZoHhm1LZ3gcwT-Y7rLccw7TtVgBE2Rd8uRyXIHk-K4Up3L-DzULAlYUOKxhbyEHMMCjFACiYsQoPKHvMUXTP8-Dh8jy8vQqFKKCUGPRTiC_68INBvkCf33oAjgnGq1yazhHEZoos4Fs4nYMOaRpts0IazblfHICEC2yXSB9y3J4hW9Yy7k2n9t8ndyj5fJj9acUWUdRLlm3rfYGyc8jHeXzGMhN92FAHrKO1X8WtWIqx4pZsrjunhSd9x-Q2ka2AMAZPNDdrKb4t2jHgMSnpmp6izpjWiP6YH81s0HNuHe8dh7iUhKwPzc8YMUBpKW6C6wLCpzQ1yH2Ckj_FS7UJvSlt1-Y9DApUo7tVeu5maMQpXDqk19sVQlKzy28ypVURhmzyKNWcrIq8jDiA9-IHvLHSh3zhiMtSqUH67bFd_SHXbBCXC-Ns9OfBQoMStizb9Y3p-xW28M8l-635voVivLwC7Cs7KT0uhOR6bnUBmHPhv8meemUyPqw_XBltqxZV3WQA8yxC2BOrVCzEKkqsrJAcXTdi8rkcI3r0s4I06SZj9pIfgNe3Jjmf1nu40AZzsEB4UeyBFo1bEH8f9zTrCEB2Z-QKCdcf1F0BH00SGBg_9lCPzT7oj4w_jsxJVfe0pR9TSPS7J2p2c9wiNzCO2eO-vMuQNwjADxX2ge5KnBUawwloqrVc90bBTtNucsI41cmmGAa1ndcb8rKRrlW6HzFAQ0jX2q3X58EQ4H_itgRdcXarjLH00e-9wVxJ1d6uXvRqEHCEgxrqfx8Qwck9MsOsqH4lG2Ne-fwe56bh-w492F3Fi1ZGKRiQVcaSS6T_z6Aw9rI4xjapfTDfM9jTm7_VAjbjKFoPWqAhR9QBtl-QLy8ce0v8kX1yrdXpW_IGHZ0ZyL0QM1DxI7pHNyRnD6beeqg27R1Zrxlyga0X437jHBXWH9yfoSxoTIbogv_j5S6gt6lHIv9jBw72JNyPzw4iYgUvBfuRH0c4cHzIoNUZSvCR1rcv7C_LRwkC9qjN9o_Xx3AyBWtT2CQ96476NU_Kl3l-vFSt2h11dd84gY-HuVWEzK1fGpQVGg8JmH2sdflqXubvRT3G3bq8Fn3Lhd50kXxd_kwK8DeCwI5221ERj4NeVHyTRpwsxOnIPrgyFc5qPEwzteT7coVFswwH_Ecv531GDTgHuL2QhLjGt06v0OZZxzj8OMVCgYT753-jyXMzluTsg1qky2zcJ7D0CkGM3usjKwLF-dMPI9qoBeVPl3f2Xh0qx8sSQZKDGDzYWJeKLke276Tn-wYlgWy_8exvMkWzjH_QW9F5zsuogxfmVempuZyL9SJh1YHZnk_rsAXEyJmhK-hk2ovOQpVsoMrmvffwrF9i3EgSjw2rxKqZaFxQ6OX2ZZVaMkwS0F5gamcgifezTIumIGx_cJz2dBhN1Pv5Tu38J8utQDNMDCC-yNoHu3gNKl7sb95fivZqcuzHOTyivwQm-E8cGh1c0-KkquPWy3jRx9T8tf4A1lYG8w96y0P160MxHt6xIFRlgE0KsF1_rdNPkrXpN26l-wW9aiXVfd9kve8R9Pz5I7m1_jygslNtaTeOaFYAHKda1OHMj9-27Hf2pEUNM7bWiXaOyCLlmEH2pNt3VPrLe_Rjp9XxJ3p2tkJSaXn-bVFMdM4yVM1m8OA5pDALOrdZpTnJT9V6FLkmrDzfrg5vaq0gKVPAHnPKHb-wHSM59Osf02nC7v2Rjea4eeN-XXTYeCk5wlK-HY0y323q3FQyjoWm7Ur1iF50nEAmw-0aAeyVTTQUmSWVwQYF7-kqff3FHRKVW3wyT6NxQ-HfP8b1qGnG6Jt5TVXRJyP0OcguEXMxmPqt4X3kPN6zwbVwVz8hbjepaNW051FVy_Tf39v9x7IDRrtdUqiSTmj0ss7ZTjStidWVNJFRn11GgnCu4E6ARkEBpcRJYv9AkNL-Hj-qbvDpVJa1tiiT0pmeMDkJGwpuRfIiqM90b1dc3NeELM1-3jrX8dciiZ-C4ALMpanLIHGFql339ERMEwtnF3fIvqNMXxshrIN8ifkwumrzNJPtymDp45yvOeB4csHFnEMAGJBKiMhIBk4MAjB9OgZy17LvP4HmxRUgrcSvs9epq9VLSgmECGxRDmZ3T-ksKUbCcGQKQWho_tLfp9FlQiDgvuN_PKudCMq-e12aonL3AEP8CrOnuRksW_HXkggkBf1uirD4oWt0ZUPZTreY4Of_Gg3y6qKhC6EmLDhdI0T8-UgFuCUyUeUMTn2OT0UG_ZJKUjKKI-awlo2TKr-Zpdv-qT0bqFq_5jRvpm0mmUP7J0j0Sprwb_5MYRz3im7KhxghWk9B8QFAoL7bsOjqXD9CN5KOLnN1hLPRN7m1d_E_gMjNkTY4yy6_fxlYgPo23J31mdzWIffeVm5wPLtjmUidbrLafuLE7r-0wNtEuqkspgPsfYGBxTqCgY5i5Q0jrSgCNSsN9t5OAVRvsZUENd8CWZ1KzNQhCDiSXcKDxVSGXB_CUO7DA9zBtKjQQES0y1wD_ARn591Qv7Cn1Ja4m81NbE2QUGYItXtcvHOTARTzJrq3Awn2GFJS1Mbr6fW6QI6idIrhHIvQIvNm2eJqOGHn7F9Gf9T9IP60j-ChLlxoOeT7CIILvKQgh5NTP0rXlH1NDMLkAx2YPjNfbs8fjkeHJLzJQ89rpOQL4kYtbuk4-hi2rdzmygwqASjqVUjBFSjt75KewqeGZp4KPQpO3bWsjOlnmYizm5X-B-idjIYxK6uGw_5O2EvLyEAUknFtb836kOpUHU8rvd5CBvrM8OmiNAtqPy3_7DyexmWODpGY83O89ARQ4EWonXKkf99xiQOjAYfxE-WFXrsoQ5DENqr4_Yt0v_lsmLh9JMclZqpXqxgDRwB_VWuOLfxfMXwjWnribMpHAHOyRIbjQJ25UCR9-lFYtf0G7SPEYzun8K3a6omW3mD05LjzHKehalKXjfWcR4-dkzG1wKYbq_bC0Nv1I4-kzjD-q72MrJfHqEDRn0tP5nkIRGoQaNV-TYWE3nNRH5EwcVYeUcBWO54K_15cfCkNQ2ombTFvo0itY7U2wfkquu5kJeskfDzJQP8kNP__l2ufwfzZXnWjTW5MUO0O59z6N9Njlp_4lsvocldw_ry4CAwY7fLopsNdNcR19KEqCHng1wysyhS2TECBP91eBuebuIZsFbYpq9ao242BKqCOu7M8Z1mQKhlWVz89AVzy5VJoCSy6Y6Jj7oOITrWbvSt6Ktgih1N3zlTbsB_R6P2I1dXFr4XoKdKzDeOXHXsw-iI2zIi7gzPFpq-bhcLZlRZTPDPQ9DMxSk0G9SN6_SlN13oBUGuMm_sqBSf3vmYE0D9KBr13kN83eazfhlQeZBvgyI2B-16TIBVSLA1ClJsZtd98QuhM0HeguzKN01gZKvKfWSGUaM0xLidc0Vn6dedTDbaihV6VbpSqQy-AAMrJs7Usk8u-CqZKkeRipNoM9obPJntL_maPa5FF2BxeIUcipWS3C5WNISPXenuAURDMznlYn1fubVx1wTsU-zIf8U_WKCf8fnhBhdGQItEft8zrbdEqIrVGP1W56u1Ksz0cKAy3_nuQxeU0LdZyaqqeWV7NRDtgiYVmuB4czczuEEo_ZuOMbv0QDwLy3tzHsplBNu29ffX8wpXQL-eDc81lEm_TC1nNHR1U6Tx_OBx1pimfKhQdcuyit2ia--_3cGTleQrrCX7dAcNuPtPf7QVUX20C_UudWOIoDoNUnZNIXSIMuem5soa15g1kBAnc-POzPq2ealjdKG_CAg6mE80bdqCQOebMhYhA00w-1_WH7sq2XurvZRMU09boa8c3AAqk7bSOuNVOOWr6qEw6ImtL3rIsKQIk_j8GfVmwFdlIoqztgMGeJAFbuS0tNXUMXJCfyGAm2DVFvPzGH7h5k3lXyeahHSRC48hxA6v2eneSbf4tJS6iais6Pqsm9eZQAIf8VnyKLmZkHggQJ-alQ38staltp22L38YOYxNh78GIQSC8L7YjiNL24Q-iPzfwE0oaQv3hVLIqwTgfhrQ4Gi68G_T1Ikz2gptXsv8j2LSD3xLVUhLSQVwyClYAk8dKL0CzXtmVOIy5QxQ7eZCNPdeKGfweGoudf-_8UbVjcsE2UUk2Bw2-7E9jcKfYPIPh3HyvvYZXWiDIQvrKv5ZQsSRC_o_-q5BCrPH1ZGd4deCw3Z4ynz7Amjqa_sk9x0YvNZ2fJuIiuJEW0LvM4tddmv0DiPEyTFvgSQtvHOi-mzkgeBj0Jg3gnhR45Z61IOuSGkVKYpvCaTxB14UR0Oj3gK66RUrfkjUuISfa9k1Wzh8s62CEXA2eZ_dpi48VMzTNMRYnjSpgFFPUVnqx-GXWPaI5nllGv6IWkeXdWPadV4y3kjZTP_8ji6LtJsw8VdKP3AuPzlRz3FNykElk-NJieyiBbw_TI9NV7Vu2jdrThzQUJhPP-ddW1q-odmkDCm8xxQ2oc4foxI1RMJd5XBVosrVIS6k2bkwxjrvz26TtJRXNWEUL3xM_t94nK87yiz-1xt-wzGRr4gV3zLxxcUIaHWK46yPKptIiz8YJpA02XLoGlb_5hXW0fkGKIIIKBRVJsm9HPaoMRvykNayYdn1qGM5IHRbKHjGQEmn0xxsXRAY30dxkX93WASnUhghB5WZQ6_CJ_61SOb73Tzat2WmyMvEAaC76QsInSyThtxcPJJv4vMfQb8PXfsBZf7j4xXoQ7T7UgPCULG6Gx0rQ1Mt0gGcBacwYUexLGaMvV5Tr4ltuYLBg8rspCiewKiSKWiYGHw7bfvxmWrM8UBv2BDJRTM8PhUYTl8-6AbnnQXXCGLvEzGFbNJmDt4d0NalbQz36x1qqY8n1pT2-hFedwUwTPkHsNsu7xxMoQp8sb_2NlafJE6Hh3bhfmpOo7NKgLROePpVY0WmJSJSYUzRSJR1FqX1SPOR6kGSuJ9MqDzx-ExUHVg2qCTC4jDHK57TfT-4fupkliYASDaKJ4TSEg5xg7zXc7d4AicqCEBCvH_oZjxyvcSrB6VYorcverUCI1XLdSl3NVbe3KGNuXjdATFCOjBuSMAaAJQZvJVUu-k1AHQCcRHucdJqaPUKqcyxpoFJU_xBs8gMNL27dS3poIzpj5xFvm0H8rOjMWCqs4C-mXYRYV6wbR-mfcoqVSsYrRhPdc0SH3SyrG1UAJvyaPoEwfA7r7IuJvBb12u0Jm5zd5OfiYlw6tbEtTHfRjjtWzKn_AgDRMTbPay0Jv7LkUaNinC856jSTE7N4F5y8kKBHbrN9TOu3qf52DZYe3fkTR_HzFmnctI0hIskrWxcY231Jais_j8ZlPUDlafo8fbMposd6MLBkG7FO0xU8raKooMEpt_Z9l-0fvfThysZ3CtKdwYaeN1C7TWV7pQJm8-dyNhlDLz0D2LEFu2DO_--lHGSjH9bppnC_rxIeXyPU4Xqah7PYLxrD5c6PFLFIFmKtxe5uqAH_07z2rseKKnqBLiJZsRGQh_uBAhAsWkmAS9sZ1YqjYQY2XzjRRdvzZb0gxGb1UwuLtloR2Agpe3JxWjcGA6H6RzpxJGk6KA25tonvr9q1PJHG_ssH5EbAg2OSuNXcUp1qIBMnY9_n0YF58nM4tfWOQMDxT-fS4CtV7ftgzDS9NYNxkQEWcejWrJvo5Cdan9YvEIK2WWX5h2NRVzsxdRAB81fqA7hNVSkq7_XEWmiVUHFTXRMND0SZ17dbVynvzCXc4_TojqVDO0uLNN7BRFgDiqYlKC5VD4H55PNmg7S2SHHIlR_P0hToIrKlHtA2spxWzPSr3sgn0Az8UUBzlrmc2rvVhHhxXwi2jPPiEtcLm_cydcnnHnzadhJDKoLnuGIj7-GoT-wNkMDBtnMQSNLIQ06rXaJy7r59Q2tiwsOXJTXsMtfkgjK6zYH-3tgNQpFvkJBNm89_HnorTVVjYMWcNWj-RfY0l59TIpPgeAWR2z2ITLsL3qr0X69iYKJWi4xHex4fQtAadQGjfXkabd2U9DbXraAhfFcwg0bS7NNIgHaCKF66pLIZboxalr60r1-Wy9hynkIx_KVCro8Ip8C_d_73UYzoSW6xqcu8aOYvFXwPZGJZFuLOZtaSXwVyPqqTlsPmYvz264d9feltJj0SJTXN-2u4xSI_LzOWo0c4i_68cJcRmI5R2baEMC5BdxPSWCToNTfARpAF5Y2pe07NEu848wq_0-i2m4J3wFSUVGxOwQ45rr8puCEJESCZWgd_rUbTjkMwz9JevjkOy92DGl1r0tKOSse4szlxUhBaFkIh0UViDqzDQZzpQRP4__2pd5F15Yt0gnqbM8xQlaXMVsRaTZSfPskA4r-m9pgZHRDvZhvxXH8ngMZLWL0XLCrS1pI5sbLM7bBN8KYyChJ4haGIohj8044rP9esvI9kuru8N2F3lhBghle7I7Sk-ZlkT4yrZ7k4yVdLUqw2VgY7vbE_dmX_qr1UyiPg7BDqmJ52Kn2gl2W4rFk-1j2ELUZqzi5hHPzEB7Ld3_yvi8HYhbPxC5Qtzl1jbfsUqGJgreCbv9HeNb9ztsta5tDhjSl8K7krIIro-vYyeTQ4S7Iq2Xvi8XS6hruK01RlbvVLn1p2QYNFWZxvzuNmPLOlT3u0gJE-muOd4JwdZZTY1geqI9zj5CRwo555GKXyMcfw7H9TDhdcgPu2EMM-Y5_Rtx_Lm6OWx1VuXbRS6POnO3Goh2ITQ3i-91tHtlyc39z01mCbSFCiQe9YQkApCZmvo0kZdOpOD_hHx9SfZXGyDzYLA_BHRNVZ4wl_gIV2rIquUZoGcRxGS-1A7K2o9gsy5QGUeut6zHNpXc7ks8PBWpOcercl3rqQUHAex2xERMXtc_KD7bw8mJIsqdvM7jYNK9_D7KOfrw7PKGJRZNHzUZ3H9aLBxLmHXz5rmYnzTU3KlMilNQ7O_bIejd6GuW2yghLcZ2XpjcYFibcYcTdpRvIDLJWIQPOV1Uy2WdPEVFVLhpZBrSQQOYSuB_tDSex6LdJ7d8AigR8eIj2w8v7roNbY1r-AyJ0PDRTsC4mfXbHUkaHpFomjTTNo9b3off6lKMBygcfTOGXlIlyrl8liW-s7kWGzK4EMfjiAMbav7xO-Gww2ae5zchaBSrj5ExX9pFNM9Gpvlyiw3l0f01hefhcEIraWnk4Z767OxJjJ-Hwy6sTnG8HO_zpBG2Hr0csFgiokvweiJ9iwG-iMlMNhUa7RcKlaNg3UWyFxOXSaQU6MTMaa-okSwruiWG_zaWOdWUF92e6oIcJc_WSq1MdfXaG1rSHs3j_F0uDWsQaE79w1dMiYm-6-G3RmbyKnJzP62wMdgaj8Gfyy9Ga3OjatSAx3vWN-78AeCdQOKUAOVxcG9pQZwYRUuESHfabKYlWe45kIHA-KxSNbTfdPqHJ_aoLWr3vqs51FLmfFVAWDKsDCA770N1gD-PwutSgqFlhukV_Pf9lGQubsf5v4WCR7nmmMpHRv9xOXuHLgDQZD2z9hOdU1Rb5Z8vLQMCKE1OCx3yo5HTta_wP2IFksfAVsKlBjXgJm9rmWa_y8GT7IG2R6qJlTbqzGIfGbOLoYRK0fKz91fDd_AIluH1-D2zZjEsXCPTrVCrjRE0uh8g7QSz5UmCg0rSG1jOF4RY2-x1DIQZ72YwFa3iB7_a6BzmJg-a00o1NN2LQGhlDHNLQelbIR9yWtkeycHllEnK2NMLQV7nBuaW13BHURuQ1iVbHUMJmcbSccU02-Sk_w2ScUe1VZ7Dv-I_vTTIrjg0mAQs1nUmNN-E69qor4dvkGxWsOVDqQU8wVqSHaLAj2DoY_wu2_pjCdaklWh5uWzFNtB64xybTtHDm8R15wQOqWs7ZgtM1b8HsSZ0_aZ7yJaeEWD7zRYBQg0SBKLO2enkMvecseyilYdDmcURoWj8dF9WwrswSXw4T9PngXs7pOXk2k4hi-DsOZE_CAyT9XPKj1oXDj-X-1zsk1z8ZPw62mVBn3qPBqpVlZ0Rfdd-CDX_OFUNaGRqQnbMJkYLZ-RSzv9f94d_aa-Dlx-jZXfKxZVmMoe3kc3t5hnhhFYXOtbAzJ61eoTNZLm0FVeS2yX4vUY7iXXkJPGMpj2gFokaKpVuB0uTcUnFbhiO7ss0K0EaZ9alCj71G5muXtyZKfpXTXa9iJKDFtKHlRJSPxnGHhIbd24ERak8H5AjkXBjLJGapyLUEjO7UKV16A52GcNpUjWU8QrOcONwpceI11frP9AyfiOGpUybymja1Y2JFXhPoDmDB0gUjzkvmJTTwktCPfIswWyRoq_smxoUJ5UTnD_RouZ27fjL4-zeHc2pZ9SUkCxpBRBjjkItHsOiNW00bDZgX_wRybWDZUM8F37hbagWH0GtRbr2ox_GTP-Jn1v8iEPlyhXhAzIaau9XbEU56f1gvMD1IB-zFKGgKB_KB5YmusWuAtXJxqUZUrXU1Lgvtk5NEb0N7HpC_MQHn2zCP-g4_OkfjMcpwXRFmCCi6Ohi3TypOMH8WYuYug575D3GbGrnjDMyoFm6baQEsvfwGTvFb_CHBQVCCTauorbqqVyfm-SolZEdFJKbl-jTmr1_fO5r6vzvNzdRUWUFSnYvbSYNr8wTj-OXXuwAKhZwUtFMEm2akrmLpVEhUKTzNiCy8cyT2KEYQASstIUtg-XqkATr-IStc2M_UOdYCRStxn7BhsbS7p2JzBmUMIxH7d9bmqzo0mD_OCl6SZIrLTO3cpcUPENY1YQoXkAbU-jL6ulVlrDtnyhVPWgcnQMMulGDCI8wOnix8qm-Z3Q9ChvX1PzfcHZ2w-aTHRiLmU8GUCitTa3xqNTJds1oTbs562YWq1ghx6uhFaF7enG0L6WqW_OexY2WTQHc37HWft7qaj--XqKQKds7jQiDECKYWz-HUfj6Jm4vLKhzfkWaTERp1noYxE6oDz8hBbnVMO-IoTTuncRot9d2sJottbmtwQrRj_TblT_w_7ldfQmB30weYYrqxd3XszdNA2dCOY5djSDlWyn8Z9i11wEFTgnZAjMRF_8j4OFMoBQgfaTkrHn1A7VaWuy3TAFlFraxC1gwjaZy6si59iWdZfiVH5lT4Ig6pvGKKHWIOVgYE2VZkRhnEJawqRqj3OuaYY-86ciOFEahpCi-_1Uey-QwTUDeHINbVDicZPBUSbpkZJ3aNql47qFbY5xS4hSwJOmNUnTkYOUkUVqy9CSXQIyp6pi4VRU1Yb7z4vWKSbLgMhv4PXnUQvUVkkBdaUHTAPLjJhVdp8DEjxz3KZiCKQx8jGhRWyDCnNHvF2K5hyudVVh4CY9te8UMjVQDq2eeWNR1buTza5M3r7hxxn9yUVibfMme-NnpsgZpC2Ues4W1e4AJEF4PZwir8JXygSYqkZwHfAcyq1w9AjllReaQ95UWqMszMUOQz31026UatiFKxkfjr9SEGY8HrU5bfuw8cxGDzpLgWj8eesAD3_fTmOmq5rZbPiTWP3jR38rOxou6Uesz-37rFCcdSqT8z9e-Gu11sKoxnFwXmSRK_0g1xbeqYLtvZzkubwu1Cyki32x3-GCW2SY449VZr2L5zMeV88-mg7NGVfWRWlzMt-ZbTQPDeVuan3H1KN-nrmE8CQPIiYiK5go0WhERkgoiXcIXvvUsMvD8jH-zhMP-bcdKd_PE2bI9pm1345dsoBt7MRe7MsJZjvLQgcGRxrrilit7jtehGzCWacsqI2x2haQWcO9-DoKiinIMer_yPL4BdmJD7OpHNKztJqXKXw61T5BhgbMT-JJ8SDtdHIduGMfwJqMgRjmKxQY8-LhigRCm-5w0PajLPGea5Q6eDR6vC1YqZXXOSsR3UWGKAOX331QEIQ1T-j0M7nr457R0r0bRdgr4d1nec_BSbu9VmEMa_DVU5d1OwGQIBd6_4E8yHCB5gvwqNRU5qf88QK_zmZS0QwAey_yLZAIDLGxQeZ_e6nUw7gS7s1wUz836g1BXfFUp-CH24HaoO94O0z3K3PZty-90OaYHuv7GFxStOZO-kPjBYVgf3ybz10tn-zRzh1etjjaENmcqGIcQqFvr3Rc1QH_UgQRn8A9Pg1YiHhQKywfpV9PJu0_QE9kvE2atzaI3Z5_8MiZumbbvnAU5a4HYx5sPWfkDbz2dSh_Ib-zxrEhm6W6iO2aMaAfBHzf1Shahn7Y1ZnHD-LLf-THPlukGRc8OopCPC-jmJ1udpvg1YIBoayd12jmo1cqQcM-v3k5jptIvts9w1jUGO4-9l58Pvwvg_i1C_qarYB_Y6nQVovuxuPhjjGummQUrSSerooW1vJkyWKFa64Gs_obiq7QJKXyZHs91bqKz6DG6Pr4ITEavOJEtYzsB-JTolkQwycZynAiDgSHAdZzTmFCxO3ylj9HraD8ulLCxtDdo5yy8sxP9iPQ5IujnEget3MLwKyem4iVNd8lrL_4LdWNKHUPx0K7wTiiL36csrxUmsQeXBWYo0vFLa8sxvVnfARoi7DMU7HIt8M42MfiuJ7PR30LwC8EgCHcV5iQ2O9C6_m-W6HmWn-be08SUKCKPEBvzNobiwJyh-xWzZ1OzgbPcpgAMOki9YfHkzN07vPUqLJ3CgO5dQxz2qV0DHREFSLOwRPId7Uk7xtFeOPoRlzXCTzes-LReUygyylzZnpIxdET2VvuJyK9XdeXcCz1SGAdsPECOD02B0vwXca8qOhXrP809iLaLk3QhgK8MeuPXruK48beFg6L2a4AM6ugxggukYkJv5UYsw_mUL9IVY8hxC4OXKfvpi_95zSY30DrniZ1RNHgJGqEe2_zhQoywd9dIEVmKa0FG5H_V7_Nq3-NFB1lZ1r4NQN9wrp0F0GjdT7FL9fH1wpOtvgQ8HtoJ-juKM3ytugI4LczWhY8dgtPvnEFgEsNJg-YRpQzuCQzzDwJC45h3t6p_xdmF3YJEJyuzVLrLL4N78y5ufkboxTu7oyWCpuuH9Zd_FI3SBO-_KxyME_A5uALJO8OS3fXEE8RAfazzfeClDIShPvbrVZTC6el-cKql6gAFO9t0HMeO8MyP7vOmOZV-j8H-7UQjroXwyIqudKa-PkeR9thFc125g4ewrv2gwjiKqyQIicGNxPAvHlfmPp0zzXBuqyPA77fQRpy2OOHFK7fXThlV_QvDlftYglqhQKVy-e2M1DztJbHTfdhTXpQXrsNLeyS2q2NKbiY11amxdFbgeve-BiBVztJFD-Fi-v6atwlvAGPJN-tEkcA5lIi6sC3Erwz0KNt8WvNIOrx2VMQ8rURlSx9AZ3U_wsOA9PNcEI1CYNtjX4SUpIXk987F0TH3y7XsHnO0ZnE0WigfzUzFJe_j-wkSA0n5sNSbZbCCDXfqC5klBb17ILcRMp_B5jR7hcjm0SLa5ooWOtext11b9ky88YFmnqNklFEEq0UfgolCspqNrecwAvXLw6pIILYs38hER2Y5XaR7iNl6p0tsxZwLsn-sBIS38FJK7nT32H1TaEemsLd-LDAqX4Gwc3t0oWsqaUcLkwjEM39hIQ-YCuxrL5zaOxhMn9ONdNREOyMW_YdW_YKyOTzIPPZ5YSH_rlko4zWDCyC1d2PSIpB7MSJODSLUCXf22HLkjqLiHw6GKPJGazUi2_IjekXsQKeVmIB20CM4cZnmi58XM-gj2OK7uGAScMRVZYM5Sljjswu8S-N8PMXaSICAymhJGGpLXopYzub6120V1K4XynXluTDpAHS7V7EiPVEzKHfP7cSeIK41LTJmTN_3D1xvCtgfBC-CKOT9CAVPcVg1-oVuLeDPlfNxzD3bFTUpYuxGFvxDcpyRF6iN-mODqHOcMWpGuz2tMkEz4ONo-opPZDwHiwYvqF0L1wfWIZnwx9M7EDFgp6cJ5gkUeuC7MtAzcn9YCtYXDjz0X7fuJ9D_5dOl3ZsnFqv4RKC_lBIhFDGNZtNMAiTtBsuvpYfLdMFZL-lp1OA6zBWvPOINIUXCNt3rRizsT9dTDvwLxfGyLuJtimL9ZS0ZgOMd8Me1XctMIL7VJ2jurDyMWvWW6KGDUucylVDF5BJTx0yiwVwJ0mFkAaFV4_JBKbpqgoDi4XHNmnN7FA5XqmxcQLdvwW91_m2btCKiZ2yjXTRvkjnkgEsbBb_FJxLGG9iefj_kQUX-pYr5Msc5P9UttPyfZd6H3nZ5-RNHzNVn6RZobTcoDxg6sFAA7YbNjZw2twVKvXOXRdeFak7vQv7ZIUguGdsa-WHUJwdrPVeD60DrqPEq5FVo2yusBJT19NIjL4mMOUL2gKyRu5Kf6lMZYlWZUkrCscaRE55SlpXMYFpPNMSyOTHukpuaWUPQdC-0bUfvaF0lrvXkpyilzL4lFha_lXfxDP7H-8IR6XGsN2ULfQDsyhPrBIq7EhBh3EG68Stbl2l9rHiQfYWEFCy4A2cK8d0u0O7Yyb-qpA4FPbC84UV_zpNV4LGYB85wCyQNXIS00Xxs0a2PUA9kf3h7sq_H7YBbkBpA9qchDNyf5gco3f01VAClaG2h5T9fF4pcpqHQ4eunqIrhuPkM6rMADpk0bApiiMAxW3WmIUdomKoWRIPh-fqOoWPJ5D6vhS1IxH9kj897nPeKXjRzZ0L9jO9ka2gUSsU9EPtwydt_2xqwNkR0rD-Gv3y1M0-Ihaq8h461lii-6XESXLTs09drNLBAjUN1PR6p6GMgKytqAUGdt03wze27QcvYueUzI7WYn8-JbPWjgjMXydqoeeLhVPWDONJDYz7FcyBi4yzkLLnkfo9IA2JFHL_Q-1S8yRwyZipMQBfVwhSVU64NZgrzVODOBpF1tFpTAX4ZwJzx7GvTiUDxAmZhSZGl--q0oH8gD_BZa2FuVhJZzPPi4JYbUqjQApkG3psylrFLxKn0XJs78KDcmrZCNww7KAu_shClf4mXuvKvkmIcWlC1U5E6750Vj4C2yBxMwruijPHrcfi_QHH7yVLUBwp8Ammv49S_fPE8F6ykRyZ4Z9iOCR6DIPYnMIyA8Y8ytoL_RWoXg_cX7tnPBYGyrYZx3GJPxsVTKL1T-9sYBNnl_mDsFYQdgx1uuoDmvAG6ERX6h8rRN22mxbKxbunolPARV4YX0kIMxkruzelLAYf2dG1t-PY11b-5W1WYUp3EJ1n6oTcgfj3Tmz4-t8_JjS48lnAmD1OnNrPdQFu6Lkf3iw2bfXkulse7PRjkKGh6nhVAp0umtwqu-78zGuK5z11DFbNcgLp_-O2SItPGaXl8m9NCs1BeKF3MlReI4C7tpHxphUbKAskFOX2zRFZ6ALLoL_HW6sJiPUe-vi8h_litkWSM03YqIb6wgsnLl07IUJpwDcsiL8_dS3JV8vf__IoH4YkQ25JJqNQjtIupnMGW96HVyFq9tjpQfYp3oXSvnd6as5aHhlA_1t7IC3l68USxUMxoQEbrRL8Qy9Og0HNi4DxEi17XBPSWTDL-KP2TxLlm3YAsE56N1xhlHNV7r2UpFVvi8My55iMG6jdgZZ7ApX62j0qAWY9R3P4Et4WWlr8Df2iZECK9Svc9sNEzHUhOly-dZ_fmJZAAR65hoD2tQnqy-dUNWziWbGhZ7A8xYDxVmsspaEdDLJgftchDt9zPikYh4lO2dwzdyNXrv6xq0XaJWcdES2gD-Jb5RDOWaSkeGm7DANZtHOE1EyaUoHf8r53ZsugfMvq2cSvo7_JJvT83cnLCbL_pLYNQjt9rHaXxSI9a1ZMjUdhGIh80WnJHYM0L0qyuCI6SJZa-WOYVroVQMc8PLxZ6BRAkCFs3gu8UZtVp1E4TJH1u0EFor5TXAS-LrIkQscYdSBOm1JD5pmIZ1F5LJZEIaozdsEqDT1TaFKitP74asqPvJSDDMwoVubyvN8MAod7_jxUybBRAg2BMlpMqa5FbK3OrM0hGYbSXgBWNbNB7whuBlX4W7Dks96Pe5JSLpY0aaRtuLbc5bivnpoJYBctwJSSdXRAgIZBCGQbSSfQ0E2rpN_fzyfhogQXsEReh__w8J9dD7ukm3kIKgN9ZQUpareHp_R1c2ZEiI4m9tztqp60P5Jx9_q3X0H9bKSMmNjbtD5yKRvquKCq739YHpFoPkbmvpWrf4Lq6DUt-mMjewu3ZcW0EGiTnovHAk_OWR__lwNJBpXlDDNQKcaFKNIFWFW8mtA_6biqCb51pUyL7kFkOqBjEOBfIuRE_mUqI-LGvJppg92RlicSY1M5SG7BG-0D6XZaKjYQPKgTI7jLQ-EkDeAlPlhV2acRx3v5T4FMoxqi82mnFRTNanLKLbnkMwhIABU766sbwkDk4_KHdDqu7YgWgq_eIHhqIBk1i37HCNvKrkEq7Y6tODRS5Cm1p2fJwnrE3fpWjVZplycUuPpz70EsEnvrp-qeOoMq_gTEpO4_-j1scQUyzu_jHlRrfjRXkdRs21rAMUwV7dQIUqiwfGktEhbMf7cL-EqdWMJh1ZsyT2g0K1TtcMHpNersT4iaL4BuberQq2ez7Bkv5IttiQUDYsxMCGrDv80yP8MDeaAx9Q4MR-9uvDmUdBfMtHMrtjdip5D35TmnltAXa11OWX0m5LSiRn_fAQp_fNywPXyiR2RMxZQLWZKcRFw0KzgleAOjjP_drU0XLdbfQIsr3Lr_nw8zd8mM12xBctLqDGmzPhkcD0EZfj3U4ih2NBf6z057jNINQ9lqRscNNCrw6tPPk3RSVQYEBrhAFedHJc7sghGBHAyWEvckybOEq1b4UfApxx_yDtGph25fpnjdQMFKeBWTFPDy8HWhM_YVnC_ruWSICcve_MmB0Psyz1Iy_jei5Fkdz4L3Bp6q6XonZ7wuZDLqgqEEyCyB6KF8LD1Bdu-uzeyGx65qPyjMVtpL_ZACko4Ed7KUQhnBmG53NFzadBppq5l8r9R1mi8rDgF1-hbcP8uBRyRsad-Lock-_JI6zG5NLXP6ZL0Z7Ht4JYGO1S6yvNQUiHqONvgwSEjMaifOsBzt6fxyo3TFxKsqrc8dC4Uo1_LVDBZY7QDIm_pFQmftahbaTUdZnFsGwHlQUVpU4MK7XlCfp9cO7iE1a05JEzDdEJeR264GCSFysHM-ed3haT1xshC5yLhVhfO3Kb64DoExkrGCko8RT8KQacoKM-kHT7KPBVFkc-zu99DStY0o5GUuoDImQZiaoqxkatApc1R8suUMOM7veMAdgVjOJWmWKkgy_Qhj06smxLTV4-DUEGp7NAU6qgnvhBjbwIO2p7EZZdCa08ABXuVQN3_DhQPtubwaRxcNd2ep6rr0jASOOjyLrPd_hx4AqZUGfhGgPeaG4fyQlyQ3_rg3fzFQDnfE6Jy91RfEbyUXIqGuh1Fu6m63v8GiWiXTNTaTQDNeW7D_08HR7XXP-JjeERLs2qLrDQ3dmbZIyaiJfQHwzKPMJ1Ip8nY00liNT0ema_EgDAgSPqlXUmZaKcs-bKL-1kzaW9jK397ql75dneli13ACuvFFKUf8CWqK8UUDE_G2wKPhvy2BAtNjk7hPUu3O_Q989Eu61axJbMZSAkxXUSnMQFV5gZy0yPwSDbIUBv_4HDjTAfuqd_B9lrCwFxP-TjTnvBUkk37iFO-SmOnD1bD2sb9b1p9MT_GmiX5t-n6T_TzWynMYmmvzW-xDbVbqqZgwSEadK9iQgn9r23S6AXlrVXu4JktWMI-sBNiQXDZrZf0qZ1kpkTJTH9-9gKol9ZC-UJq-PET9mhGeY0mAxZfPEG42VA6TXjGEmvfb2lazNV0BjPNivhzfkeF1TZyp-AG9HZTTeN-UvMRHZ0cUBczg1MTmHdK7QvFfPjgnxEPh34fZ1QRF1RG2TWYfJlT74Avl0P4GrLy9MkzItOc2CbNQj2WwUqmsuwIJdJ7nOFESlAE8bfDQ96XZJDRqVxdnmTWnGm1jSDUfYVkyvSWTJAmraAYmnbutNXWNGUMZ6Cb2cqu0o62wYBUbll6Zy96hCazEkxcUI1-z-wN9LIb9_7nZQkMmLClaJXxLJHn1fz6mZwtYdUPjS8p7zfKdH_Y7Q5lD7vYkFDSS3yzeHeIYqBsb9I5Jk2lK6ss53CvERZrVIvquTvkIzCJ1C-OspWPa6PiJjH_vdlUM05jvjiJDow8TUBzMirbZeC91HjCoimqBeEBDye5jJu4Eocbwtr_suPNqLxZhPRH_xzMTh2SFy3_DCduWXThBBZaFTKGZooObt3LCjIab37skZSr6hIihhuEBrB8srsDGnzrVIR1gQkzZ8VztGzpNLVqnIWVgiBMToG8-zGU5EZVgdczmPQWvabLtJOhw-tAvRrHuIHT4knUVlSOj0kcnzme-P_vcUNCzDVORjFrGDZEFB2b25DLrO97TKStazPUdCCyChQRCp2OqKC8abvolkfjfgOdQIRFB7aL2zxzX7aaf0xhpqMcqm4zeMWTyiF-qKbiZNSucyq3vohj9KVai_XOtjhEOe6H4tRKA7mrhPdlHCgDY3UXjwuUULV3JEItIE2lyrJpwkesT4r2FNTxsWETvMJ0AapvCBc5J0L15VUqsTAQPl3vkO3fL_NxlRGC6sSWPz_yqfqWNIdRPxgNRo1_Zv1Z_JsC4X_f9hB2YiyJbIatZhqDwpQFPJ2MbSa1LC8VMFLp8e21zOEx8cTr3bU5o8R-sSI3nTU5sal6woN3hJRMeR6xq0KBHCZQra3t4EZ5lrXGBpHYpsLUfMB1lP8rk-inljJYQUtm28J7t3AIvNXJ7aexBBCjb2mxEi7NgDGffQSHCBNcHyfRI9sZoNaavNwG4EMwHqHhfWEMFYi8-171dP6nwqAGyv7KAbCx79YxvW36WMx5hadX7e1IOXBrFbE_KcxIuxSk4SJXsQlt2fQy6zXNuGTNgdxhsJ5In5XkeJIp6aB-jg-AhraHetSokpAa4l_WAlsygpvkwvu27NBBmUi-_M-o1yZgaYzcUmoVMfY9esoPHSkPzonr46fOYdqg6YE-wd--_fBMpbAw4by5q1vKd5M1EY3vOnCZWtG2AUv8yz-esDbvClKfe6I-oJHfSHANtAya1hJF5X9TPdPxxIE3GgMskyR0ch2czDU2GciA_ReW36PzQOB3YZ2a93srVrdcplxn-L_NTsMpYL8dfwD5TLFgZ8BJKd9hTlDu8V42i_RTCAoL9Uo2OsJuVr7fg2gWbjRqZRGCa8r_Vn6Ekp9tHu-nbpzzUZOrzcYsIX-h5LQUi85y7dl-DiK-T6M-MbnDTScsm0tM_vwoaeOzjX7--h2XTkuJF1S6XnBb4wIRhfY_YRj314PrqxWUzBnqajsvOrYTS_ynUeZEzM_VgN9mYGv_lVKDjsMLgXrO1mLFeTY7Ku4vdSdmaWmWRJ-adlUeoVBCBi5tmkltk_vsg-WLi6NnI89WnVt5e2aM8on7n9_PgYT1y_KrpDgL25lqB9IHch_oSyeHFflrted_1tnb4Hjt2PPOVAlcathK2K5JKHw_o-HAdQs6L5LsKqO-ffwj4PnJc8gcgTZzo8bZNJ99UVG0t-fQH_gwCFLBqSboLdnAlgwjP7Z8mJA-RsJu2wbzcpvUwA2AT1BPfbu7qDGc7VkoWRfkrNq0zgAJjpQzQAosPV-zP4tGgvzUkpK3tAQKYlbkk2mbz1-a1BZHZldFu5Tjk1JNM9adM9N5y55IYMYJulRgV3gYmecSJUSrWfHFnki0_LHcQVlu6sW-r7FdVH714GkEBVSdlx-AgAnkVNuwKQ2lKo0gowEwrshZyFS4sBHW8FfuuqQD1CdNaKIfYIAySVHEqdst-CJfbsZpHaA3Q8I3TqRCvR0LVLEp_2wfJ23GCXnn0mfUB2vih5diPtpw2higiITujBZuJmdZsOvewvQKJs1gq1PrQCrR688YzwrReGI_WHYK9YrmqfBjshFTFWcz5Zk6YYsWvY3elIvVWarwMBDp3QuadjSdScDH7Eg2B5GtvrL8hUlQK5l44vC9-9bpYmctsSdYdKao8nPCfCIa7ljGxHRQy4Ao21axA4Qq1AnWE5EzMdNK5JftAGPFRpRhZeTvB6VQhi-LJNV7xr-CJbNnCe_DMIFozDAWSkUjfCLUWio1hWgyGOVzOD-utI8c72tNBQ95ZSlBzhJkHeu-gd9Xn6mAdLCAx8T3kPOq27dvdjWywKKtiKFaYIoGo0aHMz3L9VKCuiaQlKzj1aEr7_1-aBMAAfS9U4iq6v9kbFsETSNmO2uS96J10c8jFhN7oVYfNKtI1LNtz9maUi52cnRqP68L4OMw7au5U4VSpg4MkPt_VCpPXknwBPSpioMUSwjGjdOyVH0PkeTk33coj5A_3DD4oZAvhY6T8iRxgs5p5ktsWBJLOHUg2BrEnN0j2IjSXMfq2jHw1B0svvDogTFSdBRTn30pfKkoKV9EzWYTmo9BZZR5cal9c_k8wet1slnnWOmzxLoM5IngROyGYZbKw_MibSJu1HsQIgbg65tLY5SkM6ZfryRzg3ZAWcDQ_P7lua4dLOUV4dFQuLYt-o25ZcLo6YaaL-OC4QskZFhEi8D1qdDbO5MKEqGGIEJKNLKm2y2P78FFiJZ2l0Pe2GoQJbVQ0FpoLoSqlvn2CZKNNtQQJZHKheOufL3QeUxdMLFT8ILavcbWvLS-ZTyU3k6T32SzOyYjH9d8uvhmwXbDhb-L44IMZgSDsDoO0R5-Q0LKBsZt8MWnnJ43VdtAkRzBGVvdt_Kx37foBZfEEbFT2yKk3_5vtywGEj8wXqwJfUqsWaviScca0krnljSRYNSygxwO-OzzOu1buWPRMd6BG1AhYqAuM9gCEpPTptTQ9LOVm3CYbr916PGdwp_Q40xdbweM1m6ZpnUwv2vsICZOpCcYJD_k2MkLhA96kxAlRbG3aod7d8Qdy279658PTqyV1KlI-1HNtEPlVFouTRICfSJ6nhGbVTo2nEUg4I_q9RSIKuCchc80V9bLBf4znK3AQVbGaAGThOt-QSFnlCWbs5aUBJ5VG-BHos78ho531IQFbcYNhPelY23dvV6XE8aETvOIF23Es4sDMvUcbGs5xqNAhi-ks1bPukyjEQdlfvcJON0nyVlwBGpFXiSvFYDhR7EE-_4NBMIxldKHqzi2OgfGh9YyiBz_UiT2yJSJIEUJvCT-L1bCwPGsSRWdRkX2a6PRarmoImEDIF_dZ64UHAACC1QgI4iXLdrjU6g97scMuqtymqoUpc7nb0LgJyQ6fNwiHrvAG_kOG-UuXFMPYkfN0q8SLxqQ77Td8VfJFrjZ2xw6OMLxn5GN5lxLiXPImr8eCPmz2IN7Qwlh0zshUsXezrI72doyXIvXyUlk6_u90pYh_yKEoSP8IfSrBBooGxof0zG7i3wwoUF2PL_2kGgZNkhN0P3yz7IXhImZqytyv6aMgTbuHaHkqUNSAMU_-CyiZSR_sKAL8R_QnUJW93roHj3MxTsi0L3XVjTJVyjatyz-x7kci15SbVqZe1AJpCJeThiRQcQdSkKcLDHKTW6bmTeMJ2oKSJLfnVq972TY6VLusCXtQFB4miWvzlt5zViJQGYHsVOIx_Jwun6ZgIR19PCzDZkzEo2edI6xteLyUd-XDk0LXDxlloDwg7kIz4kenLrD04my87ibFvrt9X1ymVAWq0HBHgqoq9q0qYUjEFLbhV2j85yLpppRpYWWOz-CeuXP_1ID-xhGAZbfygNXqFEAzeZ33HgEPPyMSaPpnMyXoqhSxpxVYNWzWgNGDZkbIjoKHsZ0IiwpGERPl1xoMJomDHhfd8MeTn5mLJogbWRuOkT5fUn1dD21ZoVC8MSxB3q72LmDO8LwyGzj8c3kwMWvPWAwLOcR5ql-05LLLSCR1-65ObBkpn3kxuIU72NT7RlTyxDWmZCNBvRRLy9ccD5dmNXywIZRqgUMisWeU4ScSe6t2NANCnTmO5EQbX5QtKO-OWzYrCrxBk9WjHB89H-4gEaovB_c76zNFh9NDNIQvecdXE__JOQG4JfyOZZGg_607FPFuWxM7WOEAjKQO6qqvktaOWozMNjgUDN4ZPO6iQUujby696VBgGQb5vPuD8k2onfrBeONHEB_cb-jNA_ezl3p8rlyIRdMsa1tjNu88rk6ptYRFdT7lCDIcVPkXxGOYGz_hNKr2t5Dfq7IeMqOsj6OsdlvcJ-U497rWVT2LVgMNQgUtNe0kPUEo4rdIWEF26P2EOTy9gshjF64La_JEkMJSqHvILU32ROLpao-eOJILZH_suVI54GKOpyaFv56z5pfheT5yG83M9aa262gmYdrvaT2oX-C7aTn9PTan6kn5q0QFp-qH24i0pgUZr4GCXX-QQZSzGB-ULL40TWvxL_wUYDtdIzs4iFWxBplHXCDksXhfm5sSZuJtXo9UuLqmAY1YdyClk82N5EKd8R0amM7toIXdPNCG4twQBJvsSlOa6_sbzLBxpRoZRWMwXURcmRcAG6rK-hQGoyBl8EJFytY8nduIwMxn06273dMW1eyjYKgOGHfM1qzAVGv3wbzUTqdoaJKJzoGvh5rIhI9z360nNt3W13yTt5lx3AJyaDPUixBEWHkbfXq5AEPiInfcVT5tFPM-x6-KBGjraukgdVBlN5U9W5hTBvHDJkqIAASvne58aydtHq4cdU54cq2_Lhqwk9P00qYkarAjs8f8NkkMxuvhr6XlrciXMzqesngAsLnSXsjn6fmEMBPccIaUFMzvSdwXDenDlbkObSEcpEk3pExUqBRViExxtcg5j021-FJ-RCJMe1ffbnN5KmMUm6m7g8rEluuxkoOIY_HBaQBZG01tEQQ-Surnc0RwW3MlRIpwwhYMwZpLzrrzJ741UFSb1ABbwSPsa2iwqGluLW2lx4DNHZKyB0r94sUTL0h-Enjl3lCeNfLiOB5kxpU7pLo9IYU3fWC0aJNuMMqmNsPKrvfONUPRmIseDmzY75k-KUWfpsP3hEeJpk1-PBOLydXGGCDEEO8QYJQVpfNBRF6rcFi9ENCT9mgeE67sSUa2OV2gmh85LX5AjvBG7Sk1W-tm6r2rJPTTltwaf5dvnuBBrR3-JPbxjwNJUC9jdcK_S6Ahe4gZOg3ZrMHWOrGet1eHRHNWHGtOBgzqHZfQA-m0daUle_qW6VefIXlKXpSVzvE3KIjOoURtIASUtrdrzgi0S6zlP7oIYYda8cS8EY_f7dUIsflTGMpzPztmsnHrTButCVXzjvjIpMbhOEgTj6iq1SpvfJZk2RU50xVB0_vpFgM29TS3WaL50X_haLtVywWLpKKhOvDVGL9Zle04wLYDrWhiTHfhxKAlhDJjk8Nyya05VFoW0g5U-o_Z6JR-2rp2xsZBTiBWHxX1R_MGNjo6yBNDGBzMkUOkXPnzRjlfUk6zXviqUShPplM_f32KQTUhuJieHIBspys6YlxcJXnE4Br4i5g0Kz83JDWcKazecxQXe4e4TM7KgOHFvhZieGApNmDN7EiqfUZSPvoxfmrMnh8NGKNVseKuk0z_gAuK7QXrAHNI0cYjTKyIXLviPg9_1zRHjYBCqB08lDt6G0SmnuxPGhlFvsORc5kTt-JmArH_2LkhZ7J3cMZgkApEZsx4fQKB1Z7pwMIDQUdVStYLq_kyrZgQgDaBJqF5LPz_7MGsoDyGxf990WapcIyTZ7f8mMHPzfoHEXgpHjOHqIFPAaw1tgQJD9zngc43pB9sJLDXn2J3O__r81edFy17C2yg_JM3FYzQ2UCILIIslS2L3MvADh3qGB \ No newline at end of file diff --git a/backups/backup_v2docker_20250618_195853_encrypted.sql.gz.enc b/backups/backup_v2docker_20250618_195853_encrypted.sql.gz.enc deleted file mode 100644 index a00a8b7..0000000 --- a/backups/backup_v2docker_20250618_195853_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoUv5dyan_fKAgQfgHq6N6I-wQ-X95w_3zsSvxmDCoeN7NCsOTTxr4odUh09nhknDFegBYF6u0ICHWEKQ6gO-WjD9ganDu848xGkuGkEmeV1Bk0ES_GQyzVq493Uq8oX66SpowhTMzPnexxIA3U7CAd3l94sptA9GacD0zpqD3OWOXy4Qj17HpO0IcB-COJFaK8pdHc8EAXVOi4aLbzbTF4u2LjPXoigtZ2-vtiflQNM3Gt-teYW6S7X10XD6m91jSpdTGWaD36d2oYJfp2ducdNtmFYvQCknYoneNyJQqDuMyEYRZDxaY1Tctcpupo-AaMXZSDy5debCa42u3NNHzuxZMsJR4xM1fb0BdDBNgLiUawqHr3KEQh3Z1ZRizAWu2qE29XU1VXSsHyZxPBGl5JT0VOB2lehnVTyPJIzpCcrcURuMrx-JDbbocieeEZgxQvDuXkmt2Bq6-YxbGkn19vACalzUH5slEnRZuj9R-5x1SeY1_UzQ6MYvDVX2RqWDH5Yih1NymXre0G8nSviDI5lDHF2E9FUE-H4g2uhWS8M9dbkx605Y-GPBhn96ikyN9YV-UTQf9ZS2H5_c_qj6SA4jO9kbwiS2sA1hsg3wlzZvYghJT0TCvGn_xN28jXVUnklUqRQl5rJDHVtbh28QLJswHkEkM4LzYnmYXXm_qvktvESkORalxCqAaZWnYaZxA5Qd5czu3jR-f9kv2gXkvr7Htw-i_zSXbe53tsOxiJGL4wyrxSf9KyJJunTKflrjItdjxuzEpJAXeRfNTfIN91LmJtTGpOGsIIpX4thsn9DpBox6JCiCfPdO6SKmVr461dia-cUbQCV9FNMeTIDwm-9NTqIj2sEtSnf75kM-_BumWEDjssRrLEBk4LDBICBfJ1rM5BdBUEHGFpNODdwDHZmHg1yADPW8otrn_1XRpop_OzKRMakkh3IjRowEPremRnNevH0VtEfXAZXWENmQLBWNX4PYqvxeNvYGdPI1MkAxcYvX-ejoOr7FOVc3QOKS2vtLjhSPFaUofSvJ-43nW5Xt6DNIwovZT8xD_49kR3ktRPROr6mLEcNZBiwujwYob0FNPdtvq7AUUAmByBZ6rdFhIC4cSVZMP5pF4ReyOf6lN9d9exlX-UYcVFuDnAiMg2fm6nIl8ZgKrBc8fozBYDgfUN-4uGmJ0VQsM7nFyToXNQB3EHefegJoyOiYv2PtadaWcUyJwryXIgJPwi1eZdA7VE0u8SyUt1zfYQun5VfbSL-TforPy3kfZGA7G5bTTWrdtwiK5isBSSTCQ56gNCyUufSyU5YTZkOP9n2KpjJ1Xk67A2gLvhwTN1O7nI2_whqZmUzYNpa-Z__SjGJxxzTQBftNbjR4klR7lFgO1JB5e_FCA5BeRq5vENM6qWXDg0OKzc-A-D_v5RiGzWueSfvq6Jn2KxPLL9QEwtmapO3fy9acjO7aY37wtB2DFqhCmzCDkQJYsXZIJ_j_5kJX6iHIe9pVMh_-b9OTVHf7VlMFr6FLlY3uyG9DyVIy_lnSPfyrW0Wb4_M7aWUBM9uFRs9NmIbQmADNmXiww0ZHaxHXtFgms4ri3Ak6-4UKtjn8Q7BQR2rcO61NnsbczksiUeTQuZsHhsfSILxs5SErwmkB1NnFULFrjjU4m-u8aOsRyN8VYqHWQa1sdL-Isqd-J95ezGY09p0kB7c_3AjDNh_Z7Zf-H9ofuG4d2qRM1gDYHjv5aE20C-Pd4k-HVhbG0OgCg_nTG6EdE2LJQjbbDEDgijyW_rDtp91gD1cAfBsGLGSBaF6UYdV6cO9Cvc-4xr0vIymCYMx0a9Jz0rB291XXeJxMbf3GY7lu-Z7cq9D39rSnDVY1LgJw2vKmjuMNOY5ll9nRDUjlxRVeezXXWB-Lm_9SAJg4fHe6eGGqM3SOqzB7TamQPFlUaHGKL71Ed9RtoAZkXvGewSDN4CgVvsjpSiE52EnKtkZTAAkb8LWgjsV0DlmUXazZpj8HBpkZOOODYJlOR3xr-j2182kVNydjMqL9j9JR_KhDWOuJ0waNScXKQArxfAmsQQNLpMVSgIh4dfXSsFa4aZtEMCf3jsoLu_dlH-oo3JlJhSIACLEKFmiEU_Csy0WtGKqYnCuA6YmGmghOHC5jFr91KDmJNIob7f0QFzuuFTOzwituih5s-XLMPQwYiosfdKdWgto3h3wV28YXUTItFSsrQ0hELl2BCX5sdnLqyjlU1yjHIxyKb0OZCbauaowC7wg3jKNVVAigrmSpQupKAOJqpWulu1T34R2xfzNXwXvroxytGqyrczcPnG2QK_gbNBM0_KVXFQVGnPVEAjtXpwqludzyhYb3DbEwWxkOpZuLP4DWMZ7ABbBCZF-DwpKGGxS4F_nToG4XgxBOiThJg2j_izhScAJkZz2c2xNDLDEVQD1jdomxnZFwlS3yrae_ETsso9hEK7mBqVXgaxQu0Umon-lnejUMTWdsepDEQnN5flX68qA3a7IxZWnTE3IR2L9j12a5QGeJVmJ8AmrcXHWv7xEY3JLpNndUvggRB2IR2HhorX_si7MGV5vJj5Hhdlfi_uW43EsaSB0p92bsXwst84jApY4epsCuGB4rOK1BrhgOo4u0sfxSATHugnSXcbdwUnahDqVqPNKInfQ3Kj3kQ9QIDH3Ws4pddMtrOimZP2sW18Js3NL7xyDB4kbQVTsHcLgD92m9FwS5InL67UIce8yLXMXptKrjyJ78VkM-vZqUUtCSDB2U3XF3Xag-9fgW3qI659AOP4cg6rZVoiFrDcz1BmHfRdQQGCgcG4jizY6L-Hgs8OwfhqmAm4YvvHUtADNTXZxmMdeA6Zw_n_7kf4_1hQDNTcpm_RHf-JMNiqhWIx8nJbVIMeDbGt8BG7pB6i204WFsKabi4oCsqzs0HHwM4GxXJRL3UKKmyYr2Oxkcwndoluzr07DmoxPnE908EbDeN4JCG0p5q22EDncw1AdBlx3e62oF7VgFmt90DHexIbPle8jXOUIzlYhdMIjz4oiPDOPre1Q92bKxkkztWHLPVPt5ovom0sw6Okg2XbkASDe65nmvEe5ZfnvHHVYmOGKDiWePHiVOT2fO0P1eJGHqxfxJQ99o_GgRSeEC9UL6EjU-F9zRNruvd0AEly1uBsLyV0fz6AD-VXtQ7PMhHm-66v2ntXRNHaU8QecrwxGRrxTucHdKrLX9XPfc60XydiIMzR5RVFfmBpOOBrjqQ5kAqyjXQJ0yNl3Uce7D_8DUTlTo9zLnpXwjp3PTNNqMBjddY9BuUsa3G8VYbOfuaYz8yjPYozmZ8nVRy08-5HMnoLQNznCDS2asrbojiG9b6oGEoLfQJbIVZ4cMadVmxEYdZmxUVjdU71G7Mrm05_wTqwtCAb8M45BegEOr8xBSGQ0pQzHZd-J-e5zPhey7GexdTk74uIHacgTXg8XdoDq7U0h8OAvTJ1MNDyFhCcJgfpaZZqhCOpC9MgUuStgTKpQlFJtFvyKlMqZvrg8EEGmtM5tCN0WCRBym4NLw9hD0nHMlwRIfM9ZjSjKEDneGn0tZ6Yn4l_4ldOjLt9SGCfrcmsUrgFrjqufAaXVEch-Uze49v_-DmZXN4ZwLdg74C68A8XcvBpkhV5rX0C3PKOQLq7kTkfOYgk-0gSlerUlTrrFAO0ofy6NTZHan90U4xNcjz9B0-YBqxmnC4ZEeOk7gTv6ixMYObm7eBD-xjiaUvZYJMuRi-lzYKWWL5dQKBrmyHdJQzaI6XfKu_LJDsnSNFtr83cqFjaZcPWsEylpGsfafDXDGvpOj3HHTCE5g7xNmy9pLOVas9QtLinDwWG3hSVZhMEFybpjfIoSGlF2iwUJzeFJW2dR-S-Ghn6o4fv-WXmTs5J8KwxByuKvgJkF0BVTFK_WcHvM1_ut3Zl3MyFcDTGoI7edXcXWCp97gWuHtQA79jilcyT6zgxTev19OnCWCHZobkQ9h4lji8uccFHj_eUpumTlchoMpVQwTW9nh9WWf4c9y1pThrzHyfMbV70dQoJj9XP17qlj_9oFwoJ7pNujAj1WZRK5sqpr76LFs58DkjrSsRvkInIKeqKoySk6tBq9jBiZgON6GxY8WaxTLn4-XcYvQfdBXQOriMJfvO49SFDeVay0e1HiLDnyZODwyRtYmLbPBZdZg4MIs7GtqLaYk0cRdl5GKzhKzRRVmxfUx7bQ4WioTRdgXtdvIuezcphomWRz3irxA-ck2xWE414HL1OcDH2cOna3Qtlk_qyw2cNdbOaELbJKWgDaxQUkxDTVZYfCDN1N_mEk7Q9J1EzehYOlZ7AHZOq88slFHWkZ9TRNr_li9E1aep_iKCH4PQuOpvLEc1Zam5mNRthY7J6GOlhgtVOgUUWfcvev2smKhlu1dzEuDToQVWUdKRORZ9qS_9bo8JlX-DgaG_ZUMmeoFXF9ynB7VjMl2KcJWF76RkZe5nlBkTRXspN1OSNMeYM28mcSCmVszI2Lq6Y6Am-WtkpTc8JyvUdgM3qCyCgV3yL5XXG-zlDE0JtPg5w-vOi8wWgFL31AAJculRGJEjhplaW_mCuM66lMsoKGcMtKhO0Clwvb1MZlDGrFJee0obqsQm8qV1T3GhZIExiyE0YuSEzE0fRmBR7YHJ_QBvlonOKa0F4ZgK912-uYQhN153D_70BgEpz-K_M8VqCAyHy8ADJecCPzj6ssLV6agaX5oBZn7_1p0e3sLl_MBpyqCBCFQMYSMEjBTpitktufMBiB45ZfL-KlJ3_HRtgbbDV4xOD_EIRgk9gZIYUVV5N4LfaGAne_-Gt95dggahDNsx9VLEEQtRv4Sn6ZyGdoVRRynmEboHCjvgg8a52_-1tKTyf_bdUt2vnWzNIl3p5K5m3_15yotc403Khy2zEfWN9kUOP2SEtGf2i34ffw0QUtk8TeIj8dsM1C4na8YNL_C1xQlDOFCngqdlsP6MhSLJvEIJ38TcRTyyT8L_pSz13JdWK1D9nxbq-DxRqwAYaBney_jjBVRvl02vcizsVyzGHlKR06gPJnCqmYLW2ItWZjErQQL2MSO9SEuDfqdhhV0KWxZOOIEDnkcP9BISj1YIopP9IQdbVocFEVpEwTkD5BAVSQyUzYLsILU1kK8DNX1nAwD6kC4m3geDaq-TZXBcVVb1KEYCLw6ULFKWO1tPbEzhLUDL1ZjJDAfb-78RH7M1eDz1NOcJVwR8wtUMDafY3gHegj8AIHS2lU3VceXWASsMMPxdiyIt6LS4yztvCdh9Mj_iHI7HugCUsh-ra2JGfgvDLz6qEAwR0YcljqRtnrjljviQYMiw7aEQz_M-ji76C5zUbjVfWXqn_x2XvVWIlyk0NkL2qF9Xa2X7k8ssqE8mKo91Ln6vQ5Xmbw9HezfQiBjBhNnfFL1rrmOJuyqL_rpbc-Jfo4alcghgr1s1839QHP5S5NA_hyXiB5k17VSxiUBy7TwjFRaJjfWCEz6_n9njFcVpVFgFMvZ-K8PjJqSBhCUXwvuAQtDfSX3LpZ1HQOxqKrjDRj3CkpfKmEiSZRvFfvFSpjL-kIHheH6b-mLP1fH7UkjB3HgJYMforDbI3skzYJ3Lf4aJEx3YLv9ZQPTQVP9yw0OmWUmlWo6gNxVjynOq-M30yeIbCDxOTHOPF2-ZHhNHEpQgYIo7DPqQIGkiHpbt1iTny5MdDWRT-5DUxK12Y6dyU5-rGIAjje4FszVmfFSZCPjzVYknbehBbLKTuOEEjXN0LzxNI4vbrjdmCT9TkoPI0MhVKGk9Op87T02aoV5Y0Qta5iFQceGy_WIjt8Rsj-hv1wCDBLWT7sRlT8CT_hyCAtKMQiv8m9DawH_XbP6AWS5DHnCrX3pBwrYcOW_1o_hPDyHTUibAxKDDYDY1XMxzGOc5JzP_VJITsrpi8di8vuTg4B6z4u9_AdoBHIfuj5YD1r3nrwWQXstzDlMg0M__GrfKEvZhXFNV-2vXLkxYiWh-S730nY6NGJo1s-n43k4-VNJImRjETkJ6AjQitUcibsNy8uzORr0qct8aBB5VMmsQs9iLgDVKp4tbQDmJYqPnmjQfKTH21s0I-InI9EVxiw0hORVATWe92eJjXFZwvXmz9tQ8hlw1H4IZG9nCEiuXRTnyRU1piRgm3p1MrG4cyeIgzea-s5OxQfIRuiuOd0kTOR9iC6yg1Lhl0QgD3NnKsdh5WD6jfUpUHx9yRYLhPMi-o7BWXyKpFZ8Kx9o5szz9WlMwx6N_THpb7vc3HqQ4pqIWRRhK2w3SufZTJvQznepaLHKz7Tq9Rc2pTcZyLvpUl8H5TP219CRXv3dtPs1XecyCEe3fRBw9rn4eBhp28Q4y9W2PesO4AWxGEIq3tzPNglS-p0qs2yeEnnXA3kPxR85beaegCn2M6SZcksDH4QKmMNINqsGu2qq0bw4XNIb5Wh02S3_0dV_vzHrruDLrdY0aGvFn3jhzBtp4jOeeoqewoZiuF-9v2a2UspP7BvJt_5UjNLXrb6UsiI2pUI9NseEjMyx-baYwIT_fJhMRd7ghufxX99SmbnAqnu92Qk_0GUSfU22YgpbWT3c3WmJ-nfa63i5TuigRrx7lOG4wCrgYA_dep-gqdv0BAnO60-MlKhkl0b_ICSepOq_IRgITn0hE6Fg3oOq8KCye4R6D4UGpasWC0QUaLVVF0yxhW1SZkYc_Ma_bMBaMmeDoQU-VGfPP1COQp1Ptql-69FCApGFtn8sV2j-B2w6TY1QQ6Yln6rrnQa6QcUuaVW5JorsWhxxL0UVGFsdZc8o5HYyOl026rAJj2gyDeOrhdNfmaiLWJxWoWbM0sSTaiEivhxFJQwh55az6u2OtZPAKHqca6LCSSGx3Fa82XcQ83Ks0JupliJ5L17y0LxazCK1GEAZE9bV1S6i5d_PKaAGd4guwtUdCTo73uVwIjjIH8HODUSZRxb7WkKRWJW05-ilD-iNDsaZtRm5AFBQvo8zkt7wj-4ol6BSVunnLlxg2ITdCTcCWmmGLa5SC4qZpAvWHQiVeV5OCS5CSZzRFNDNDMw9My_THZWb7ZHmvJ5kWb_bWhzmdRchvVWORgg_ykZvs2X0EBZrc1yFbZA59EZIXG5kXCv6U2Bj4cS4lSnsh-q77cyfyeMutlTcIER74ZkZLvr70mFV9RZKnLdNS5yG5rq5WhCi1VBtuswFU1rzwvbyeMzL1x7SG2EX6SO-cStBsJadduhyvpCBdv8fkojwWQRbyfeG3Dfv3yjANAPqcm7lrw14FiLSbQ5GYaWOpNGiLaHvBuWi_CXWHApNT5eNTbHLXoEoHEJVNe2XBhFMR1m2hlvCeVITKgYICowb0sRJd-nwusIsu49qCVYEzYqAujysIhHrveNX0Q_AkBIU4ltGMSACCBmLz9k-CdbOYSCordxxBTWVyN4KGhLtEm8gxaUsdLHGhijF0jvsNWNllf6T_8acFKua4hGpL0YHMV3qKLttOvBdr1vniCnAdC_NwhLM35BcpFJllfQOsFIjmJs-z-XEhMn1vU0pm3HZ3FmOvj__Qdnv1bnEU_-emYTdFm5kV_Ag7M1ffhKosr3IfqNj80dHlE2gq4tk2_qhyzckk2p6eJtvh7o5YA_RC1PWStfn0ggtWQelViorJceuV1hAN0vJICXD66RaMBXyYzm3tBG8kEH2IuKb0Ne8kydbSzq5o6B8ygjvDeOWyEuYylatmNtiQKDjAMh2ZL3jLgBgm7tA6X8DLALuz7t6DHq594Zptpr9VQtfLrajy__5mketICK91-1aFykubn1tMlwMQkFY7VlIn8PJlPTtp5ehZhh_9dgZjWU1RTUDqvH2M8_XErvJaFmGaLwTykadl4rPpFYyUFEEHQBC_QXZvGDwH857I3aqRgsDl33fPhxEaefFebTgAYIGtHerzgmx2UmSzCQs44LNPSuyK8OieXB_j_AQB5s9xti2sF5EeYiFhxXTRhFALGY4MgJhSKndkJvgXiYSM632Y36HchQxZrE_hmiewOlS5cZeJBlzIlh2WOa4iFthmeUa4DcZnkGLAqZAyEiJLzl9dQVSQ3Gwfpg1Mr7f6AfkJGNVd201tCGiMRLAoMlCg6N1cAH-L-AnMGfBTiCXtqZifa817HDw6gpjF9ozTga6Yaq5wx8fbRevNXQZgzPHIgv1rTYD1LxB7o7MZpTobGr71Zi1zmHVt_kyZsIqr1U8PqXoFgV2ZuajdCTXYnU6R5c2adE9hfLJIhX8UpPVWI_3jeZUiGInSptntWcaYFpB_OWhrdpR3bVFIzEIovL2CGVjuPHXzIfVPJEHCGPkDPCOUaL_5FWHUgdeo76KShuDGVQUwea_jFYrVk6OrkBa5TWfqVbFvyxxecE92ou2phKV9zwyRoNXTgdUs90rgycpRIFGJEUvANeArr0PxVfEAzU2m2IsEed5gcMJSLHvE3lgdcsJctyIrWg3lsOodOb59srLaYjD9Uct0w4llPE-vEwmbLy1kiQRk9w9Ou4vFfqUY2dJ9jLhAblZXTXVSfIXaMkNsaBKzfptpi0mBG6sfadUf1bZaIotEFnsS4flZ3J5Dq5UJ5vfn6-vfrnabzQppVKTBhk43_LMcc2bamC422jGg27AfwXC7C0kUgwGp5Z8JqkWJPu7jMohIGUIoKjtU-wF1NVa6xkeWMUIHJNfDGsZ7K8ZYCuZY21cf0Rv7XelVsVV71izxNByQFNUTGhReB9wn31up2SiiOxRDzMNEm5TwNIQQJf6KdGywOGshwIz6qEeiLVY2HRyoNOk7wQi4XzthGVVfkNRQvSC6Le5mVsa7xfXld3R9d8p0LeTRrLQtIURhB1ci6Uc5qtlK7Zh8DhrwIp5w4PBgieZBSj_ZFcVHX1VmPGb4LhSutDuOJSYy8KEeMQRcwkxgyqaaqxzaDlm-1dDDqMha-Z6eiFd-Xa-MTGfQSdCI-jWcTASvF-yb_WxGyq6j7aWnHHcHlz2__D1HQwStfDiXtuR1Wq9T2o9SSisY1pbJvyFZPd3yTXTi0JsRkh4lg-VmAxgNedNeOC-OTxV-5QbPDYyjDqiWH6pM4izjhffoQBbjeszP-FbbPkdTfht7Q76-vRav4VlEiaRcptiuH0ffRZ9K_j9fDpGYXxjB_o9YKmT8-krWAZ_d8gePUSSWJPWhyYhcsZyplegxoAGEH1StYLInMXnQpGymzLqCbqjdW2XZjXjXGtpedMyLdMQvSoNxHNRTRtBgYfC3WD0IdM7ZuEXoqp8MclcTf9THMawlJuLysr6JQtJjcmpU7m0W3NdZ-u_vMfXQoqCd9ytFA2b95AvzznkUPbsjAl5J8Px64BI6En_xAw6D5hSi2TWl2ArYwupxHNI4_pARK-ORFcbyX9nvTsjwNtY_FNzTo9mI8567mRXrFl_Ge8aTi-hZXqvU-rdZpRbQy9r95QM1KSK5j66pxZidO6Ba1Ntf--Hu_ExO9k2PRlVqKqh8F74iKDvyVCyp1CAf28PtEYwtHLc31qHXqY-Ted_6W3z8T3Te97pFzhaHCVQylQ0kv7cHBvcOdAyKmjgIDd3iYLaQcAWDjHHOFQ6aKmLrA_GxBv4z6HMQekVhjmmazANut0ff0bg2baH3cSW7p1S5EfCWcK0Kg42autIHJ5ynybwWEZj6ck6h4919wARLVvTlejDGfyyL0kNR3XTtgZ0G-ch4unzk8isj9-Io8vFOHV8SVRlN1_AFsv3dghT7ZVw0_Qf2nWYYXQM2fX75Hxlz8Dt_xzOcC1IxHvAMikbnagM5_9egtYpwWGDNitBG4yb_L7HG1vevvFOJfnVNTRMhTifoNiJbZKYMIp_UFWKoZbQuJmuDKIYRmnYsI-jczWyFFiMpwOS7fikAZaW7ndbYTZwroFUwEQ3LlznEq2VZlzFd9giodpNUjhUtaecivDbkwvcEnlkOt0sNNNWS_8Q7l3zK7PBSDvD5Zoe9yRAxZyV55-lyHgiF6e0XHw_BXiYpFeK9rkyegjOe4KsgvK-GUK0N5-85pxwf1F1IxhUE3IpPZyOc6jpwsafTx76pZrFq2H0BkrIGov-_wXbxVx3v3MsuNqjq8dCx7If7RJ0O2ygXTL45q5AJpllxzgesFL0P3ari8yC5O5Afx240twBz8swyf2fqu_fNXfyxnBij4zku5sq72K9Ir6pey8sEFEkDyPFSSt5q-kgbvYnEKqLrXPSYeogAeK5rYmFn5XeAeIlF9XlJYtPZGLGVlEk3nF1004BDHUD8PFawUl6hQXBC8OlJVguFfHrllrP3LwR6R28UWAHMbfVFheeb4DnLVBADtAxOun5r-ddBrfSBCy0sLPMyq61cIcO-n-jlJuHnPM15twahvFftktevmnYTJkZg3uNyKebgGyc-kKorX0NyVgk2mUX1hEnYqYox9TLZYLK2oO-6oOTDFb005zw1iYwWkBuoLz849nZN7zwwld1aAy4b-W_kaxa6-xWB_kQOhxBViAH5WULYgiYNiubcTQg5a3wxxo-nnUg3PARmnOSIs0JLIfZX5Px1uuuDQwGBROsz6bWLfhG-kdFH_XhOMqONbKBhdntIfXden0Cn2zHe8qLqvcQgdqjTiHbMghROO7LmUHgpAE7G2wQTHbf3aPHaPInXKiYI69ImuKsEv9mrH6N-8fSQI7nz8f8NyH34EnbqsdmZfFdcHEGuWAeadoDlyGYF2VxBcoB91V1VAppx6m77qAS6VHUea8Yc5i3CULXWAPIYq-2l9VQwwalifF5PhwtuYtnUyKgxghkLbbGkHgJHwZTk4MOgj-Xrm63el22n_JtsgB9GY4i0JVnm4MGJ47O8jJ-H2SEsLGZmyoWaav3sL12Smo2t4dMvp-vfqk0swgR096o9bl8re1jGOVR0y7l3Z2AdBqj5ucTmYr0AtSKtf9csmJWIL-z2D8Cv0kfr9cP72jWq5VyXEeG72z0_nINXVN_WfcFfaVzrVXcJ8bBQ_fO2w_eNZ6tRycrvZgT-HLlA1WyvU0S2Yx08AhZ49o3YWjOlW91AXZXK_tjd7eSttsjaPdu7yIr9XhAWUnKYrq2MlduZlLRV48GFXX27b2Ol2wja5eI3rRCjkjsj9PKqlSmfX6ALmHwB1ls39TNziEsC2Ttm95w0Wx4jCjSTXCmSyvRXBVnzTo-M_8nPf4g3o7loVhV73lAZzvIvdxBFAn8WJopq0w2AKIrnpNcaSLBjrytHiHMe7cYiiWEypHmsaudStkV2wHCgCtuN1yv7ZmgNV6-sCuDBuPQIwVJrFZYQwHINinCNJpJNjpChfjrs2AUgN3NIYEFkE4E756LFWIZKwEFlPk_1T950KZkI1PLWdq05XjAXL1U0RWqwx--xMhIAlKxxpENBnRoB6GgxNmtTos0o1_QhyM7EvJfxl0mTsJvwv87nxg3W3o4HKfsiMbu1AVJHg3uVPX5QX0hM8ityjg9fKgrQnzZFMA_iOI-6BosrLvtjCWD5HUGTyID1g0EgNpZu7PNc8GPB9hsaBaHRqUf6aaap9hdsiPgWZxJE3uEh-M4GMk8eJvLwWVSc2bRypvUb7nfeuRkx0Ut_wtMmV1MiwxInZoZxBgAemSr7fNNKEFTRBnjQ8wQFpFDiWbqsO9k6pmsOyhvsOTyIZteuybWlfX3xuoiD6q_pd2pAn83pyOsc_pjcIEprvOrkPPOt9tHT3nI6OdMlalDZIWh8tiBx3Pt3aGH3cOrGcy0P7qvbqBMOqCykp8ovML0AYiNCRTS0Enjn9EI49JAvXhpPclsCOm9PUhTrketXyV2OycUCsP0uPc7Kpe87-RSGW6X-FK23r24zyqcHTL094bDPtdmJDcAkVBV1e1-pCi4PcC8ZOoFeyHpYlsxNeTE67Eo1Y0KmXJDzS1Q_QJZUrQUHsfqKFAQKe7MlM2kIRQoPOzsdJyzlMKJxHUREBy5k-dS4qv-oJ5PVjAk91v58HO6xXlZ_EpoCRYTgLCHCUJf6-4s7ScSn6exs4yTI5rTNk-KSIM8rCOkWNll5dfBNv6hSA2aa2YoHjVZgUak3baaK2rheBob6_eiYm979xIC114A1gREclbmRZ3P0ZZGlbSO7rvAdBfv53rgOPv1la2Ffb07x2MkIrHqFqRWsfOJkQpjvHEPLXsuTnjPAPUj_eK7MiBqLAUFNOuT6z4QP0PQehSoL78DBywsccWQNtiDYyEQdlsN_EXcmkjNmFNFJApAWc8ovZqHkdPm-GYkawabKW934w1AopBr_sR9CvQdRhbzYqCad_6wcppZIKTC0-NeXDfojw9r6EAQdpEuG-Stsks9eDojn14R2z8cVpa5QxKT6qSQVB975SXVVneZPodURToCBmZPxovNrO6OGfjDk-ggr17CMOqx_uC35vNFAou0mkmVOoUHT2L4qhqqOiM0jzHhYRHDQ7v1F5a0bVLrHWl0HOiFmv0Ve_QjjW_R9vuM6byN_osFkU1wAgLuS0PWvjoa2VKXDHFWoawQvGbk2XISB2PuSW2b9WVkIM-v535BdVa257eDYtnFZjUcAh2hJkKde5hdvm9BC0ChX8x3Tf5o9Es6Nl77PcJHAY4mCxNQP8DjFtfm6u0ui2aN-3vuLG8S04HnEf0dBPFgvFyffwQq8e1qgVFsKUWCqE-tm2y2Hn5io9e0f0A7fYIT9hEXnxEe86f_9Y6MpVBVMRbtEJ8yHxpOrOM2qoVekIpeIcpssh9ek_RelU6e_8isVdbH6lTr_vANQnSITiqZBdLB5QP09uJnLKYvkC6fYMIBI_6pAL6u764Yit0BQOndLJZ_jn4NHVER5e2-zZ1fv0d_uKJLQgnBq0sP-Ob48aA0WF0td6EiBW--VT9U4DztGWAKz5Zx68b4NjR4adX90XgjPfBnj713IdnXAXYcLs9oGAOc-dgWHE1IGNp_t6TNmxN0cPjGMoaeF1S6bbbgBDvaHuhDWw6c79FHIpagzu3luZf3JFshQA7CD4SXxJAAVoR-CWVK8jr6jI_E97nddbGapnmiVgKoLAIkmzOJDu2u6qxpsaUaytsPO2b9bVL4QUJBYRF3kBL7bEDHtUp2rLj50qyWBisAT7jniNnM8qJPEtwqFxoiiYF2tNAu3wSbu7NhdgFJIMdCGjQ7SCJYhXYnY7n7ChtHPKoZBvsJQUU5zPRXc16M15g8ueTQU6PufI9uYDmOGPYGRz6hLXG4wl8lqEj1i-djo43ybC7ojlO5do63dCPxRdSg_jxc1vFNSa2pE8tjS5itKOzscY8rIC1maSp-AfQakKuFNO7uWxhEtWEQzxVuFN3-vmPLT_Dd5BeHZKeuU0g7gQrcAMVlbWvIes7IKxiz2A1dcVtGD0-uOkXJrf3z2FAH2PWrjSxIxVNnW7cGdo4GzdOMzi8F1HxTDPsSZkLzCoN0Pny5E2ki9mfLceDP1SoQ0d036qaI6v2_jPrHlcCKJKq76SQipPjcPCeBpuDUn_JwoTXqBdFl1inP4r7e47dUftTwyFRfPyWftA1eZpiUA4qN_bls5kalnMJlHn2LXBOGGOZ8BlJlmbbjNIWroLMz-4SWnluuWbuv514TjV6TEJj-0kEW_5rEkmusmZS0RQwM9xIUzNFpIIfzNqOEUqdCt60f_6mYE8vQnz62lty11OehocDUYE92Ftir7fnrM4HWgRgG_VPkDNwW4NwoUf2dOXGv7R78qUtNKbVZ3cv0KyWlUj4mks1CMz38dY1kb0NLAlS7ISRGOlN_lfNB0XfpS_RlAWbD8uttNl-JyTn2e1iL9z59zlIad9mNYKdW3wl5Q7ne8q7qXDd3X_uKI2C3doGcwZF-fLBv_KlZxxqdFhdmVs8lm4eCz3Cm2Iu_Ap2s763yn7lvslLrrjSHgjhlpNgviWBihq9BKKFczSeSKKVVcdYuQIVf98IdAAJXlXEL553_MzbmhWqgl1YiKu5OKDRFul7eQAeVVHmfJCd99VPuzM36k7PQiRFYyVjahIpYyn4Otfn8XGAM_0a35Gw4XCUhRzxDjAAR8CANqQac2NUceLA0Afxh_Utc_xJYfPT9wVuKxx6gj3bseffGSlknvEn7bkIqZ2FaFX9mv1aAhUk3yerHBJhs7teg1tjiOuJzQ7Jh3k7MXzt3SM75WXsvtCkaeS6WB05xo7k0PWOspKOXChTdscQTjkknzorYMFdgJBo9WQ62BpoN6AislCI27X_zlYILK5cKo7jNW57GSBUv2xjuTqGB0tiAtWeCR1lxtb0sF02Q8dqwTUP_JO34WqNMKTPRXpORjyaqHcvXJDqxDuVhwBNVMBmEaAchDinjOwBffEZSqxxexKlaHEONqxab-DMESDRfFYF_lERXcsO0nclPKqRzXsUrvay1SRhRM4WJnQIShux8TmQzYUr0PZmAmZykfJ5nYZvx4dhxF7wNkEizURqO31BNk2oixpnpmaZwHw0WUPUcTe83UKX4x26omhn8yjlw2rV2hLqXD8yNeEgBKmQMZfuTDj-EFzZMjfNPAvz5iKtdAs6DxWxUZQfq9c2QJnLdwTe2qOWQqLL-Zsw6zEqaQuWWCArmdzz9EVSRRPG91SuTYynLyem8kk0rLC8Ctnb7bWOAivhmh5IOlovkFJ2ySUk-3KMYFMuygvkM_wSZuv5uGa_RnY8qRrdAN9quAQhtDlfZj-B8Kt2WKh5zzlji1v8kzjwWRiHTcwg51v6wyaoJMFMHkxNcVGkOyAC0SqYjeerYFHOCNhlLL9z4jr1J5HErSnim9t1hnWMZtd22vJpj4L_sx3g3z8nXGa-uaEx47BbEVh_juw-JZ9kCrzc5VqIdYlpQURawyisHc0rsc3COduwJ-gvjklX_QOzMEIZlFe52JkVH-BlWAzw8CIK1099Vp1SUGLKjIX5evy9x4myPyV0gXVyFLxki-SShq4c2xzaQZcGEth_txaV-5Xmlr_WVVVCzdD7GnQ2nDRD_kl9ePTSDFhCaTttom1MFt2ZlThJlNQH4oEjiyxGv4eEGckDz7th6OQtjFHk1nz2rLpJ1IATW2sNPtQXAOEjQW31P2rGobI4fdnhQ_mxB1gEL0adP_DN6cC_JvOAB8U7-0gzgvhyeTHj5aGWHF7xl6xClL-7dq4bc8RoLuRz_6b8JelP3PkJ76Kl2w2xbxjuSp-zESHNuQyo2msAripLjTHVuVDg1oyU37P36W0apl-sZySaicZxv4cgnAKB2jaEIMjHvgsXD992CwzSBZQlb5papqqhqGOqea1DqA4cNBy_SlwsPe5Qt6JkWKPRalxdhLlW7BCtL8GkX3X2vNupHe4AmJY9At15x12CT_nHHUB__ocF4GUttmKIyP7Ed3f-1bAcWvO06oSXwrlwxLPqVP6uB6nwRjG7wOy1SNQqBl_f3eIkV_8c6w-A3cWAjj_2xMkGEQEr6ek0j3R_HTQSy7KrxIHU5r9ALcfcwFisYGh77Rd9YFpSOQJ4yX0wWPcsISo3bH_ptEJtIQNzs1k3DE8AsBmHU6OTWwIG8VXaGC3KFrUZfR9Q7ABBlwjqircDpcyR0MvPTYhcR5Bri8UFZFSfEXt1F3BKr-3ZcyqKgowCLVnMkeQWymCRkcIJi3hYQgoC9kARp1Zl72F0Njz9_jxbN8x64GZM58-LvUgv7gv_BN_ANHrFnlMhVrx3jTqruxK8I6W6SuUvgNxynCKmV3q7XM6TO1pFFYz1g12dWUBnh_CT3TFdmy9igL6eC7a96vaSIwOiiYFPao4cCF378EG6GRHiUH2-l3AzYghpPbjNrtYF1ULRpsghVBgDjibZB6GmKoK_ocpU3ZHH9GQjVSCqGJp_ONjOvcUqbGAA3yqNMNhUxCTU4T1_bdIjzFBk2dFxXSIykAgOcdN7sH8XlPXByuKNBgMMlVvRrf2M1N75VOVlmROOCLcNG8iaLfOQemXJrCSAd9jf353miXBIgylSclM0nKcjcjof8I4xgVvMFXh3Gfz63FN_7E8-QHPiBBil9bZX4Lawib9QVrR6AUiIL9MNvLMfE_fJwmfeglo38MxKAxudtzkQYRf026aOvYfowY1YKIwn-uQO6HsDTzW7e2JNrsD3jLnEF_IKLPUwk9Uc_jYSb_2X6JP1FKYl_bI7hEwF5oeTQ0IupzEOHjgYHQaAgEMBPBSkboUvgsdXeqVVrXis4Oc38qBAVjLQr4UGaA2DSlIuHCasg0QomFS78wW57gNLYUfDKCETOhFwtOT28hfov4CbwY3vDd7RsQmZW_Xh6GqkFlbdOTpv_N6hya6Q97SR_kfkE18rnD21lamSv-j7oUZrgBtCGuhkRhR_I86PfE0FN5hnEu0U9a2acwAU6gexpFT9W735vnf1v845-Rysn2pTlH8xSZ7Kx6VqAynyHQJneYt8PlVi2BcplLroemKYQaM_fA0YLObROIqALwgM0kN6bWAkjwlYU1JSjexYnzAjpt-wvfx8464Mans0B_ttL0e6oZWDhJGG4cHRKUl1muppysP39kGQ3ZJxekMwUXAKXWZFwIFsdmlDtHngIY--Tye4XAw2YIx4SCQZ1dfpNqFZ7EaekRLo9DPGzcPtaiAAOAHJPXG63jiwFRcY7LiYGPkHo3Ot9AFxB6LCVdHIHuDncaJSVR8hzvIQEesKdYpOS0HWd3YsSkFbo-kSGfycSKix_V4e3wAqvuaJKRGVF2tAzMOCkz52UPyt6y3v68lYxUDFplKj2xhk5z-kMvBGswCba7nW7YsQ3RJmwxyuS3j8ylG1DRl89vxOY5PME8Q0gYHsMHwDM9VBOfqnSuF3eaiLTxCovyZvcGimjadZRTKq-QIQWSsnMqPQENK5FksWiXFDdIvdNIZVehODaL_qZZJLH24mK-upNbPgA1u8Q1UPju0mlbd97LJUVw7D3vdwTgWu_V2A2QW7py-kYphJtQOH7DHS0R-TbIx3hvC77uk1bOVwsnkeoKf3oajGhu1rEduE1jNw0zZj7AiWXur0dXrG7pqUiVBgG-jXbdUIZt4bS_4aiVv-CEeECuoSqc_O93Zg1S4P0j2SjoIbZQfakK2lyCrvZPtctjnhlm3FsJ-8Xr_IMo2cgLC_BuvKc8p_Qm7Vb3KlzCjjKsY88YyBRco7fF6jvUcnaJmI2KvthHLq1SyBLGdcx_QbCBIUuyIXlNGzpI7ua4S-EJkqIx2uca4am2WgLADiGWZ2idXD2ENkL4l0HaRDPbxgDiPOtavv7mKgSTY0a-z4V6ZPhGn9lMqgmtLfG_3G_lTn7-NzTyFJTWLQ0rdB-uoSXpajD95bQaNNUOcNsIyYdTBoE_TPtcjE140nQAJf80HXc3Pujshrw4JuqNkJMu55QNpl0UUIpkLWRiNZGqvZ1j5XaR0qQrNERKQfn_CgzHBDLQ9-sU5a8BY-qmbxax-jcjAsxbvw3ZECXCPnCDZCQvRPX5xaAL9OAjNRC929luzr-5APzXw0SGSr_ZVrByCxiZQM5WJW86OW3k575c4AsUFbJu4ua2awg3wdv8gczvFamazKJ5bS1CcEmr3gr5X1uXR5aO99IrCXldiGzxlKN9GXwZIldwIKEBzAlYtHKlOi828ykeAtecssQhkhs9qkqtT8jm3MR_FHN5irUeQ9sPwQsoRRTxA5Qs3Oj52yv4mQUfhAzmmAvFkVbHD3fps169G6LMANSRBSuMwtjPXB_Xsce_wVlCe4E9CjL-sVwvu30T7IiVvQ1NHfgUkl2WOuUXTVu5JTMVlUdXrZjmxDuaZO-X3agOztgqPDKGCjYHKjQB7CknOdFdPm0FlZhRMG_bRzR3OCoKOTTzE2ZU8wEjWcInkzFzjXmtXY0k-GPYRpGaxU4-pNuHW931uTuCIGyMrDZ9Nv16-JqLA-Tc7AtUCMS3D_d3d0XmKBAsuTWdHgyvVja5SRVnKP8oJabykwD9EV8fPQKNdymaKrbnGd6-0e_YOH_34zF0hzTpTu1RanBEVXwaaYhWLxS-fpsxFmG_7zlf2e5L6YvIBC69_9cf6xFvgGHN1a3_R1d4WWeKiijUkAvuKx51FFjaPMMB-dkPsFkWzdDfuuwlqryyAf8SPkOOlF5n7ZV771lWLegPr6VJdPowj4b84jq5YQjUXSltgWhLX24-RneJdYzRzxqLY9wuHqpjDZ3DxT6uks2Xdm2m_73jHpqAdfcmP6BfS5CBlhSBHzbWNY5tmykEpsvGVCdt_yKI0aPoUGcow7DURT-xZbcyat1szHW_AZhx8y0m7SFH7Xn5a0YbIsdqBNPLDCaBP9LefoHZkHyro2NSeNxmCJ8hwtP2fIUqD7azBV_Ksd8RCF0K0qMfzUNACjNOefH34rfDGhKYzMB-gLTNIWmrEU6YydK-QTe4__DtL5AdwFFa_w34RHcetFZNwVYQh7sz34wDMyZ6Pk-ebVPmMD5bxjvz_s2GDDmyN9AbEprE-VjvidMm0XZn296dQ96Ysi70aAJPdN5JNJDnMwZVDeCpLzPumjiliNJLwKHxRQP9vw0PNiCaFR6mIUI20smQRg1yD3mVCLV9rjbfq4_8KnLu80KqetvIcH-O_0TR5T_hxLHO3Qp2uZcnOwXmuA6DEzT-Xwkd5Pcq10B_w-85rj4Acq9croSAgtvl_ZEidRylI4S9hBsgsVm5CZDapy5k3oTup2d4u-hX-XwSJYn3dU8whoA8GlIl_AYsmVXJDjZM-ULTQiDwum0cisZrdPNyY6LUx15j-_f3bqZUwEu0su3UGp149GrEahmQCUI9_bR_i-5o2_wnJCk3O6yFd2cIGvIx1JZwJZ8fYhx-fDEl8_BNZihOqCNouhR4yHcVa7XlQr08Zqj4tmhQqHC5Pd3X5bN1oHVD1YsMU430nUHpM0WMR4bYkpdxkg15ijkRaMZMXpxVuy5PZSeLbYCf360zOB8GyrLVtVpgS8SUdylwq3k-3MbyoaMTA0djfBeGV7rcU0iw0iOGonTMGe3BpdWeZgA4BZQiiybA2QPbs11JzUa_eAZ_dz3HE1LK_IqAWF_ZVad4ca-jh79miWq3W9B1NdhDtqr8U4btKUXuHRe7M95y_SEghOxX6qkOkbSC_JQ7k3JbSDerANRdOfDCrHOrt93GKzCnwxv5EAe4FtRfI0qombfAzAZ0M4AbkC4XivXutK9kA_oKWKmPtWiNlPSXC5eFOTtMZP8-VlPHfs86coYiaXLk87GWQaHHAGcStrZvezmfExmKU4VUk9HXKbMUeJvmQb4SxenRbBeziB5IeiJkeW-L-uZbPuSbLqgf0G-u2RrZ4DoHBmAws4ivegi-ZuFZ6Q9bQoHdJEkK7enpf_AdGL3E8bXrcdG0OY7Wyk120rizNTri_3BHz8J2Js1i7pDuYWSxejoUqABzU49kVN6BcMmmrr0j5uqGaPgXxwjLrFydNzSGkc1O6HZptUL0X9GG6XY8KNw6Wd4QFQLM0VZWxT2hWwgN-ewRXHE0itMxZix2_kAC3SXtdHBi802Wi8GwhS0PKNF8QDQxZcDjdHXo4Is1wFmRV4c-dZbHE6qYsLeDHuFd2EGMlBXOxtbYl81et0eL6ANSwdeA9f98AQmCP4LxluMMmE5yAmb-F9kMWtYiwnVqLU7psejeV8flVJQtHas0U4KJeKayIIFNAlKw1g6-HeJJpLFiECc7T6FkOVUjO10_Z8mCwtmJlqq8VZcqwGLWjqn9TQxQDsk5qJGlNyeovqiZbhILZVuD1Rg380BpBGI0jyC4jijIGFENV04UTgz2l2eJszHF9nmmS3bHkujb1buTnMEj3CJqcpaxqNkoamj07D4x66jdaZiVl4yCjjiGsCNFXvdNQQWaZEzY4xwoK9kQ1D6Oj4fDY9Tn6VrKV9ASR2ID5pghjb6uQ31q6Av40a8TSO1B7skK2Goa1EtG4NbXhtxz-hw4yGBQK40aofoJhexmg3VTOtVMwgPgGegHJ8OxFx4T21-zdaaRgtF78k6xB7IbBB24EPcgA490s5OaCrxxjTBP4TeRbazDzpzif1l7n_j_kcpMpxR-_sun5a9DHPmgPY3whK7h-lCDF2daMDuTR0USsS8YB0x-5tnQ6Er5mACwnKBNYpUFMwLnWvNiUsBYAYhHQ8qhyjOblUvEo9kwelykMXus5nfhLxwI5hyUcl1PGBOd5lU6L8BKpgkP6ed2-jvgfdVzuXUhyfUBo73BSDiIga9knnlfCQCnCxmX_mSGtMebXoOf6k46HJ3opNDQDuOOdf9huZIsDTe31ErS5hJLDcK-qoghxXTrv3N-yH2gNfOazpjf2hcpHgVj9WSJcdMC3cnfCy2Ut4aCr3oxkQuLDHpl5BB6uSg70nQsSz0aKqtvs7psRDvvLM6GfDZb5jZ0hmG8mvYFANOXl9_KtXCCohDqFItt3vjjazecA21_-n7Nb252VZf2rYmNZswXTVXNqbi3fVp_3615lBi6z_0efxctu09cLMk33InwhHs4ioaRtOwGzHJ-tPsF5WH8DHYMf_8sD4fs6ZKiTPouy-I2g378HI60Q_hLtQw9LyxSMiGslIUgO-YGUyrEVypoecBK2tRY_nKaHtFWfY0T4CIErC4-lGBSALqpcPu0lXe2MkEvlPTw2adfSMW5lvcbYU2o3_z34Uq1292_0CkZUCwtuCAkdSoUu1L2EjhRbMaNfmLH7HuGD1uQLixuvKxITezd2ZT3bhtHJX-0SxSrfcuD1Vkn9dw48JJZA_XXpWTQGOnrKGRPRklKhGWzFttrA6R2g9cahzG_bLkQHoAq_Gw_aJMD0yYWGZMvEAnEkPQ3wTiL1wgeVm8BYbsVqeV1PBzhMPTOyDHiDjGM4fRp5EuVJOKE0lddqdQ9Ja0uBzfUca87W3YjiVqMF9EDMl40hHtd29igJXHCQc7-rbKSzWjrn2MKp3fES8P6_bord2cRD8pOgETySwpqQv8MM-oGQg4EyZC1Ub6pcZuwBfN4AY3HFqTQTP46eXl4WKKbfPuJhKF6Uyj86Ju74gwM_lmMRdxjF5hzC9xXA_crpWWC3q7HheITngrJkh_fSM5x6E8hEVF-DcR2FDDzPqtApSqzSnUyC6c59mqnqtA2ojf-YxOOWFrikfynKJVX6Zi3-t2ob1MDK5RyXrt1WAa6xSVTPK_mjBABiRNct4CjWkRLNrdh3-Y4j-cvsjquULNWJKf3psa9SQabs79zC-J9RLNehaDqU3K_NCr1_skAfWBjcbm5AHqO1odlDDpmBxwq41ytb30fWgkTPrYUbUXCiq1RGBAlSYZNgkZ-RBLGcbFcDOUWm0UplJfg_iEFAVWisgUYiaVgAaG18Lu66DY2LE-SJm3gc-_l67tM3a2Fln_s9MfKBRxAwekfp0r2caMZRgOK_j4_lU8iS8-vfekw3JZsGV3B7pYY-gemmuOXXaI2Z8m7BFX5PBInoTlMnRa3NckkJz2vXqogYNpD9euG1D6D9j9YV14yj7kU2haSRfIdRN1zJ0y0YCfbjkAcjIWSFJLgau3-ifXiGc4kqoak3MT4-bLDZtGiPDjXHoXH9d1kuHUEfHVbC4Tj0nMvyR5wlaz62H2EQ0o8Yh1LEVoyHEc0c4AUGCSAaXTN7u9-3lun3cO6U8scEMsTc8JXXz0rIaW_2_-Dm3myJFVoiMMAaCKxkz940fjU90xfFbwMqcTTLQoTXxSEM4GwzUv9sHhWrhRvkILwi-DhZ-a91k62jSk-WHsaFDehXwBQRoqkipq0g9bTqKZUg6G5iIPGmjBFAH1tesjsX9n9mVxzWJrL_XLJ565PKxO7wbL_N0Gm1OlSn8i_FWrpgqQ2OrZImEhfMs7EmitjYVEFSU5LZ2af85z7Rcj6cpcHKbEe82yusVsyGzUrUW3uZgM0MBxk-_k8g61DJ-s2kLhINa0QN0u5flQljOdL2YYVRHYBxf5BAsrWG9Do9bNqOisYORRbZcW7Giqv4Bv0w8LSkb27aT6RjS-RrEXUFH5wjWguzm7BVEd-qV0jhMrhCoBTCe9_xH_bxgpYYRl6onS6zJAwtXal3daTLjDfnXJQ-I_ipKzMQAVqsKwQVPfB8GjjuOHMxUfW-usacT1fSHtOe9VL-7teJ0w6CO2lEMoN9uxPWj80sr4pbHof8j718NAgjkXIb5uBYaBbNUJ5s8P6cPiUDDGohOO1beV5ty4pXEP6HBvm2Smhlk0vH9rMo7_6Eo1721BfNfYhGN6kWzBd-fY8Qdbf2z_91hTs-KLwvehozpWoVUaewmhvCKG1v7gx8LwT4y0wjOfaRXibSTRWr7lnruEMZ_ekzmKSC7lGNiUSuQhuWcNS5UYadPaTwAn7DtUKTTouQngqOhdvkWLuxWtImTdQie25t2BiXLsbkXda-FdJdL3MoqL7oxVPuCFSWrlHUTVflseGs5VQCu1hpSEXpG1EMZxlok0-xcMrGCDTt4TfdDac39ftbu8MFD1zMcdeTG0_wQPI2freoWQtfY5dzHiMB3iAL9SLCOXGglnbG35OEkDZmFF-oZEOzAGe2H6cDrwGWXdIUHbDf6fQT-yykgUKgUUgW8YiWMJ3yyjedzf6f9jDiIr97mfiGxMrJ3gArBk0-MDGCv7RQdr4U4-gFNYWQFnLyWl8EoYnxZrbjdeOZ_GxBf0RvbPJSKcLD92fRuwY1R7KGWua5QBcNZEfCj5BbFCSgm2U7kLjgh7jRihuuUhAW5fd6qJApG3UeCUObwTNOGOFGXpbAZ1g3lBINCS7HKNgDy0x-jwxbWEKUsqGhXXMGu_8lxZp1-v-tMg2MJIObFR5GiPSioh2a4OpTtC8Z3JIdcFwav9Pwad1cE0r7W_spVa9srPePoYT5JQLkr-93rQH7nphLNhftyPPjKqz2ubfGYegda2R8xI8O-CKjSDBVQtENDjCgwH8ogKe75znmw5fygoZ7evGV71b4VUQUl5oASV2GsKy4Zf0gdu7cilHKITf59N6dvjgvfdmky081jOBN63oFqNZSfpBvlAU6F5MGuIq0uPqgV57yuWSoqI8oxcN6W9T2ZHBk1uR6Aj2AouVrYAiHtk1grTapGEypMAJLHq5_Gt_OTX-fYqrJjmEzILhgZBYMkL9f5pFxJh2TBSQcTbRyqj1xpSH2MwcByzcmyduHd677qf0WlYpDI9-BBQfx4JNk5fj2A49tfq4g6hMciMqts84cyk7pc0Noo2JDLF1loKQMMdWg_-7vt2HRs_vXzsaEbTR4lc0N9B6t-ykCRQZevHa_MIwsxcsINWMpFkLwe9ETW7eUaG3My1YW2V4F3fU6kadlk6E3TmdippDrfeIYBi-7A1ShNx_id_XP8os3a3HPP1SiRyk_t6VzHsh-KVleJIuigfz7B1TLycEN7hFdrau_DwN48uFu0gv_8z0lxsrhoGYEPGL9Y7rDw2Jl9m-NrCxL0bmily5BqPX28huJmankn0gxDSRD0GsZyNN6BV3BfdIDricZ9UxFI4D8I6KYcrgi9yAI_VX87lEUltSgFf-nJnKWqRPk9NkQ2Qfolba9bWBlbtXgamC_gQUm5ZKnCrh7YUiR6Kt3rGt7LzgSn9KFgqE1QpRUHdf1JfFVPH7z0jbgDAIK2IuPkrqkMpwHSbDHe6CObYoX4EZGZFJJYKKDuLNv97x_z3dhSKPbs3_6A7HvOeAKtHHidc0X_8JPVBtYi1NxkNzWBbNO_0e7yH7qaoym-hboHNmrDiMJDwHe3V9oj3wKH3RmT_Mqvmtiud5wiuX82w-1d5O4UwsWAv-YMuVOpqnm64GN-X_-gWhJjCAB5-T1O0KOg9RPbPZGC4GN0KKyTIqWAoDjYGzgSKbeGhwJz31AGKeOqAXcn6AO8Fssw9ceZ9kX-a6vgJcnhWXGVHTdAKfa8Ixozq5hPyZ9KXKXSqwJxivduN1AIDstWE48vDCgyO9ogABIxWKuYDCxHFpnk2CUShavasyXFxH8ZVxA5HD9ed55plKy6Vi8EfzmiRa5KRtpVceKFLKao6j-M7NjDRtBBvQW0YMeGIUX-eJUl_A-OCEQa8UAoqDnb15bVMyTNkpyX8qzTYmfLxdTYyH_wpJExKh9lQCVEkiKDWAX4TO6NJ4R1Zi-ycq7HNsa55inFs7bRy7SUc5LoDSK4lTwISHGAx7NSNIZlKxJTgavxj-Wku2t49VhWGiWdq3XgP4A5gq5QC2J6H7XX7jaV_6Y5wD9v5Poqc9IknKMqQGGK3KKpSa_XxJb08xX3UtO6H4JEc-_UD5KOOO3vlRN964mOCVtxoMsDv0xanupeddR6aYQIQfoQrAJiRLUSJ8pPJ0Zzuhejcfvin-btp8y9kqviox0pMXQiUhITe-JcISC_zXKQtSl8JGGTg6cx8WoG_5pjihSpsvXCx8XkpzFeq9TW3IhlSmkCMUa5dfwfFu0ibn413AcccKt_TQRIvU66mIcAC8U1u_0lnRaSUSmT-TV77ShzWyskSWk4m35xlSYfvxtzVj6H66XYZHx-yDDN49KkoNEeMjpv3RS7IQAtRDBND9LJiPugcbH9bzVnTcceEbpgpjY99jF9cI54hVmsmkYuM8briTikcU5e2Ks-klKpF2iB79RxlBBu2_1CuZw4JHw-yrnrIL7cyqVPk32fhEbNO2aEaw0I3f-mbpNfsUFmhMFLhAHCX74fMYwGMra9N72ipxBDMURiUu6CFVLkNHYkv8pieReSDo7rEAa9eKBsjAlJDkSa178Qp2IcB_UodzvHextx95hFIXd-8IVoXi8ktvbUTVdR8KA85crPNBLXUzIGQdsHeHXHVdMB3cSHhuCBBMrtuo5UZXKH8b0BXp_4fNrENKOGE7FxV4TnJ31q1c44dkJ48xH8WoqDpWcBgqfnad1ro1Dm0WQnZVqsyV7rWLmvKJNyY5Z0K8UlxXX0ez4oe5UxsVeTn1KRiPQlGQp9awU8nweupZBwKwBO839hvP2Sq8jl1nNnliaMMFQujJXD6iq3phCTrHDvuw0y6givwHWXAtk4OfhjolMR0wKVUiYNr0CvyMKfeEOZtoFA4IPPZ4LOaflg-HlCjBdDOQh28B2lYKKKYOMrQKsWGeBkvh6yDR4DgKb-mwAT5G4zygC1VK931G5agL430nIU966TfdH9Wd05ugVUByeStyejoTW10ws2mJ5a_vZOmYELfwoJJngZL-cv_fU6GKyPMt7mzF80V1Drgp5yZtRSNjgtDEWysWn1lHnYki0E58sbXlLrVUlTDbheH72egWG87hawhYF1tV8Js7iOuS-vauHqOBWiUYhaO7MyG568v_rJCPb_7gbxLrRZpip1XBnBzZIkJy4fqI3MxHdjjpNWBl9Avw9d8Jvpn1myMXC5kwXCwMcJXMRs8VElhy3b_haGmE0Z_TsVGDRMtQIwRL3fJYscqVzPQlSLZu7Mhu23LZQ_X5uRCcBqMdH1cV09P8Jahmm3lP8GFYYeLB5OT5Hcq9v4cTefx3mjX8dli9wJmtJZUybgzIk9xiFhzhLPVGEmEMkyOn-VCOnYZgdPtNQmc6csJ48VpqMzfRNItbF4iW7lPdiTX01eZzxT9_g1v6btMwzqgl5SGTv2YZ5nr3tJJMZbsa7sBeniakH1cXBoPbckdT0vzL6mOI4NcYg0IBnejvDCA-4te89vExp2i_I7JFzqO4gotdr82YyQtRIfz9f-cLf1YhWc4DM2rxkuA_Zg_iNUe3nF6h9jTqiVV4pig1mZsNWRfS6bFoZ42lo4olZuDCB5PH6c4LvrKvUpQ7mJ8CHpKlD0B-eMJ5iwGAJ5Qh1-wMtMjhOeEP7XSvNM2JIAlbobKkrWdGSqEjF6E1-w_o0Uj1506Os1oS6cytvAOr5rK4hpqcO5bKJIWjKvqOep8ASaCc2NUBVmsU-S0P0WJllij_70u6NwPj19mWTKxTJgVpEqVEoNW_ulwgP3Mlfv-i09BG9rUyoeNTbzPqGfzISz32HfqkVwoEavAFyWXOByn_EIpF5EEA9yKwdR5ig9PjCKVNvn1uDAkbsN5SzA7jMwKmqnOhOKYqvBmuIe9hYWjh_ULjXOy7BycfodEEDsSew-R_SbKnWN0iFQuDZLECnnRF_z8C927R-K851at4HLpbqDQeZTUY-TjgRHZ0ZTtVlbeQJgf5xhSs35I24ouu-BRmPxCdr3uhlfPuwbtyK2CDauoLBkXEmRi39N7eU71f1vxamaHfaM-A7P56EaGop3q8P1Vphn_RPS8dok_-aGHI4b7pTlqpyQkYFzf_2khOvuZWzJ5vCkprkOQwMHZJ5YpneTwcQl8nPm0DIBrYekWp1A1C1IFP4V_7EKXOaDc3HdWaA3RU6_Fa80dUJiC98mszNKffQbygydlUDeqhPJExPjxNK2MQYMEd4iiFUr9pUelu2DqzJTYPh6kIw8grvVDLAv090ggyyKk0-FHpic-_hp73vycz-VB9m2lBRvXmGWAgL_fT6b5z6hWo3t-v2MJJ0OyacF88U30B_NmoaCW3CBZfAXFEl6FpwWomVP6n6fOh-hKTgW2Pd-22SEr3GYKWT1OlW1SOzAAAREMt8qm-ZCAOeGTFVFO81JWUDfKrm6ddoxxVuwSBpoBD6ABtOKCLVFzbgMGC6HtoRBX9EwMwWq98cHfplKo__IHmOAWPMg6OkpGx7OTfUndBFSxSDEAdGITwR_ECHKsiKWGx4S2im3jb1TmLurSlRlmq-QHiwbqJsto1Uc_Mwg48BhFRso-1H0n86blDwO6ENKpTyjD52_cbajyBkJv-0m8vnZy5Vi7BYft0_movPDtJaM4EujcbSieUJl6BgcU7VN4XBrzU6nnGfQiSaEEIzeNrXWNRmoClZmEKIOE-lxZySyzuONPVtwfEUwzYx5__hooUL_aE_HnOMnrTLbGXHvMOKDjXYxgAijDPmW6_QcB8jwyC9AuPfDcmMPgmIZSXEXTHnPUtuh6vfFWiRUs5XtqKOxK2lCUsaf8aMmqqou_B6kkUjQIziAOcvLFHZqxpRjRpjxLmxOiUk6H-SLY-xQalcJ-I3v8AoO-jNEkIRuWOS9-hP7c_wDdZ0_K6_34ZEq8sT21oOSL9Hg7-buF5IrHMji2F-xebTrPKwd6LBHNn0ZxtvxFcGpPCC8vfOhl8gbEXUyfAMwGZpJlwztaeIQXhwYr71Nc0W2FHZjyihg7Cgy-5z444gbFC0TjakBDePRXT-sIQAjr0wiMIl-6WGWahcDONCZKlKW8moWRkS4W1YqOQcpeeDgtPU_oV-N2-ARCd3WmOZSXMmACMV-VFmoD5B9NH1Sam_yi-gIDr63mAhcF_wNi9Bo_luMZOv6JucCDV-Rox0RuksLF3GZTQ7hCJKW4COoTPvPVgrnIP7HSJ2QgpuCz0l3Cx5lVfWvzK-9EVjNagnpWDfOEdnpVG9vh7K5Si1Nh6YVkOqJTv_Jc_1YFw-gyzcuf4T5mM12HsmFh4nXaWwcmYXYmTei_8szsWjEZLwFBchlB0MIjknuHZmbIZcQQykA5AR9UZJV0A-2CQ_zA1SO6P6maJAAhY0bWrI210AUX_xR6tR7hic54VOF2TJG4gl0_2MJX5ZHbnAmRHE5Yx_vvkW6s_L7lOt1MYjQ8-NTk8noUz-M3w5m-1wrMNpb14k73QQOxh4O1vznhs4yaKgHwTdwxKAnnJrdS0X2jHYqX63lX2hqRUJ-NTN4CbXwTJBQ1hh1bbtw7Vy8GjYaUiJmLmpDI2BfRZYm808aFugCjj-MnyW0ctrWl6JydVb9qGfzEKfuc6Odqp0ukRmmbG0OVA-J9BfES1_jMKyYSQ1H4mi3VjdF-DIgXNpFH6DSbRlO0NpXMhotbiX1lY4G7jNVwMSO-ee1I4Dpj1WE2gRaG-vbjlAJT8qrHhxDXU_5C-KKfYPJzZglI_n55i2oKAtrPrPwT5GlhKVuPjYnVevveAmMusgKEmKtv6BP0walI6zMo2sqLwzGDYU4D7JxjC0zyxc2nlXDOLDVCk5XsvVn6alWzrTZRstb0JTHTc76imcEHwJEIT3U8W2vfOQEvTRudNuFTAV1SsmpMdk3izosBHGfHOTD8WwEYEsOn8YZdLURV16wHpuExafeME8Vv18_NYCK52bd8xjfn3KKqG9Mdxv3GBjKozQ7L-valwBuo6tG4YJPn1AsNXX-5W1a-q2K7Qu6g5Wgcldcy9VU6nWsusEjtu_QDy5ab6ARfN7X4d60mEvW8LdxVyk4TndGny6Shb0ALIG39rTf3Qk0K3e_t91zO9etuzZlGWDpZT4U718DTkWuWaFNyXPiqSdFjoaSB80JPZGB1P9aWaWIEpRmsBU1G25m52lg0rh0E3sJZLufd70C1ukl8HBWKbGJqoS2F4Qm2Lg7Ce4rHDCTmzMXMFFDmnzTIa7g5OZT3_q85Kv8h8iCLI6SOs5mTO3C0TjsbdGSls3yWouNjVn-x-K-_RL0uXFbwGh710qT9Nbc98dw4V2ISibZhBKZVC7U5FYBQkNXPLWnbG2I5HEMAegSCdeCqSEoOfFS_kePmQ9GVvnspva1nptGiBJhDj4ORPn14QQcZXjYqvsvO90_d1s5d5DBcyXXql6QjWmtzPeWQFAQsyoJWojemr32orV059avTfHJ0Sg6EA80ytesCKQ_DIDFTQxDyaaSjokm7YdLTO3YTMq_v-EyADBeCIVM8UCuFOfCFVLvpUr17lyHgBWgdovaamESeRbqHmIwAMgXQACpiWY_r2YL3Ue1EEPeH5vxztQQk5JKD-lFQh83L5AQR3e0R4QqtLDx5yTspHF2zxdXVrzAUZ9aN-sHxY56MIkP1s2qXcfyE7Mx-nQ-Ve4h4bMdpj0V3dilHf3aE4_R-LRV1Gu_0BvwpKTxDsArPx9g-6H7_flPr8oPZNYq64a5-9TNpfF79NY7dq-VjKkcEUZh8HuTwOmaPobtgTw4YG5buAikpf6x-QSMTrUot_d6yZRJyrjGQ3_lYdGWNrpzC8HnxvD5IfExS53EmzGQJBOIZcx_KtVUhBtNdJuGLsaAQ1_7_HFyEjXwKGjghX-4COHjSvje-PBAdldyhtzZjnm_4bHCzzaP_cARdTd3EQlCtJDg0AHe45nKPAh5qGZ55fxMUGBji3q7ImhJLnWkZV8ZdxDT2c4Fqj9EI9HPecTMhwecGIcAfWVWhLKHsMPit-Y9XyiO-boBXzYwVSvw_ZjOeY-VqmjmyEMP0F_aswSQR-qwq3KZwZ_Ypxv8QVIVp3HgQk-9K3Iv_o1wgmB2uQYgamG0bsHm58aTyivzj7WvrSzcIUYRzB \ No newline at end of file diff --git a/backups/backup_v2docker_20250619_154355_encrypted.sql.gz.enc b/backups/backup_v2docker_20250619_154355_encrypted.sql.gz.enc deleted file mode 100644 index 6e646b6..0000000 --- a/backups/backup_v2docker_20250619_154355_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoVBQb0KpLMiCrRKW6itwu4YUHU0pcQkQgauQWEjCYNAjAbrqmV5KoR0memegbHRNFQKs6--vgkwXr3pTJmIFzJYrY6tmEe8Zy2pUdIPK7L_KNC_1wyHK5vOhrctOZ-T9OMiHh1-T0Z5HvGs8-qUlWyMgU1EP3eeErEePYWAI4y48Fb0osRitzQ2SJ4TOKZ0hFFQCuBzsIt0BFGPIONkTbLziEA2gnAHQA3xvpqJeq33iHmUenSOWCt8xQgXDlvVaU97ZcjKVHga7Up4qPzPDDtok8xOemUQdBTlIXbBw-Eis1_rsSagA96uF74UVG6JMZSygO8XwutJYyjCu9PvSV5Hbf26irZWey5dOqeJYhCbflkzRCF-rcRllcJms2ytrpzsgafiY36VODzNDxDuQQpMAc32buBQ3Rg6h7Qq9Iw9A1AO2frroX0b05ED9nB2D4hnGytwyA6lkTTZYur3JiVh4tpAi5prjYW5GPkTt2og37h-7Ay1unepsvANguvtDxwCpP2NlGLx3yVgdVgMD2Bgg2RWxWwBzWkJdYqGYjqWm_x6BTF8nHwInH3Msr2BxkpQVAgRHQ6uOJLgB18vQfl3XL6w8jUzrNnVCT8xLTeJJW2zVpbw51LOA-nlLU4I-dalhI8dZF4M6WEgbjOD429lsLustNRAutcgEWpwhgmESo53ITKAmLqC_l4oqN7uwg0W32TSOADFtsUSXWgeBs-cnLWeRHbSWWQl0abtSwKQdbaCTviy3KCEVlGX7IPNN4o9-gPR5SLpgy57F-riod9ruZJ13rBGRwf2ODnf1TtIYWTOI8oMxNNHlL6tdiN05pcAfQvL75TOAA-0llvjjEnODi2hREpFJPaK6ZOuFuTBzHzIC0BlAmOCZ_ERJ4JNjzEt36PBxbljFU-5xSrNIVmWSF8AsYuZC0Ss20JHDBaXcnPFMg5RjXQ7o-MGx_7lbPHdKBTDBtnYhpVtn6FFHnzkynIJT2EzcGO7nH1K3h9A3ncMs9bOw8eh5UWnX3ai76gk7cifKWtMgcdeMn1JVjWPNPjiA80Yf1T6bvQ-sP7a21q2b2hGpLGzYaRlzHe-J9jRf2B8AqKjkVPmveuTE2z1QwUPr45jPTQmFl9wCguK_AEw7pcb3H2OffXLl0yVrVjRr2gNaPenLDu8G0PVYOVG9BWaHwI-Pur5Tv7et4d2VLA0ExK4TGDv5IZwu43itL3UQcCEm7TTNXuTjkQI7NXGIbxZaaDA4boDHsEekiC1DV72xdG0aa9b_tL5V--Ys-5xyTh9F3UeyWYDxAFTWj4PUEF6ROmsRz35EMASxpHjspO9e05YuPFP-N-DBwFkHBzzcSPYxXeS48PmFvrXb28GSDVn0KRfpaCZNOUaTNFTvnwxsEswXELd37j8XjGAW-RNMSosEKn83RMR-BMkUSj_T26VhZciTH9lnFMtgwjGgqWeZ8BBCDar4L2RUoVNvDx2FRPh8m4G_-PgmJcx12dbM8HwUOctybbDuGqt4gBrf9tRusup6TPtldme6Xq6nkDB9RilsJyxwyy6DDQoNJmVCkwtswyNzsOf1DQd8q44u9qUbJ9tF14vYuygvmOt6bNOUiYvKuh0CjnTebKJWHOnpLLoe6DqIqQoX4AG85_d84T9fkLw6sgWPL5rr9HB0lB9xNRGhGyMPmoo9gyNGowHDyTGDLyLFi9eEhrtoQYkigVInFiXTlxB8DCDMy80XAlmSU_vb3drn0gvI_Ez4UXDIGg2qvZWcLvw8aiNp_cybDZBmFkntIJfAviY2QCYm4bj3DnhF3pxTp5aybGE5zOdF9A3XX4WDTwU0BhZPkE0Tuc3cuNBhE95ucA_t7PUi5qeshqswK6CSEeLOGKnLbjLiA_MfzVJl3bFLgFR2PIk7bEcJOLn41hmPX7rN09ecpMdn37HYR-bWQ9UZuCFoBNAFhSY850PzvC1lr3ttMDakFSpx9H0kBm-GaWLzvbzmX2RAytybth5HLcMVd7kW5g5eGmcalnov7PQ6TznI47dJF1ClTAwX7S3TC_zqVM5gUgCtQQcu7koqFRe_61dtKriYwnSHfmD5ZTuUomKOW5BiyO-nuH_kczQj2IbvXy1eVLsdF4m9-pyxPj2BUdKqBGo4zwz56iQpzTFAXmseGtOY-a6CLtw3WJIH9xJ2tiTBqtJer0QW4FjftixWN-iL7oBmSRepvLOoyTyu12s_fWVWSLJNIlkIpwBPbLGCP4DR_Zfd6-fN51pJhzGCuG78hgeFOJNvjpt7oev5mAuZMAPPEQw6xR_MjiUv_5Ms6NpifYZ08KkX5DlnGYrrGHeI3nVGJWzGVGdroBIE6QghlTdlGPDdesS0OKDE2k9DKI_lSIfrvM7jTsbQHxFdYfJKYcwABeXyARCZPHgY1TrarSKgq51sIE2TH5vGemcH9RgmFKiK_EION5FhZPKrLE3t30ddH-YyHJSQ84CtmpfFCjFsBM5VrHQ7NPCHGXu4n11L8NdiaMiCBxn7ohleNKmK4Vqqz9Thl6bBYNiJHUe2rN2W9_IpW28jVtwBtR70FLU_AzQhdRJD2PIKzZ73pC8l3EEdBm0NAoETP13od4Zzaa1q69Ll4PSbbLgeNgA_m7xDkKxo3b4D6VR_X1zN3_bk4bfPjd0EocCPPCKMAdscieF2OazIJiVReghb-4PuNkgIkrkterT9CnoxttiFgesYbz-ZxRZG8oUIoaB63HKdoKEP67CCOtPQvyI0dsVo7NlDlDkbIw_Kd3enH4zrfrS5gW8kItaJjxaYGAPu0AIOtHNpxxjTOnPEeiAwFRnZ4q1lhvn2Qb3nokp9g11SEPUoXhmMkJUpC1_3tgIZPWWb1clFrbYqvYOBEG6JgjUMJXQGIfxDHvnWmXoYI0xZ8XEQC8C0ZUvIpRnJxD6wL-YOUqpJjUl5yYO8xnRUXjm9juD7KlfB_R4BcU3sYTAFhbEgjv8HjNfDfiYigSC73vuWZRbjTSCkpNOmJx1Izw0r5bVQeTNVtlv_PorNSIK2wv0qwWnd1sEQl382Aq-_528coEraXzLMNX7vnDS9NEQZ2r2fgYcC4nE1nn6YlabaBSFvWPcfQUjMmo0egNtF8YwVny3kmxRKVuERVxBO_Zn-dr1Mbz3uWawYU4aY22bJ_w5waHyBOFcwu3a4BcikOsfqFUQVQdSUkaq5sUAG5Ez-VBc0URWKjxjbcDF30FDGnPB4vv8PSkaSBJzq_iC-H_kQCb48ADQcg1fVv775-ou6oGiRHp4z64DZqdpl2c__Iu8tZmHTeAxDGZzy1r2HSjY67pwoDiZMMn9UTCYFU2fujosiwqftcnyUyzn08uNGLD-vpse0RrD1YhjTqHJWNByc-NGTeAbFZuNlQZXHQVrkElxdsi6s7MXlhAddWwpAE0YUOof6sWgCXO6i-oqrQ3EfV7QymdCGNL0eipjv2euEEnleQ-i0ZYU7ff3zMY5QosnHZ3oCaubGysQ0aLwlvVVE5RVGRd2B_bjSjm_0L7WWQI_ecQhvJH_FU33fzWhvpyd6I0ZfSsW5hfUpCe0zO-4_LPFWEEjnVbvsrVZBjbDni7ordrIRVF4vY7YuxXBnPRd8b8Z_cPtjOqDjOgObQn7oL-sHrsujlfYVdiPT9jnHb1i6bt7JQU9ixnzxyUqDExzoXjMMqDQ0f3S_hRSakJ4OgeXFYQJVnr-2KX65riAZzHINLbFoIe5Po7ikieRaPreQAoRdixo57MTT4QXtd6fHsYbL_zWf7jQNNZM4YrEKg_Not0bT9TkkojeT8CQy3vGoqFE-yAOzW8aHEvm0lVQqdh8SHRPIaVzdg8H1CCpJJd3iPD9QmxnwVEYha7f87Inzl6iKwsa0iuXdj8OF5yQ0iL-_5eBH5n0scgj49-22H2soBxztAvO0LhD7NobWkejuhggq9DUBv1gdJEz9Pn_CpSH3G5b2rkZEKHHWj_pEAPd3qdpFtg6ZXvxAIQfev-QlPeCn-_51EK26WG2TZU1dhFW_oarz_suoQSU7KoJsbE3eXdDlCJMx8kXs2G8NwMdByR_m3XNQZCjFAkwCja5kSs1-hJ9YXjDOBV_brBpvIvKHAtLy29WxJA286JPbqhhY2OkKyLLQnucGo2vStvAsY2CaVWveyTCi56NmEFVQ3bYn2J2grybDtRnsHuDFXAByWP7TQwoTK_zV-dX5zi7XVY0w9DB3iNEIbuB1jUvaVWRWdt-IIqKQPQDVL_FG0EDJBxNlGSUWGup7zzfl0l-qG-hp_VQiOkYjnYj2oLWM9WvXOgbtte4O4ahfFl3oD2Ipj58jGr3SMA6P4sFUS20Wpp0D7gGilvSMRP0LwuBNMslfp1kWf4FsURie9nJjoCI2-gr6XOsGd_QHH26nf4FEpiGnsRB5V3SaLiJxhgG3nPAST4taNDOQLJjKWwKj8kbsD0IFzAwFGHkk5iSIfscEqtCbTeqEvPDG4b7bsgzky2oIwXmkNMsemFAYMdWPUsbtOQJDEDlNBIT659_XkhsqTcHrYwvLnfVr1EsdyzpPIAo8Bn9hfroz352JwH-wu0GDj5Oc6UG7OoxCjiBQdd2DZTW2Xe4gqoat_4OzGg59vXaLq3odL6nyngOiPfeDYAWCeQEIfUQE7-0HReaU5doTTcuu4aPUVd7cbAcAgX2Olrz9dtitCf4rhqCWG4iPo32krp3XJdtydjOuDdVqWdn27fSNn4XrARrd-pZCpKMuk3ADvVmttQ9lnUBub0Dt_amex5LfI-6el0iE0JavyCGFVDUGRGfulbWE3mQtZrlBKQER8WDFquDRlHWVZRdiou3orjhiNqIX6YtPs5Q9PulSkX9Ff7_wN20lTh2lsIz2O7H2ATy-xVthqbwtChMNPDaRJu13Sz5c-MgGXj8sHKoHh_c2sQSX-SQCeJ8-VTAjnu1Vy3kSq028PV0ZIUADoqO4TWe5n5Gk6jUI8AfqrrOeICh2pucmZs2rDueNGLVpNU2-6AiHq_ziDa5ac95Ni0ygm8r2jgqo2yi4RSBmX4ibxNsB3imu8ssaxjxSnVrB5DjvGjEPOn7VY8vZFvxLZpkAvHGFW--U3NrNhSg-lN6LxKeaShJUsTh7aiSR7ofKgOYwluRVKQOv35EMYRF2jEjFwsX1R-OyN_eAKJf80Vr2v8NeXJiqBWPSDbP-stSaOt6VZYyBRhVQPqe6WGmmkKXhAY1zNmCl6kmBYwMIkfPLKx5ioANpu2waCjNe2SIf8tPU9IYBDNXHrYDesxNxtKDQAR4BgEWhYtz7kT7GX2l7tZYxdgzu2enohiywvyF_-191LP9mBzJBNapQhJWjeFaEIinHOKbaY6EAMXGIzm-MV6hIEgbfoZ-Lt4Tf8MiUGqgTgJ1VR4P21AjGRENds33jDzKnMOOLGkanO0Ir43opWBqSmx4Pt2XLi9BS7U1g9-vj1EVi9mbv8oVhlC3ohiEwPU3PpoSLX-gkYtnFoaUaPX18AMMnbr3-5EOBxl22iRjDMvtyMGcZuU9fU4VTrUjIfdPwfF1wtvwhNLGKrbPXvK8n4FFMCMyOrJPLzfCKNQKYCqrLpuxsV5uNvslKDTzYEiJ15b18T_L6wLu_IXZoDr_y-i8JaR7lGFUmiBimE6sYCDjCBxRNxJggcpcX2eXGqkQz5Nav9WMGH5gIpUDLqIo7OMUmKPkF1C67W3R0cYVS3SxwFI0D4sCKbRmWAlDs2vJMLS22pVCPKEc6xozgAHr3Ie0oReKGRzaY-eQiQC1lUi7ubqFjIBMeG4Efz51V0FxsD6VThG9RbHXhcHvRhMt4Vbl2MpH_nZ2unszS5gZmZ6hhAssma34M5s0NTJmMfVDhZMG4SMwuvrtIaSDjRVpLW0I0pVO6cdBdOUEoe-Yd8Afwvt1VazWa2H_Su3KpxNJ0Mtopm5lOlfBrl6ae3033vgbRdm2HTcxepUJZGiVhXKpNN-f--egA6cOClC8HeWJihkBHU7Z0E-Hj_G13NQef5gzC-yPPxoX7y6scB30cp097T8LBVbvor-VkGJijJFbBDziwleDi3fHKsrlVOMw7FW12Aq7JMpofaqjO4Wh3nHILXmDzL__EZQ07p9m0sIgwTLE0bfWIUXsoliNa0uFjwKEW7Z9Mkdnt9KZs0Ijp1TT2SMdkcPYl3nahHs3yQjHAivJx8b5V6P-4ILLpZM-tgH21p0FzaqYmtrd_u-BgGZCK2dudKe8A9kij9ofuTwHhQfELxZjWQU_sCSRp6uT6eHVHQoY_wuX8O9XrY137vlRELmxankxf-n0l1elNZy7iWqMjtIQuP_GUnSjTY905aHHQqC-UwTtvlVAyTclPYaxSkGdeOx9_GHVW1EM_zJmCNQuUJMw1eXkIpCSfRglMarnxQStIBtBrq6moZfZxXtDhdbZ8WE91VY96SCIRnUA8D89x2FIoBW0cSNKOD0A9iW5x5kSwcBkODt9MYrpuzY52husIkZqkZoL2CYc29iduCGAOLaKRjPCR4YQcXKBOeac9BhJ3Z3R4vMaT0cS8BCaZqQymTfXyA4VRAZnlHtJtuEOFOTrcpL_rZhfYktzI-7voUWHTIHW5-bxX3dI_yimO4P-5E-FrClZl3HA6dbfiJzgJArlERk6M-IgtsWC2BDJugmuoUKNRRMSP8F3M3yE3LrRBkgx8o0zRD8iii4ed60us5_PWjHUihYH3ZN3uzNQpJh7LIUa8N33R2zlYOkOe1UVuJno2BjNiTW-ECS0g8HGg0x8AkMzAM8tlG7mYhvBZFaduwo8UO8d3l0AObAWnNaKfWXH2b-5fzltWRQ-_o4_4a1fzfVBx5JgqCGXuRb8Z_0yaXNr6hZK3Ny3VocX1Rp0Kn9W82sa7B5Bie-Dk44kAQ4UvrmLMIbjIuD7l3v9XJMe0R7r_KZFQzHrIhynQknH_EwRzOteG4vSMtYUJNSwSDieBiQHW8OzRa2YnAOW6z856dcNFHKw2PLPmbFH_8UoKoGrGmVy1GvHL9hK4TzmndmIMsvABaqGSGpzPScqw1HpakChjgewTU-RlLzmPL55Qf_PsfDQ_niSq1eEAydq8uf9Ttwq7bUl8Q5d2HauUK33fHRcNbA5-wE6QG5vveCsvndxJe2LIelBE_HNytoVeno0zk0Avh_sbrnGzcMdPlaaNYhzjto8rsLYl5sxUoikzXVzHGH7mrUYm8EOryqRVMuLByTsr-UXxQsSjy4jFvNz4KoAJxaCDmFFphaXRp8Bj6OZRjfwVW7BRvCCvOcZCITo9TKKkZK1LNqWT9s2pbANEqM_qciVhGaF6E5MjT5CiyLAEEUgrouo6_qgNFUJcs1V_ijaRWxZ2VtUByTVwLYQbAdgHT-XK8ZcXRI4HU0GMQ7L6bI4QeXVDFAh151R5uq5t6CyzHTUKEWrBcLdGeozlN4EPKGqK5GfvQ_QnToG128e2f-sfes9bJwSyZEAL7OCOWOyhavsTKD8tJs-9ATE7zaeFJKBjEJsj9cgQAal7dcmEprxdquqAz1Te_3igIlMnxyBosLyUV-drlj6ae6adv0jNnNk6533IcZpzHXhHIhumsd1J7Bj5a_uB01WB1VU-qGqDnXsp_mHrOiPfJSujBZpPIYnQaNt6EBAWWJIQ4tYi6GwUrSWqY-2S4xD0d6cASH2onWEhbI_9WrspaW9t7744Gm_OppDkv-8cwCq7r7zL8De2rL8o9uf6QWjqq1SFOUzPclrWgFpiSVwKFg-c6mcOjz7_Bqj9FDIwx9IGLXafYSTo6wdamQ_5BZqepnf1d_IN0Nvufn7_iu6h_Tmcfe8ixcUNBSHLKm4rcSEsfXAqO-KtZrUEyFVyvwiLP8wlj2XkPmICn9lSFMPrJQCUSIvFRolDO-ChGxoNKoKytrtJMeEcuBFJ0s2WNco9ISeF7o4GaxocMFFXZMXeUbnNhT7YjDgzvxmLpuygmMApOXCUPojebMBTMHlrFb9m3-r91KwiheaRtXK6OSpXXLv606igTAFmvYmoCwtN66nUFA6I8HqPvvaFaQa2wUF_AklwxmoOmodqdOXlcTXVEoFxglYBDj_NrA740QTiwVnJrxWgnCCWuH56KJM0fbzh2BembA9twWnchsy3AKVZBBkYtPKFz3RnWBRSZGsUyxI-0BvTQjPfsoNgKDCx-WerDdqzXEzFiYf7HxxFGfVzNJms4i1j-3dqVrkIBKyEDU9donLz97v8gmyyrhq-A_wdiIX_IOV1hFArEz8Rsa1yEqZXdnK4-z2gnwjtuK4kLpzmJqDuCGT7zxs2lnq1XUE-iKWx463ujldzUS_oAgZCMm8s-4UpIEoQkHRmLjo4iU6MMYN0J--hjtL-2Uche49DiZREuz9a0M9Up2XppCbbL9MYpeITIdzF432WqKCnVU6u4fWHtjB1mNcFVWe4xy8SQxh8RJCN3N7eq-0nJkmyB2_bZHWXj_ZW3FLTfBA6L9a6-3yPgllLal34PYTzafZUQspcsijj9YmBXSN2x-a9jcjv_W4VnmoWw2fIgTQVQW1635sutOCeutm3NN7mzTZBxLaktrP-jKx62080e_-p5tvRSR6SIE2cylBWNS2ClPA53bSvcnwDBSiWv3rBw7LvjsSjXlkOEeZb4eEs6l1cKkeT7XNtU_VpmUeKQWJcFNch4PlncldEe7T_Zc_3tAfZXPupkzagP2suobMDGEbvDCvRjJ7eKCbttUNoYs69dGkp7GHsaM0MPcCl0P-is9wTyFGKbXaqlmQd9yFI3CF8LK-Rf6YU3-2woSWJ3mLuqptTICq4pjP9E-LCIalveGON4gw87lpyUiboTglahd2bpNQP2G3jIgalMwm7Lw_nnougaPlHT4bVfGCBJ0UB01LIIU3l1OD9HrfJodeKg7_uEyN4vW4kXi9xP_x01iXg1pxNkVvXTYISMJkzHht-yHd_jdQrTTMGVTsLwCjiSgqefVp-am-uUoU5GLlSLi84GZ2RoyASHKoCki2XaCgE-SxJ96y40DjC9mal6joUU5AFZCH3yZYOBABJ02-3LQdM-714OKHrSfzGXVC84X1HRY5zWT66wUvMuYtO6D3MbWyFlqemgG3rur6zfNJj3FPxJ1wJeOVWze3cAsfZSgPJPaALUoR3MLls24UwDWncG9xs60C8mKV6zbzp56P41pnBoZdXYo4X8VTNxoyerzM-O4oLlWOpkniCkufRB6IbZX6hVv9Dmq0NwhMi0RdvNhNcTGOourMvf4AErwBP0g7gdyBQP6o1MBfLemKoKOjDxE49DaNxn84YFkVGRo31C0rnCBFCEKP0nkDXExDfYAeuFguz8v3KVTUYXRl8kNNB8GpPqZE07Q-YevrHu-zyKgTBFK4BxUQH_ZxSmUMqBFUNLyu3N7aL3_e_pc8JKS-yNWQLNEQHkl-ynwYckS4dI0HM26W8Tzi8s1MuwaS3BFD_xHfTiPEzBmSmuKAeQNFq5Jp2kRQSDlBavm4XvmEqKgYO7XiEZ7LOLu9ZSe2Dh1oKAmMEyWiBYntMjce973qNqd2jSHhG3zWhlrEmu0YQUcrM6q2AgVZBxHEBKySQYP6T8voj7InOxwxZvYrdA9frj3gdjgDnJ2flicOVNellc5HIL_qt3UbUECbJC3yqjTliV-X7fvGo1fQ8su_YBl6sBtvTEeCXi2w7iOs6fe0q5JfCLZBrCTXfDJY2pCb5aC910jUXa5cTcJNkd3rSdoLGfa6EftX4LYCpZnH9C2Wg4BWZAK-r2aEFECUTDWFk48CUwzX1vXFGQ1M6Crysr_sBeVcW2NfFEX1yH_CnJKyJ5WY0YQy4lb7i8roqPcxG1NdeAmt53A1_bW0rY_oM2jjSQ6PsgvKyB4yBclaf6msoCuwKzMKvRYNfq8K61R1QXhhM7HLBbRlzFpXSOX_CWseGTYa7HXlJYdU8at1nuABvHvqb70-7rrNktWAPx291R2F9DwJId0QK4TudcFeVLDP8vBOJP1ytfPcnh5SVWrPeueL1AeF2Z46p-1Qp4VBVNr4ZTjFYFuT2jH7pMuVVTk9fSWMUltBLE2C5wWmxdaQeA53SACxf5BiLqOCw49w9yfUSAOjyp_dGyRVDzgA9eDSaqtdT6pXKcbTw7qIr60guwzpUsGGPTjjtSBODyKbP2NnuWN6Ko8xvR5kDJxHXDukOv2hQBKHDRxeaJca_jguaSIKXnDP0ulLq906r_SxGTec-a0IDmjTznFL9bKBpl56qcXfvjnoYoinYjU5iZ5j4m68Dqls6UDCmlNeUeLrZSiL-hsYdrhVZkk2oGWF72GC6kgk453wplkFVO2LjuqmzhXd1_8Z4TRGdQyzynYzEzRjOGoZnsQGHc9yrdT3jQs_3tHNyb8QuLsliMUU8chXerKBCD98TkBRRUWjAbd9-0K1bh5LSAZdMXxzZc3_3m4AUUEsmmDMMBnCU4sgunYQ_rYZgTqULbP1N_Uijt4LwRk5FTk9ZBMKZ-rsdkgk1uSWHKBBNEHZbXbPmODQUBosnP3FCF_TA9aUcgiDWN4JswAb2wRpd_AERT5qU6sl3v1EFXLVykQc__ONiu2YVNcjsKSfQtnwN3UI8t2ugK_u1_Ic2h_2NsWKXnQWF_oGMGKlABB3rKAhTyEUoIBEZSipf730l8WRK1by0m4mfQ77frcZtMlvZjXsDgMCQD2NywqY2dNYrldBqkIb4mYDKjJQsZBOs5bsmS5EKiS2hvtrfaAO-0UaHRZ2ZSQ9MLcKGP5HsFPPWMzizk5oqFuM2nElHSktWhxij5kJ4TDArQXLQJD8udtgd7Wj49it-OaiEiKPK208nzlRhxrvVBJsQz-E-uYbkDHGjTR0xondHrSrhryIlFkIFlxABR1KT9L9LIgEPjbtox2VW_L1OY5--FK1hzzae600zF1nihqXk5KhHBUZxqeG5E5jEOTT3vuSrptRkHdhBRi2cRNlqS4QK7mSOI8SyvTeWNl3IGxM8URe4hum8ikzYwfJJWdZrs4Q4CYS2nfGV1y_ivc-Mq2gHGaaGeSnOLXdTQYg_tFLwI8hy52bncPXrC2chx7XKj8KNU3z1JHve0mKvpCqemPReMArtYD8tBnOBjbfzx4Q6Kumgwo9CiswsdlOGpvtxi8_jCGguPejEnlMzfsSkoffVRtgxuTzmiQUpBf9lTnaQ11tukX52ewp5HZxroUn_uqMuhI23eTj7DgFcE_2VcYCDqnkTr40wo-u0WVhRW5-GFxxQgcaW9TxbiY5UIhhnRGwgZqTSfdMSFUikETSz1UVPDofqGKxTrAb0aaTjsSi7sxecoI6kr9vEzLbD9bAJn-r4Vb4KAc8sP3bazZjIQKE36wTJoM75wn9e7GaAIG4G8UF_4TFwRex_wDOODQepAAsFmD8-CI8V1rqDRG1N7MmExdhjmnaYI8kgTlu_JjvPH7H_Tg_0ApmnumOhgeRQiZ4BMi7RCZPCTSp9sk9pgMp7Rj0t16VCILV_nVAQbVZuk89Tv1uV0a0BkmQba4Z2ZJlxmRlzRXxF7n1N478b74TKTBL1XcMFaX7QKIVVoKOQ0_lM8n1MLlulnE9pm6_XqvaqVj0d3pINC8wk5VMgf8cDas7oopGgMgzfCizlzfeOCVaRibYGnDIqbK6ReYzqtWJVNRbMmM-ezbL4esHxKQ0ahlBqAnPxGlesiWN1RAatfORkAH3SaXBjutmk7cDEFx--zyGCPUwoVUqEIbR2BbAcFXk-zWoJt245sXsyT_587qG7nughgvm2MIdw0FhnTj0ae4bwheh2PZXKdi3nEFKYG45eDy5je24-IQXzhA678v4Xw4AsHASqHPDQ7PWQbiGlbavBccaK-Y1MJCGX0nDQGMim_Iwea-E7upcMqbjesDX6TNq9wMxGT4YlwheKcX7Phc07HmUAOgLyuAuo1G9S-_FjosvWYfWkKhhuoHMu37GN3_dRbsS8eoCsl2KVoO8RUQy0BneMAtlN7J4UCg-9zXwKSz1kcQ68rDDpoxFm2nO2HAR5EpSFjtngnn0h2ClUOTRWvKc4Tizc93Xwa8EL_zt4X63NG9-GiO55ppvVAAERJiNh1r6LBMA_AmBWeVbzUnbaRCN1TG-k2bVIKL6WwhVE1Iy-gDnHeuneOlP8Nh5ZRTYs1u5orFsM8fHHZkbxbJiOtrZn1RGZJKeB4Ybh3Pw_nVUkLEeLcDy_MDL49zoUQoyBuIuDCDg5PhnIBTNS2-79_cqbuhX38qMplbcxCU0bAZXCBTwEtJHZ00zGyICU6lPhbS6dwHSJKRbXHMFDzRWR7IY2jCXPx8V7lSbgHCbUIgTc4TQYkfrfMNoq1BfBKdCx53TWCknm8kEo5vlegaXZRJ7iN3d21yc6WDEUNUXUJMpHrddkJs7OnzU1Q3bvIxoqdrwbAr6u2UFPXDwtBoj7WrctAVEw4wM6ZuPxtqSMxzIqqN-DTJzPxDOkXKUosrfxe99EyGFYJ-OU4LB8X7Wstr9fSGXKhuOy3oIcFDKyWkTgTCARtjJi5Q4sU57kbHqWihfp-mtAYR6vlrL8ak_0yiAKPBTdQ7D8LDCCYkjVK9OIIanQZL4g2eI5sUYYpd6LGevaZO2TsJbjpfLiff41OgDzNrD1CEqfkT0egpl3AH81SwJ5fLxShOuAnBpi6U7RbZh5l7TN6kcGaNS1l0_Vrda7WcIgn_k-V4rgEhy0i89rLW7NjXd3OonoA2FT79RoZ6ztwVn1UJvutNbtPA8wwFIg5PFnDITlUGTI4p2L7XhrAhlpVxrriRpVlzy6oKi-YYS-It_OAm-6JpAPgm0XXoha88yOjW3EzLByL7WgzD4ABx4m-4GwuREYVNQcElz00nPt4Tk6ZbLD8pt6rbqwgYTFrURXCp0qD1DpF7KS_6FDMCmFZZpAwrEUj5htCdBxm_lEsCVrhrCQvqc8i0HVRb4YGjCKbQrz2FfST4Fh4HqtyUemkhYSw8W8VapduVMuRDw4VoWJKOsyKRNN41NOCksAopO_KVBvTVcSfFZVeWx2j0J-78fLp1DMOFn3t7KM5GvcwgsLgyNAJZoHzzVzREhYX5eYUdCv2k7m7rSIY15jRJyz24ZpzrORGRen9bXGhpXBw-Fz8nOPvYWsMVTCjFeU8rwLZBa_2MzsscTvUMU1xBfUYV-uAMsbT40IzXAoZRDgnWy9_iFcMUAJC-Jp0ZyQx0mIS7R7RxkNJWKFhP1TEZSgFu57wOoe6yLYHer9DRK-bNDb4v6SaAUmYQ9AVCNDiD9SKJOQRYeWPC2lh8N9ubdLzxKeV3GwkdQ5FlVEXZNUh7AG8ywjgpAYptn9zhSc5aqhGinkFAfbp6s7vy53VxSTrawh-e7E4_mUqMlkbTi1Qo4aL6wg-NZDIMMkJBYgb9f6tBV-6Crgc__H81wj8qi-HHM-wsL5nvD7L7aR5CcIXNczAwwYObIDA1RXWaLOJxnT8OLE7paAvIIhXUjeFz2DPqWcTkJXSLUHNJgi7OdhtqmUxLim3khn0LG6LbXfea9KXIvmAtCLiALwJJj083PShhPV5CG7YE-lMbrvkrgD6iidZy-qY6zqeACVLJONuCO3IAAwmTfEAXSu3jRLw5nYKCXKDi4FK-OpMcW3Ju9C6KSg7qcTF4w2UfQEpPCImB8kfVpzfZGNL43qUd7Z-GJ7EaBaD-vclB48PsM70T26pt4LENupzJsaaj4_2cjpVbmbixqbVH5aNdF7JS4oNgJVzP4-gMLSr3Z4fSREFrEDHHExzuMI2ADAHwUNkmBmuiP726DJZNLMLbSXA14n--J8uG7y-AqqQmw18o6xW3KPeCTgBvUFmzxYN_kwSJSJe-em18Et9vQwfpEzjWe3586A5zrXVQIP3kfb7Tm4vVH3PSq69XMR-JhpqawIt5dXTrl1vNf52bHJ1VB6WLqJozEAC_J704yEvKZI2sa9t0WfC26zGaOWGohBYzNdx9yMI4QOGK6fc2E1J52AwwlCqEzoJE0fTdyj6boOE8sgzmW8wV4wAIcuJ-OzSAtslNsAKf5b8fEkvUjkNhdGwY0IRGI2UpG2AAsWmF4ujnBPGyoottWqg_gSuT30qEWMPseFcXiAwItQWUeVvkmcN6axSJdKaRSB_jRXxHz-DgIurdLk5aIe490G9DGZZJuDWND7HhSWjjZGuLavlOmlvB_CTWLiR-X9sBogALDW1CvJjdBPmzSzEmpJEMOtn6C0lygCqCK2-thcOB4MhKYOvixQt6ubuEZISVDcF8woWP29DWdx9MRHNHRg2OAFleNtp1WsqjzxVx8WYkNdBEGWzH8Nw1jUw2L7YpOrCsvUBxXsqzOHrTVMOZhWuk-KLnLggi1wv7b3vYzouPFSQXO2dY4bMR5Sk1w-tHOf6s6pOBK3S-Sw3r_BuClzYoqyTcM0MnWYI_9JD97OIk6GVAzOXA47G8fEUwiBhQqFn0JZo8vQWnzhyKn3-gUjD_b8KPPu0_eOlHx3j5hoDHqwav5LaiFWyyKrT4ZGBgSEcURQEaGxkeBitJCdhu4GNbgkCcqqfmFMdkBBnmNykN5qUk50aJ7_a3FxSVCD9NkZimn2kJB8kUdZer_UCWclrc2ZPYux7GxI8a5hadE-VNnBav9fn4a33BiCWSN8U7jdbxbnNPkcJXaqMsOKgg1Ejow7SgPrimxPwOHxEbdRnbuivW8-9O8ZbbFVEEOsZqpqHZzyYJ2ml0RT-E8A7e99R28Yghb8aCsvwGDfpkviR_2JMBOsxy_0zFUxvpE4uh1Ovx-byu7ued79e7qXOmWivEAo65RYc5uoq_wxydMAuujZx1zESzTrrlXE_IoG8Kl9Bf9Ev-9j-2Rr8bUiv-lYo31Gk1aHZmpjAs9zTuIvX06LZ-SbsU6mSpr5ZxvF1m3W7byMUZ-Pdi79YYglCcp4gR6PVtcYAo0I4F_Pg3gY2JDsuySC3SO-81Tkm61-biKyUbQG12C5ijA8Gh7IxxJZRJEtcQ6Vgtkw3nykNIPsSut9DsILLVeqnuU6ZdDJAiAtFHW_nN4XR_V9czDSvd10BhDSXkY1AA7g0Mtqgz2YO7Wb5IfVbhtvnUGilqr8x1BB3Uj9g4s5nupCW7g1_ots0MsSVbV9FsPcWfMV_H0QFFCyqA60Wh8Ya7fJOnPaMVSpNepPAYgVxHd4ePDAcsGujHl-QkXwIKLGorlYPnjoJ1Vt8hz8OvAofFWtSM3yR3j2UFl3ByTyuHfhYe8iESWIq4I3BP9mot9QqQHivysMiQm5Id1SUreQ8DKfuIuGEcEOxJMULIjLFpOC7eDIctTUyFj_9x_4h9JVKwyFj9lYvEZgIUf0iAc83byvsyv6ZZbvsLDY8pY0SiWHzHyQTiJo68bJlQedmh-oCABODlOiFXeCCPblY9bh5fLY5hLAd7bPqhibcJtmi0p8GEYZ0PC24Lp5hWwx3AtQ3-lZOM12n42QLN5bbMcW62rYLo0pYlSh-W1XikmEvIWKF8TDChbcw2cUO3W6Ek2Vksd08jeYcrot4NbaZhrgNo5rscTcQ1b6BbmooyEmqROi_Dn0bJV3_u92yx3kiOFmfaQMUucbnmIGwWemMzi73XOeCcZl8fhTGx9yySkfGFZPIWWzCZoUxrRneymkbUs2PEL0AcPvsHLO5Khyp6mdancD2b06oywI1vAj__BFOBMjWQI8EhFvIyG8BdQamcaQ_73yMCXNgmxCbPdSbJ2yoVI8zzr_1ccJgNwPQdz5Kqnj9nFLWLXMXM6fjjbKHes87kCPtZ7rRhT3x8oBGsQIU8ny7_mD42UEyiQ_6FZ8wYS6Y3XM0Zs6U3Be0jh-t95fmwPJL6t-M_tOs5WrDj45SuhlgeSFBzm1pqf2DZNq8K11RbVHtV6oXk8b7GgJUw_MBoYh2q7vlniZefXpHZmy-NJCwWZFEqA8TiD7DkXUTy6fuqRhoZcwPaRk3TtMyPNF7Fd4J9-rEQLZp-b37MChD4BUoZuvftCAPlwhzg9-pI7WLqD6I_ifowrD2Aq7MOo2w8CjL2U2TWg211dq7nMxJjs_jEZVpax6AawSAFC7dT3rCZMqxzgfTaUJZhcSShb-tCWtaaROFo6dj9RXFmMCnQknJLNdDJCaAGdAgr3lSpBDZ3YFwmJaGFAKbyFmpF6H4NRtHzpttaoARJyfSwc0Qo0KTj9o7oQLK-l6HAAjO72QHnrJKkmasSqxbSn4pouM2DzfI45F_KYATi_KjJU2ocf0X5rSDlR0eZiTHVz2XGGmmkRHeWNmNf_pfI8NDmP8PhqWT7TkIU5ILsDnR0LTZNBIaWodZEFssCLacmg9HtGQG2Uvi52k7a0HfEFD7T874dzp__RIRIIcZBueMdqzDztwCsielcSkEIme1VqX8qIiGjehGtipBPTuLQ_BfxYOFBAgajmgu-DqhStkKP-X0VW4YIsQfNJLhJDGft3UV2_wApobiN-Of1PTV9JgwZgsCY5yw_1brUUGlVuKjZdKa44okjBQ7m4KmB00MAp3cGGLP2HGhgJpgB9eNRiSrC2FAyrY-iF5MJ5i48BFvcmFojV6XsHwEkdv_qOJp5YdW0JMrxwZNF0Itt7eZdMvIZZYAvH-7jK1h9cSfgRTi0jP7eUxC3zzRgA7qIe-vYtLK_J8-o4Scc2wSCfG6iVX7fmU3PJaNrdCrgeWrHO2AWF2M6UqqSvoQH77z5IOge4ny_0Xlyk0A9VqUfCh_uf3sBy2_yOBmYYYq6ront0eWDVwsnLLlCRJIuF6F1-5JYFLuxKkpfxu-bRwCUICteHL38yGXGPY3lCBDYXV-KunxXdbIENiR3OIqqg9ZrnLi6Aj1wCEsJPOPrIWKOa6D-AGSJZNf3hQOZPwnmhfhObAY_5RUq-M-3DcPInWxGDYvoXv-yhocqqv-uVozYeh3bsRmDJqItqMw0fHxftt02p9SaTrsM6FV79oaEJxxoRBA0w1kHPKfq7vcdF9PZLb371b0UeFQJ0MjEl1hCaCDYIOuFdWeg-z27mtQtu1ogzvZF4Pu4EsiJT7Two7Za_bmbXdt-t3j0HM8CC3rfrOUy8Yp9UrUe8y3RDu2CnpgG5TcbH7MZxyfRR1k3XGbGYSEaonaxgxIKeC9tIr_jOcJrCW_ToGRvId-9AFpL8HWq0w6Q7dEnUR4CAl_qPM9zs7L90JO8fkLwomRd3I-NNWqAtFd3Jmie9edd1sfu6oIP5tXn6BcZYiwaf2S2xvNH6TgrSATxerSN9z7v322U3umHIugSGbmgi6b7sJSo1B4dAyIwqd5c8PF9_vWwcmpHT5WWdNB40BGY73ZJzqA7k-5B0Y9RnF471JGr0Ka4gG13K7vvUH6jpjBuN7maIocmJhJQDHKXnRsTltyTzKor0ygtLcxEUFw-SeH-DPETfSwMxI6JZlTfP94MYzpgDdIkA067Wb_oPp_6g-L5Zvvmqb7GdQdgsF-ZqWDLrNeTSjH3Cda0z_meTGg2tcuWlugAKcPpsjEaDWsxq4Y7BlPVc4dlsEgN9IVNNxBoiT2fU2q1laRPWYi1nhrjjK6q7zpju6nVN0pyx9KC5LQIBXzc_QCUd7uvDtRsjyl6XDHM1UzMPyESI5yCqwzYm0iR85EuL0xWWHHdYrChNgui645IVemTTmkkfc-zKj1NzgvK2UZ3aoODiPnpzZN_g9gtJk-bqEmz-d1tUZmu5K8-2CeZx7IQoDXcHXXXX3uXbl7JoqTpcA9dQrnxR155oNEvVq4CyQlbYEtOMjWgOwu3c_NkanhXhQ5UlnRc3e_BTeMVfSnA9Bg8qo5z07R5osdV52ypBJ-OtITGkM6In6-AdiqpHk70GUPS7Rr9MKTJxaBCGZDR51d_CvXJi1KUgkjnbbsOoDBgkOZmN12hMhAPOY2DSCAc912gkZOXdHW0W79IXt67_3J9_jj6l1nGvm5aOANjg30mL73eya4Z8ramAQ8jq09IwI_3-DEsFbdXl5FY4O6-aIAN10XzIy5ernxIwa_iefgj4-wijqXlRsH33uUp0mRaNIaUy-JmSIJZJcZZENEPMYw_T2FX8cIMr0jdAV6dpI54Z0M_N3mljQbX8BnJEkzwq7kjjeh2VSFe9hU_K5zDTL_2VM_El-YZxyhjnxVG3azpTcRcOKUykHO4Za42ajHkAuGaKH1p_spsLYF3iS6vKkLwIsxO_DjhK8iF-vKCQEALkxOYMxvaBRZ9Se0No9YremA5b-LXltT-ueQPT7KEP97Nufvbsw4Gfy0ssDkaFV1cZVil-9oKHntU8GGtklSYsey-l6f-S6BuQNOPwbNvsEpDxm4BT9XJbgmRVdKlYQ0hiPLsNpKTgzRRYGnViGrAgpSVXPLO-N16IDihf8CmyVSqfebeFaSTkC66uSlEHV0x6vYzT22NzoVNnBPqXuy4pjy72qxjL3AvLsUyx2z8bAzaPAjsh4OpXs0v6daI5xJvMbbYijRSP7LjqLlqpTPhTEyTHCXMMnx3VHsfYFrI83DjEHKHFHOOnSLvFxBOQ66LfdqnlzNo7JXcQxXdUBZpMMhezMyZwFCtKkM4F4fLB5lo_fpSCFsieP2Ny8Z-tG6-e84re0IMDottq9idRUpepajIqg46_-AgAbhRAx9WvRzNP7rKEvMRsXanhcBMhWVpgD6sb9BWTtZRfRJ8FfLXENFrsub5R3vqvO041RgApOMEVvgnyrpamLetr2iUPrLNjsMKe39BXrhbII5tpRrcLzMWP4hcYz07kfNhKH3ijWQpGMk4Ovd7h_x3u5rRWlbreK_gdI6rDyTGDhdHiHSGt_sZUat_VOxc2jMyzzMFo8lakHznOsbR_gTxtj035iTrw42auGsdfC9xpsBOg_p1nICAbhiMyVHzBP3GuibF2p5MuFQuELx7rh2LUe_YiIUlAWDM0CJEgR1NYIpDgwXDsHQv18tHjFTIDuAUZyPd11jPuoLwy6L2VGYLT-dyNm6cJ6ihYjURGsdRykC9Vd_pAL2FUtowyvCcWRp835hGFhzdddS0yjdVei7IBTn-OAxfOKKCC6tcJLSAQG-4v2BqnRZy27nwAF0rSwa9Ciuau1FTwPrpm2igzWTlEue03YykrOy-huLYq7jAoIGr5ES6IwZ_U0a9Pa06CA_3xASRC1oLtnzKBL3a4joZKazCbM8dPpsHXkWq4x8ekhItPnDAcLQjO0qurgs8ZKPzcL4MuRdn0ZkGNmc3eqSdOnq_TPgb3QbJPE66r8AsCLbZZaPLi7z_E2RzuJYaYUbnYMHxmO0OYc0D1ZcngEtxEKai55b0_y4hoDnIhKZ4-BN0nnyBz-VkfLMzD71r7jp04GScwp-MNXlWggGkVhLJho9Tixz6_72Uqipa2kANBsbds19wjnXjWAc9V9FsTm4yLwRdcc6t3dSz_VuvUtqdFZ8IB2Jwz5XYbbjYRXdGGUT8Upifos33RKFGAdg3ei2EpSAp2YO4Ltji-Z9eGcSUDVEaDf5Bw7kA_CPX0yHABMdJvYbN8CiOcXSa6Vp2Xss9f2CjynBOyElTVbjXjSaAC_-c8cp0A86BKoYLUvNi12HYQ0_5OnLwStjavNSqy_TzVb-k-01IXp3o2_Q2oySQwo_oEUvAsc0vFV2hqWV6B9lQaunsJCyKwDtkHhLcYYzdZloesRLuygCp-gGcYT6Ft1MmAleqcnq0IYYSmw3vVt5WbBqFo9pOT6nhUspM7gH7I0zbLVUFY6HapH0-cdcFW9H-v4ws4ZLqROeIgjMw__pGgl-hrz5xYnvuEi52y7cqAzo4aGtQu2u-daZBKcXF4s_fIVIxJiCme1JY3VUQlLFVNCsxhENhHl_kckdpC6cxLenO3bjchEh9D51RwLWV_uiNwhL_FlsX3ECMu-SlT_5QVbdFkZoWycFaFRI9MBmPUwNjnKUQVt5JAhsyFRYEFtm_pFaj2AhSnrKYzPwdw0JofleVUbpz9O48XX7IcIpGjTH59vGOLx8MnLxpvCUHcxcPGXs4v2I4ilKQxWUZ4INnagBjqv5Hb21dJOCZVVxSl5gbTL2hCqL3oNoRbNj1qXOSf0f0qMU-m3wjfcQCMNd0q1D7ImhQffZ59fdAwuuv5u_2Mnmw--sE7BAjIoaf5rpMon7RZQplJvcHcxMzOUN3F6cW1nTzCVJVuXWgMvRvEJkPXkzZ4y7tRBBap15oBc2aqqPCr2eLNJkp33l-R612IDQlGXqKYh7qMDAksprGaurK88ZtOc88mu36S8oM9rNo34eoESMG9zB9CcBEpcwYeR6k0QP1odFSzdrKmChRLk5CVWb2FYFatU2OBU-HttX1F2ffLjPqAsJIUwBLQV9NO8AG_R2Anj5vRJyZG4J1nga2X7GFM4qckaRDlrPjmPYnVuHELjXFRsN0t8egitHHL285XFWjFddRUr4FXRyvRdrgjWwPfOt9juKVo-8tFLWzgg6mv-s21NyjmVPAdKf1JptnoxR2t5A8ld0EAH0DMa2gg59XIjAmOpb5vpuojICTlA0qBdvE2NRrDk4zVVH2dhtpRdrVXdayoKWnOslqBwz_pzCyuxJchSp6bFWEfpj8QwqpvY5i_biPb2G-J0p1ElfdnmYMlT8k4mbJMkmATidkvsbg5kInRH7jeHiP6OdDAHzogHCkDjHVaxIizaRvhEBqSRsvUMt43Uwnvvgdw5RqKer2hL3J9GNGHp1vJGktzb2j5hulHTELfAOS99N4ipxcFbM43Oml0Flnra4ys0MMNNay87qgaPKG-4PzFp7ZJ3ylew_zyCpc2XHoIUqjoZzUwl35mtUTxNbDXQWE-YglKjzXju_PNtnLsuJJACAKd8P1Z55u2H6Yp9B2O-y4_A8MKnapFOaEgzSdDuMY55foNZA5VHtlg7XD98YRT761SSiMGFkv5jFnpNYwGenjKj4GQA6IzResJyKl5mBYyxsEbJJBT53jJoFXDROzHkbbMdFm2Fz0S1Jr4x9tN2PP6lxvn1deWZ4sfQj5sSZWNmkFuDXzGIxUl7xb_31wJ1q4GU8lcKqeNb71gtKibOfAtqpoPWcKqFviMTNhfSUlviS5bpa2Fs20ud_zOQA8wmIFkpv_Plva8WZTRWSA5ou2HgJ9gjJPxe81DsGREd5f1fQLH-CnMYRyBovcFNNoEt4wWVYFwUvzwBCpElP8tpj0f1gz7NUdPo2c9Zs6Y7PuVvtbpj5zp9dLWC2RM9DJWwdDoewfu_Q_I8FBgVtn6akoZepxnEup6OFE5tdWRIEaCByHVkzakAP_517Da1PbH-wQ3bJ-5y9jU4cEas1ZhfnsMnKdwYIfL3t9HhxhzwwXz3UgcuNjHTMaMeNknApj9mTQSg5wOPaPWuPnBOx6w2yhcjwDTZHQNRqw3OpGaflybtLcXhoDGfFsclzjOjQN8TiqnY7ozGxy8t7BphYCuuHjJymGjUXQ2-Ys18Tv5al82_eht_Fs7eE5B_7ION7ATo1LznTHE2BWFkuADJTw0TG87eJbZ_rlsf-PCUWT-0tndvm2UfyoJ6aJYAvUiq4VEFVI-8Sff2m4R2gryWpScQRS5PfLMss_pdptQD9B9nHVLBAx7TTTpGE-SYiDwNJEOAbGOad6Nh86gWQc6ezv4gde3liB5dEdKPKFAXacN8tqJPxmWjrdnuM3V2wKJbmY3MIKKmpq-YTUpJg9nid5TpBjo5B3ejVCoo_1ogzdvukWl_UGuqqsW5dXPvf4_FlJGYNKLQo8DvD-NOVf7fclSBp0NInvP3EvkOoXYnmCZr2o6tAq9G72hOcG0mujjmHAU1m87igep128e0ufOiGPc3U-sNvBsWsp7VEjRcYwq2E60ibW6jBb0qoFim8M-dXCzFv1y9WyfWlp5ESss2hdacFPlLTNCCeQWXwCHzeyBETxVcHUj8haGXHkJsIFanQWoqGnhgyJkVC0NMRJuXWVdyXMtab_6l992UPVCJCvmppj05XxMl0rFBmenjDzQM8ojiS1MMRZxIkRIB1VAI3ZYc-AHZDkfHdOIRzrR8KQqnXgviQ1IondPvDqWmlFf6niWQlZ0ooj_neEEoGNq24dfWH37vhB3Qj5pyTOSu_6i3YvHmfB5ZIBH2VTHQ13Guvfml6zLmWrZmATHXtZmiN6SqTY6YlPlk43FX0ruXAifGWymHyzO1pVEne4YnxJAVeWPA_YnpzzHgakrrpJ9WEPPpf4LN7ZNkhxN3CPHZjreZYz6uT5dUu4UfUR-4tHIL0djWOo0leD9ahdRVac_9ords1sNKt5fbmMZpuIeJ8_JhCKFeeZQZRqZl99J48TcVOQkiq3H02efzPN7R0Bvf525CUyObB9eBAe1Rhez08FzoLiXRU42ThIxINLiW4uT-C_3K5irocRpvIHjyPEQGaD2-CrvG5KBemNgdIHxgRxOeCyKLAwgd3v2eyHuTlmhnBpZBIM4xpkvmhSnm_0Ib-bgt7G7LZ745jsi4zMDN1WUBLNW7E26eYr28Fwbv06vhIOf2pQFzAfZQmz_IaiDK8nSS1GuoF_-CMTJIXfqa92TLZQaEGeDEcibORPyikb0DYYjGYIcoyhif2jTNhYOV2sFneFoJ1jVnFsK-L9QDRRFbRxJQn4xBTEoxjLDHGfJr1-r7VQ6uGtOz6qPz8cmq8vodLN6jjpT43fi67IcEOqmoqLK5q5PDkjb8rwxyvEnoyocwVl69KXVlufnbNdE91wIvNR_7XpmefZPvQFt3ygem0pqxuOfLn65RM51sHISxh67jgzl9dnaS4hQMfv8XO1_9kNDVGTNC81LthxC3DBymn7Q8SKuIvHyc8mBgBJB0xeGaBA5mK7yPt6MRWYa8Bt2_ttUPq8N1UeB5As4hDfs1VGk3RvW9C3aj4LtJeyGzaXcIQtYlDuuZBIrPz-1FQjk-LF93n_qFziycNwmh_6n6ihKn7XXqstRpkxK8cZKKmSq8LgVIFMDZFBervqm7zFt0Lqk_1Yh2_rz2zooh0i-hKHyrQYhJO0dofcpOnGkoKJoLcI9ukjnl5uprH66JHwOJw_Cv0XjHr60dq7NjoJjVRh0Yobleu5M4_FSgGjTFHust7Jg_L9eMj4Z-BUSI7D70OEDCw4eB0MtJJTB7Kcu-sz9tcldQVHXuce9gsFz7ZDPsGd0-U1oZhQPu9NOnhHtiV3OwvsgiGC1FTPabKm8xmLc6W9C5UnS1pZX0ux1quUCPg2DAprp_Ozn-f_2-cQAQT2ozeERuSYBwdSOaxsFSItp9yDJSyrkCmwvdVSNXszp0m_B9Dyh5gPcYYo0WXUyMX4sAUpLjZFQ39a4TWcmaPRn5mW4_zBIC4BdTYnZHrc8tmxZVeUI260jl3Wfp8IT-Otn6DhBoWh6XnbhNsg1fuhaUuq-B3s0SyU2C13eme_B7nDqbVWmDEWjW87f7imhLbz_C_h7xaptWSp6pzkdscHvxuyu7j-hKz6arMNGdrBYrmCdnLDvuq_a3xsPnEPRRkk4iumIeoJpLo8BoAIMiNy96gnq5mxgElP_dOkGwRxQmuXGd32lykQeniGESZEjF_jHBhVhQOfi-x7Gh4juNV5Lr0TiPIFH8bN6EUSP6RUODJNVlbyTshgwOb9lEt8IP8TQNuWjRj2hu9dUbv0iC1tmSewLeOgmoEQPfEIHyKEJA0y2QI_-tjJFNhvDc8UtiuXW3dnng8Nprn7h7q6mMe_PSdI734bBLIT6qqIGDpl7tXo5d4yehOwc3Mqj-aqINoFXwc2uGvZKUD4cAQUPBpye5Ezt8G_14bHGz7QmtA8I7d_V1cpH7PA8jmYykJ8XVbrr_NMxjcK0_Jbw_0Wb8IOBuyOvTweDQRiMAUKCnpG98b0jnVutijgsq-JsXVBxcX0Phk_xVaylrXvhuTOjODaRksNJlv_s4-PFdIWmcsNq3K3L4Q-Jhy9Ts54f2W_-m8SsvNlXSp6IzKEBi6CQOmuHs1m_b_6VxtTgzZeBjpGAIVe-zG0jJvXkfFZbmgb1Mi2fIt2CE49ku0oLIFnOc8hI-v3VzjPF-TqhE-UqJaXX--GX0228TNO7DBruvEuccRsPZdBETOW0ell15OGWaVuWRgGYHcrBOSbAFHLsnmN4SLA9mK0RgeZvcW4vd8pRfZm8gQX6Qdz6zcLU83sZseCn7SsVjjvktZ93l0XEOLoVPEeCG6oqJWGM3th60vVMsDq5rDjlqDG1z2BbhlETaQiKj5cGIIQQsRhY3Pi43UliZtZyaYYIsG57rtil5ggbK8M2fRad1tnYZwdL2Q3abXO7prigh3wsbEMVa4S01UpRA_qU_lZwVkYFtrPBn-rxuOL7xUw-wwQHUyf_sKfKij5T3GWbyzovjnLwOcypGJHiA_udvBaSL-OvBTZC9WIg_PQY2zjrQ1FyvOOMUgBir4oOsIj0LA_svKoUF2e2aSbJQPJ4dWxkVgLzw67GVkXMn2dacdhDPZKghCyij66wpTcHK0Oz9dugx_ggRkFCxPTLS2Eio4GbtiifZNt9DhZK7rFsy1Zh7W6kM2n757c7IRLZ6etAv8uOiDzNGq0GoXLPUW8-Jn_woV7vErwhFxMJjifP1OyMzf8Z6PZYCPJar3C76wwZdHJrdW1Y1muQcmdCjKY2PgzhXgUGyIvuKRMU4jJm5sk0LVArR1u-uS0ZlxnQrCv8XOXXWoZb05hbVLBpS_VKfj0H-N2wiXdO68_Z1yw7-rztukLX7NLBdTXUgCo_Ksq2dTbEQMG99xeifEZYJDC7MRiXsK7IWg77-Z8Eb9KT_wzwGqikWo4iKOVIU8ZMCEdp4b7s9F83EGX6ir3JIlJX-S07tjz4B1MM0qYFKCRR3VPQsrCavMvfbXQhC73wppUO18bz1NMdGsRI3s14oR6Y2HR0uTgMTBFjBTWWQUn8O63LlnLbp-o3-ESkTdHeXbiJAELTQzfF5hAZnQVSjlSrmi3TNOSlktfzf3IszkxoME2DHS-2HJ9700TYJ01ZO5G0CgnL4IEJ40sgqW5ziozeio2lKkoTDfNQR_8E92cq8RTAesyloaYxTGVMFpRwBoPr6d92IEQgtG7qGC3PFdo98FZRrX3DP5JiDwMaOE8yY8u-DanJi27PF2mtu4LLXBQTWIT_4gDytRWuxMSW4Ct3TnnlNJk3-YXzndj_rz3Z6Q7qOoCPSfXnQUuw-Anp-UU8_Id-9ByegCMnX7F4g55sYcUjwSqIU2RAj6l5kMYAFdetiwV8rcz7JuwhUBbE23b_PSOCKXB0zwpc2RcPgC8Fi72bLvhm1VQr5vyZIhkeLNMzOrrw6dPCqzzRhGS-3DYxspui_re5n00rzOmUJKEPMA84fELtY4P5V2rw9oEG5E2asUppZ1WzVT_xbhUAqafYqk4tCnkNegU8CWJ1KmybTmoIF01pY_iiOEHX0IUBBx9XQgLSyc_m4zD_cfuqK-4zd8-24UmWQJeZZAR_c9TFmkN30OSNx2mj2YiSdeRYVKqyNDXTvmbQkxxLcWX1CBZ7bw_SiSbTgkQlVp0e6yrt0whfhj1b5atd2pClUcQ6S_RWngm49MB0J2SatIJiOTNauEoxzr0ig-Kr9TERrq7uQrd7rpp3GsVVrHJcBxR-DHZM4Mx1fcWg2hNryIHw9kl3nR5QLxU_ybR9eLvEw8D_WJR51n4TOdOXEAmzEFpQ5AkDvNkWUifApBOuR4HFcIACQxgQBNnNMqJAN8JNB69O0yQa-Vh9bAq20M6MNoBvQVFgH31FAHkXZDSPp0corDgGTI4Yq_7HSBGPQI7-g0lT0NXk-suaQnv0wtHMYqeAy5EDorZoy6kEjQJL4mbyPDl-qnCKJk7esQe-AamGQxsWcdnmK0fccV2icargDOViJi39i0gpfqBK21TB-kUgSpBRVHaLE_iQu2MkMI2Z5-IVc_Tj7RBB3cu-6rLyOgZr2HNnP1ERul6rd_KlXGhwBNK2iXrAMfVijdRcxK97jWnFapE-McLukPEkcAerPpVNAiEJDL3PHGMUxHlcU-g2W6OYJQZ1Hd6vUx8hHhK4cwB8uEBq9g-KTe8ZCk6rAQKugAf_hHSVyF3Tw9nGWm8vh7ZapnwToozSCmJX59pYmOGaB282rKdCy3xgPCtHnRZXfsIXSGx6uITLxVHlzSNTuXQDDUJ4R-Ov7y1WQ7zUC8UBVHuw5yxunnfsO0EWKjv1drXNSJqQRYGe6FhEKrDwbgrcgb8zZjyVyheXu-RUwCVQjIN-EIAUAeV0oF9ozAdb6qIaX9z7Aa3gqxXjBY69k8SNKbEsP4wbLqK-TVJ3W5kQDB8VGpBPAb4xIVFYpLY91WKZfmrP2PT-6hqLrRkqpWkmx0L7tHP6dBDUgz6R_XUh7VHFZ5KX1l-L2Tlq1kL_snm_MOuPShNIihr4VEg4f8djldM9S0GH_F1hLXkC2W6TNBQNlRAjBAmrzN7sgeiKbKKuhu-GEjNt88fJyi3JSb8tzOXrWkwvrKL3oAaAinh3SbB1xNS-td_WY6j6-5jaCgg6Y_IXIjqloPxT_SazSKt8jUmfINzMooqRxCy122LCKPRw4ClUgiW5hs9kLYVsGG7ygDTbpky6ppfnYBAIM3ozNOH2sqe83OWb2sBmSO6spqQcE8m7XIw-VVvWqtj9i7Y82tIA4GygyNwiEoaGed0KueDTaV7qqFULGN1HX1HHBIPrqlJ8qVEri-CK5J4hzwt_RX2sYc_bgsmucued0BTNJgaOD8EmU2spgv3W2YqnOoS9yHTMrCaXOaYAZBkFN2nyladQ7wsIqHLrCrs4uyGnn3ybKVsEijp4OU4uXJH7iE74AGcoC-GF6LlHlctZS6opnq5QrLgzB1iT_W9bxkWeqPOTHSa3LpHq3qKhzSVJzD3o_-jdoT97Rqe99R1g7sNAjKCYLreKcRFmJ6FYTuyWlYR2eKc29gLvWeloLkvmXshsZtmDI-FxxrnSMuCmO-OiI2i2HuspL0tOtCDtERyfCVtm8nRUjk3a3Hdvqims4Y-C_yy9eDm9jMYzN565Cj5NyI2kZxE2tN6p5_WMlnCq6iUIEpif8UQSvLDN88hBEoQFMIsVypPWRJHN83hxHjbN-rET3HE5hdNThrB05nZGVvC0muzT8af3obH3AUGMeKdunVHvgkIRZQmpCtcrGVKi3sr-pDE1NtAqYDTMu218tLdI-1xOlCfUNLbp6Ib7KLb5nKRiH3lVoKy_ar_zLjhXGvJlYY-OtHi60QFHlz5w7sdVLT2jmWmDMkAsa02a9ardBPbSdsV0_bvlit0zfKBi86mYLktNbvoUK6ACEOJ-xd38jUJVQ7F4yYHMYw36X0XCD8nrTBdMdN9rbvD4gVPK3cXjp7nm9RszKEM2mBaMqbssSeG9AUKkEoRAR2aMGEVLGQjpiNAvDLtVHaXE7e2XggfHET9Y531YiAitDogn80vT9aWJvkiLkTPmICxQtGw7Gvk7S5YJGmfbwakhXSZIGaia6H--Jt6AGJpqcpBrFUe54k8rM60MYLdBbpDCWdrJUwInPKySPUhhWZ4sLyrlkPshHCUK7vWcA8npL3rTZo3gCc5oSelhhkaoolu4Qjnd6b_CPhkHWXtE62Yac0ZmK5pVm7MaKjA_qh3vRKr2h2EBFbdORLRt03db6nyMWkQVmHeJnDmEM64NHLI6OGN3JqTreAJXr1g7bEIXHasK0mfmGGIlBvKh6U7hN_HtXl7jyB4cWq7tznwT_OD6ncW7907iaQIFwIzx2CaN64mQ1d7v_lYdIcjliyMDkNeJWcbQfi1dJHdsKjJiCrOQg2_KhfVVbkXy-JAirCkG7_28fPuF3SyRlU48UWT-CFNsdaZKfO13ukNlWknSXV2rAgg6rJTvz_Is9MjQS11F37zEOnB96orFOHc9FbOQsutVHLiUr5SI_OFxews5IK7a0ETMTjYQaOJs6as2invZgzhB1pEXfZg9EVaTRXlRcwZkFqYQ-8N6o344_tg4loe5Iq5pOAJnc3zdyLPnoHzge5YxkmKXyCPAmcHHEEQ8-gj2DT7QLsO8Tff-G52zxaWIE0YPgBsvjSeUFXsLdF0pGNgHbklX-I4tIOgYXhCiUa-hw9HCCMToamiA7brsPGLplw3Ajh39La149ub4brV1jDtKcLM1Cf0qgEdEfa63uWslHn3NvdCLGQ1kDdVsoF56j0Jbr-JrhXf-jtWlhZJ5k7Jl2rSAn28BsY-j02ShZZ8tL30Y2-KaMmfXdGu-onKwivlFsnXdDqaV1ecOWZjn3hCuCLrDo8klA8H-oCU4bZMHKN7lRfQ92ZkFBSXEuarH0yv2huzDtBHlx4G4YMtVg4759xmUvFqkVLA3STpRm3zwE0q8tdhZWSCokP0fl8DDcnt3b8YQhJSdUMRev71n2-bmSy--agwFuuXNmi24VpMJ9MJgaZGxAqrlaGirVQVaBms1onU1QglxbcbYBAIlMgvjYMjAg6Bb10CzwDU39vjFhbXnwuBUa6-A51Hjh7wUNN5TM9EJWflYmHV9ysS_RztL8cTj61GVGskhe5PuUpoNwB7ASM16kuiS2kKTPFal5s_igG9bsimfuaGV1ijSLWovVHgLKKc7C7yFDTzj9qdIjW-D6WFOsARDTKCcpC2E7Ce7aAAWtq-VzxC1N1RdtBw25ZgqRgwNBItE9krkZ2PrUVq6hY_G7Jaee55MfI7hUzze1KrYz5U0bwBW3xMUeaj9kb_xi2y0MIx0CVvjIgI-HEPk8GUleKbVeeucgV6Po97l4i6AHorgeXWtEkd5aHipDPYruYTXNVJRIolIZRr4DRpbuq5JcN9nqCAe3oOOScjvsAt3e68CMEs6hqtGpXo2fk6g5m9SZMWDo4_X6uNAjuW_xKqmy_9vMO6Q-NoExLAAluvhYhodTQ-776dfLqvF6mV0ZlUZL_WP6ypgeUp1DB96W16MOF3v_hDkzAgzQCNNlV9H_iVoLdmm9gJNHaZfXfdswZu9kIgE9slhxy73YHSAwaDu1p83oa4uxCZosMVc3lvTyg9jGk9xRZKmO2tECCrJHdAyJsN655v6Wn_yXxucYlq89OAG8qsWZkn4gtUVmu_Wt2uaiiU26BKhEf_X6-Hn7joSj8XrjwAEofYGktI0S_vl6hgJ9HtPKC6Hy_n4f1gOtUllO5OWi66jzebvoGMpZmZci67YUZsXneDl9Er1_7u8TNJq_dCK1QbmVfygvKQM1rc3-7L4WkAo4Je1xL47x9xmZ_qz9ftjAc6EjmvejL77gIk22hES5-5KMYY8jA-wxr127POWB49EejkLnjJ4dey4JQ686LXWc1KuG26zQl130SlJPPvPO8Vya10TNm5W31xJhOTehDHzHieOx3FagzBBPNyswA1tmZ2hVzMe5_MxAJGWmUTLXAK3LPaI7wwwaLn2f-Ja8jNi0dT5inRJS8EwyaiMwnuclGWCAoVKnt4U52bpjxkOXHUM387z2GZsWPg3y53PE8vf1_xz66Tu02YdaL0sgh7RBEHJ8ACKaAWaYENgcoJ9oPzUlTAsyc8qH_3-EvoASUyuUei3FpNuSQZVzOwOCaSSjYxP5JeqlmoMt41lZeOtZgef0eA1NwDgg4l05mk2zd3G5MsFSjBEpJ_wPvfGVZMVOUT0NPFf8lIdkWlmT5ufEZjcbXMWT4G0ROpPBECegNVXH0DGmOa8Tlw4GS7IAqG7K__srDAvCSpWbqRgh0wnyTABqdaHBCN6dHObVE6LI_eOcXzf-BhBe6Qi_CfZsg8JI0q4GFWg4-IN1XVnm81ZYLWUZDx6owiv5nFpgv0KcBGh_b5zTDAiV2C69iu1V_0bXLZL_-_GQq2T2uZUl5jkDEw1lOsNBRgEX0rfHEDTXSAoIjssZcZCJG8GqiiFPb-7hJqp3R8DGtB47WR5tNOR1rjCvrsCj0JZfNuB4HUucqzmpDZkB1i6gH_MofxkQS9MqTYIT0iFzF0a3dumwW0l3WgBdiadwDWs4uzdFyQ6rWgJkyRMox0k0rzbFdGuTDz0j7Lx-NoNTmxovpUHv-_81aY5s4BHpA08DTTUUjMuv3i2kxdUXCFztQjgUbNn72GcScLwn0w2CG0yDnSNgufcWsygV8EnKyVRCIwmfRcgMNZ7WITAkPDmCQxLf12c1Qg89hmlgOLQEvLgEtiGDWu0mbdIq0tAsz8-k_eEXignVBoEmU0PPbcwODCloo0QAfD8sguNNsU4sStQpDRE7arn-RevwssD7QcD26FIiTa6yA04J0ii863VPlM4S7AB2EJ_87vfgoQQsjhVjvH-C6PkOFL7HPd8fCpSjlFpTm84n2O_7jh2A9j0nFVlFkvZU1PXwxw5vSsinOTPf5XE1cJOZDvnrPlW3RlqPCnBVMv3Xc9BKB7ip1WfjgHkVLhXU913eQHgBy5jdz_hdgfDKm9dzW4Ih-4gxvVuLvI-lq3Oy8dRS2KMu-Cgoho2hc1wRv4DPRorhvQF5Wq9wtJdMxfwaoabnDTK2XKwlGD-1UEHDKNHWLBX1OM-j7zf6VPZ8Oj3o6b4LfVvQQtwDuDCc02-fhc5SltiymqISwZysXyFeQfZHcPExtUxYc3mpqAbnyOxx9YqEihibB1jNHbjdeU6pZEuk1iQfCr5gKItwM3OT6zDwX1noK0gZkKuNB-hmS1g9y5FS-3D5hJpOT7Isk_GJBnefJKgMAGlf_AlRCt-W8w7XzNrjei7uX29YExL-Tp8s7imn-uVFgJNqEJe0OWa_jaKMGL-hXfxU-2A3FqRBLVJixZ2rXegpJFoRPhWfbiJErn3V2LHj3QahFsUrsNCgjKw75YZwsja3HV2WIm56z2qyZ2taDW-vreJSTgt4Wd6BONpyLEldkZF8ISy2g0eTafNqz5u158pj3cTwbP6c5FwOT6uqvB6a4PdwpKgsyEK9YCY_UB5TgxQxY1E1neb7P1gaMWoXFti4mo-cVArPcPNquwy4GNBlGjwNGECDnhyoEx3b6122YIQGIeru135Mm_xZeWjXeNoG-e5CFfHsj4BDyKl6iKh_HQugwfjx5W_NCIkU0zpnPQDecdNp79QreV7K0EIXNTnygnxJu-B2cpYzwxWawLF9lJaFiwnnPFSoVveaoh1Sw5S47LtizzraUiJsIfPGSCwBjoUlDlePFQeg4x91EkBacbw6pDbgS9FRp0xizA-tS6pjhGbnsPcb52axBVQfkCn9TefowXr5t6ZVcFOmDO7Kkaq1-sAijMS7tgFscKzaaNZM2_08l9H_RTpGqhERjjdwxfuQ_pQsJ69qCzKf7LDfAdtUtXHFzlmHKbtI1ndgrrEq9CXbbrnFTuPLnwCp6ElIkPPUWQZi97tcEq-ejOdHqE80P5KZ4LrQakG4c2mhHDEVWyAMlgyf6_hR0aBHselgoLPNEA36hCOdBH03fmTbT39c0tHc7_Qi6yk07z7eXbo4-wfd1MeU-lahRGztrVxsCUiceC43-LSfyhEIwtHNYzLgR3ZrZpnVdz0oIlUYhQCFuCVzCfRqS9NI7cqYeGXYrtRN6KyNIbEbsDMVHlv4lekYRkxkxHIBNUPBJeyH0mhST5Df3oRrE1wvBXVivivb3DzIjXUn7kfVxrU1f0hQzTlTcl1Sgb33UN9q4e2gu1QepRM_R3HTVRsClbPyeD6TV8iv35sfkNClKQ0XfX66vQZm6ecYNvQOt80MIZoSzdrlYJj3ynvo2eX75uzkbDg0rU7f-kiQEoPEPigA9rOoLkWyOX8gyfNjYCksafD94WTp5X1-c2U9yeZ1cBW2MA8vdqe66XTSYXOyrM4M1S5iEMJCdxby0uuE0MqjvmJQkpAgu_avKpjHQmcZbj2C3Bj9B2jeRVE0qgtiTezOJJUtvIm4zs8R4cEka3-g0WLJXO8RUzcyNc5SpH85Yxw04lrBLWxQia2jJvqJvD6NZZl7Q8xbiDN5Fa9K5A2mEHVrexTJt3p9LUecRvNCvA_KJhG5zFdbnjc-75996xFcSkbrQEigc3uQlTVoZjgbo0fhf23zDzCkSh4L8ymy6xVASLJrqxrGmsGDEhEVDQMmGvI0-gXtImxwGd9GN49mez2sxctTuI6rjOTIvC4X2AhYFyks-JSCSn80ok8ox81UjCzmrStKEKAKckZFKK5w-IlpWtJCFgnWKKgI71EfsOrGnlOR60fO5GeS8aly4tvNaBwhDH-O6JBP3qBIRL2slAf1BJAtYbuHBHEHzWZhpTxN7PZIJn_0fpPFiXgof6Xy-QOeH9v5OZBQPfwuwAj8w4osPN_Yj1-c2H0_PBLM3Zgb4fXYrhIfJtndA1rJ16pME11gN5WnFPjD9-iuV2IZ2_en2IByJlDPg7knoMof73fqndLaANQR8VjKzvEw5Cr-0_IU3pzCad1svVnHnVagbaJdjAegErQpXY7bmSmK6gN1I9VarYyDk8jICqKilh-3wna55fd-38RPaabUawPTXGkLJd_hYEdWxbX8gtgpvkNrK3P6jWvQM2EJunhxZF8nnvdkKdgpDG95rgCNPXzwDwZxGdQrY6s1kNj-zEPFt4vBxkQZi_m_CDjdZ8jagn_EftprjMR0XVL_hizuVflJvl-onDHl0Ab25AM_To_om-41fVQ6I2IUmM0tZpPULF6lMB4dt_9SVqPCxvrytWIB4P0EcHvYVeWNRSAzaU3amgKziE3hhVPMkf9xVah_cIKnZFRHaRg2boZtDtMD81RCwBXRkNacb5o7VMpgYzR4lAi2dM1Jzk9lpiZLDyDHDCL0Sjr0WJW7M-cjR905GgGlRPiRbN1XG1C7L2UoojVJi2OlreFmmEyRV6Y5JLRJC5WtfpdsxTf5Xhl_xDWyMFYcHxqCjUXpEPZKBdywbXkJfwmg_qbZlS2gMs75jXoFdIrOCwwMPv36PnJr5hoNPkfxvEILNbubM0Z0iYgpwtiBzI6XeLoHjj7FPNefC2KtVw6KlixVy9drZxWomZlKXIY10DPhUuIfoHlKM4o-G827Fa8PdcppeJiWuStSyeowkQjiMQe8JdhG1ob6OTs4obQ4Bdxo3CgFNZeD729yyrHbPxFvHlS35XonMnzdd8OVExM1agqscsPd7D3kABURtBqn30WnQh041ABkCRud9gA9y1NaNaPe_q1FTylr2ZVXWzm7o40trX9nBbv4tR_lzja5mBTFOq-MPflSuwkBo0vFE0auOqLOUXEqrD_4GvHjbX_-KpU3wnUKrj75wsriTYmttpnAt_14wh1DezZLzMipjlVBiFV11iMf-A2j_GBaWEmdqQrYkH-DdM9nyS6qG_jvwG_oIq6ZvzQYj9Uf3z1Ttgq-qwKJnT4JogvviT3EsAOjAO4IM9l3q4cYdp47hlNw-dITIAj2ZgQyUj1e47IifDlV5QkgtdTHEtgXd8jrRMmt1xBJ6Zx9kdRUvnuR3WkRuP1-sn3BTExfH4lTDzRtIBz3tmvz166WoDeLg3V9QGpH9YGE7ANnfOxaPj_yZEGEMe8MWCsJ7H7f-zzxy_twxBW68v_2q4S2xF-UxaGw5xcYue0fhiR8PWoJPXzJPdYXJGBGF8eYu0F6-qIRLvci-oEcSs4xONd5ogyvJaNwru58PwD7CKx9Gw3-yVhYrBhsNx8zMTy_0VZAQrY7h2I54-HDWT2GstZgFkIM1QUwJuvxDL2MfACsw_3Xatrphj-Z8slCncVnz1NfNKkfEkk9vRKZX3GT-7hlattp5Pzir3WINcDLhpQ0sVEN9Y79OlTWJv_8wp1tXQd_FnFQ-QXH9XYS1xGmdMFqFMJW24FUNEQlgyTQ--zMuvywophnnimuBIeMN5FRyFLVjnYP-EqUiQTzTdK31X2vkiJDE3x27e73a1tTO9-kS9AGl0Lgc19Elj_TO4LXwLHXqpmM1CjjFImMuPWdSLYoVfJnkzQ81NfdEPm3UTv5Zi7ige-8CQ8MAo2q4dZ0FW_4GJ-c8Fv9eOVn_vorb4fgo6MgslVXTBTArrVgU-H6AqmFVxhdlL3N3XEe2XiQ= \ No newline at end of file diff --git a/backups/backup_v2docker_20250621_030000_encrypted.sql.gz.enc b/backups/backup_v2docker_20250621_030000_encrypted.sql.gz.enc deleted file mode 100644 index a16f5ae..0000000 --- a/backups/backup_v2docker_20250621_030000_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoVgQQzH7txDEay3ji4WqZTIjnNQFOQXY3lmIYmohzmGpSloRplk19Pnm22DVIO24_MbyeM9vFDah4cxxyuLQ35iAF6ZrJoxxvRycKf8sg_UA1Fe1nwnjiLcK0Z78HD0faB8ClzkzKa0gSJJT5wUNn8aHY2k3IWdLUbnhnK69rH0ZtP7I3FKxaj4Tg4YUpir-dSa7TtuBHZYRHIVf-bPUL3ysASVgUWjNMUqBDNhuyUqcVXmEM_ASBHfeBjpz2lCulwlrKqwqNdFb_dJOBmmEambkX9Ktk5Y_N7G5_VrrdIokAAuf4pSNJSuzTmSnKsoFbqp6KW_wgXC1UsRZBWBQHXiOrc1XOkLWsJ2HiIQgX3DrRwYkxWc8CQdVtNMbEMosr5EQKVU51QWrUDj7N4JfodgFwPfYBn1x5WrbO0-0m-cBk8Tk-tSY5WLT9SensS_7bQ2YhXRP-PQxMyjJNaMb9l3GyY33eddar34v8qvsYrh-FFVPufkt1XLGr1pc550QfYpIR7D1hLPTrzeyYkixRqc-T-bhG5GohGQoboi_O9-o9hnpAeVp1cmB5mrMT4tfYxFoGNj4stCoOhk6JoyRAUy0fDm5N9Pr6RPfDOw9HcykB9Tu04JH-lJjnvwrzREmPRYCSlrD6KxRd-3Q37XzeM3N4HI9jp8xzD-WEas0m0Ts42gQ-jjV-8ORxuABTL2HKfAYDV58-mjdHOFD8Rzfet7kRsusZYF7A0GyT18LPsKCRuJ--9qYtDi64tQ3_D3LMKFsqCnlXoV3FGHlOSRytKpACA4gqzqnVI5IxulM8iiEHejf1XelDHfD5Sk3k8fgjiq5SI7XIadrmUj-icT0A91PujNH84HyXX8slzX0mSijfaZxZefIzymwQqkUloCiTkIaOQnzOHPCmn7xA2tMEhCkYj_M-iyX3QArQJ-krBBjx2bJaFSPVd3cw-M0Dq3oj8GBWAJyfbScKUQnnfRf-aGoYQu4l9L1J6X7o0NlOQnpBWzRRTW3sWFYAw9uB98QP6q1cR7e2wH0rhbKZi-_oyRe63YRJvuGKWkigvfXNiXeFhm7e18PRa6mqqXJ_ClqmQi0JBgjyBv1_7JGReUqPMZZ1L-bOuX5N408fBU_0DxJ8_V0d9kP6vEX0aOb1rMqjwutOY-HDr77FG9f-b1i_j0f7nMZxi9_iW49ZoDHYp39A-U4LHcpE0-ZRhq7tcUr-NQttqBg-DiV6oghb4qyo3v8ueG1H2sx_fZcozV_COZO7PnG2YtzOFXh-QqfXjIHLDJs50CUN-zEiCITPBFEL1uzKp264Qprr3541OTULhDMh1JFXdwnA5MR4yk_S9CwqUA8RaDPiWDSYu3wEojcKiB7KuVuYMkZgli8UC6Y56mqy3KFcmIP3RT7Y1xE42iwolJIFwsRAnPacULsvu1RL5oNE_xJ2tec73G0vkHQIrVGa16yL_dnKqG7vJ7AzAYMgGJVI_Xr0oGM1EL1ADYAleq0fdVv9fRB0nXAfyO6t1jPkOn3yXLbzpKhOOEHhtRjTmm9oRV081-Rw8OO8Pg-8-xdXozRHGtzRCjiDnkV5Djc8TIPOXA_nMmsMvdqBe9YeTYKZqf3IznvioG2MHHNdk6_Uf6AUI3bSuwjQCw0pwsOs1WN59ht5U8T2pRZLb_mMMuehWz2rmm39CCBA4nkPZypBgKduU6u1sVI9Vnf03NReKUNYzMxldAifwtvLa7SivZFd-b7a9w7pLj8xbN7F0iBKC0f9kB7D-GM6ViKzzIvg3hlQ9cEkIqUV7Her8rkupA7fwetqXEkI5TUuNNYGqKTLnHIev6xeioDuWTQ4F2l7BZf_sf6J4rZC2uYGNSTziXsdrXJBIc1nItuqe3ok2XPmzAbKSV-iYzvtxsbNptl0c_jYMgnIm0c8GsxllEaRnZ7321hgi5H24xhuQ52BOJ4B2cqKonJgM7FHgqvdT6__76UJMINHfiuL_Y7lpUZGWNhB3jy4n0kfxai8bdz0oLlZ0bgyPqbWsqjurbRX1bskhpZOIg-xgr2Or-tYwIrJ7NNSKoxnnHfYKXB503b3koL5zyAw5xoa7rW27-W6hvvqOEnKPF8Yt6mj7QM6b5issRlc9PHIR8V0wGgxAOrde2rnJo0wAd84sIna3U3LQVnLcsC9nMQXEBz5izZnISfAeSpCUpV78gImUjoKVG8jh9agilb_N-gCjvWWB9g2AEwsCUG0nKMShPkIS5vHU69_2EQPshiUCr1lgxufacamT6YWXbtUtvWWSgc4l0IY1AGyb-qaEFmzOoB-UDzm3607zNGdgKsaTxbcZCbDz_2VzsTu2yRj4-1WceMau0sqBwmpj4AWVRvIek4l6ZIkexEd1vZkd2P81CFjJcsEcXwQWOLN3eBUosf9TE3UhmKmGCP9o4fcVg9LvUB8NhqBvxj3NOJJK1u2_dsjwtx06kxSfXkts6SPh8LmaTN3vJJGGfcPVRht13tnUK5hXA1n3viBvyy5qjdwhwNbW7SNoI5TJsrL6ZLrRHuZTmEj5OjTrdIuFm03hVH03meq4KG01tq3YnH6u4cqwz85v52CfuopsttGrjMW3f2yWWqYemLr3HPQW7pEEx0GgRMJ5Uo2Se4z0LCBM86bNZz8MDptX-R-R1a_MjJm7-7lKl3PJLoCXK91or-MemQutrpDS6MXDKlweUyDSNJhKWIi_h-d5Lds01G1j4eSiDHnuGp8TJ3L_zyx1QrVj9ypMyPzIoUIUELrcaVmZnQu8ymu8dkzy5jbgbZ6ktsUswNslGbLbDFHFly07SygigeIW8IstJDwlOuv4WOVnQmkl4VR8ny0PgObtMTlro_OBya8fVBgMRS-lHhlx8mDs_OHCQ4WoMZTYdWbxHPqBaxB0UTa4--ZwlXd-doTv-jJrk3VutPY9uMqrx7V489HFY_YhXwAZq8IOB89MahpeaI0yAk6jIEJftWdmjBhyTCDq2aqHLP2piU63a7Qc0RKEGtvRao_s3_1jlpTQ_Vj77boGcc0OCsZBp7zbv0A2raIYcRGU5VeMaQuTsLYKyCj_YGRIdq9boFuhxquWRw0o9lwVdjya4g_-vsmTFAV9yEPYA0v0TKpPQI9PMF5WJZjK7LlwhrezYMZN2cJPeTdGLv_lASj3nP4D2YKHoXEPQlZ8LLyIzw1LsKGNAmmksGhJWL_mWr9spLYwZlgxvfxGh80jN3H-ixIhPo-s6r3dsOXZ8y97AP3sl3__XQBgkc1ypSyI4EV5C5YCABsBINW36hNN9cRpajHmyGPCjshzKV7POjpaF9bkvfZqSROEuvlb4qbzFE-jIv5tVmVuvZ-pEZv0tjnVBNXJE76qsZeXH21jVSp8HWSNzG1VeU97rwqubO89yDcwNblaJ20lqpCGRQkkqAhfRt-xEhlk3XsvPjcVBctg0zIcM_dhfVpCYEj68VmU2EWQh-wMMTKcjgcb4x7NtZ6D0uNLXROlMvY67u2TuZacnyPZUCcRdo7ouD_Jkbm2lpJnW8sUAcNaznZ1wgrQy0s9juBAD18WG2Vx9ttzmzRBwmN41nXPhkCepqi-AI5dmdAsgaM3yKZ8JT-n2c6p-x7NIqeJmjSPcrXe9irT129dC3SwuZLHCyUDgXX5Kr3YIlXFfu5D9cTVAFYzjzoq_QfJGzYQvrxTPk3m3dptQMUlkFD-wIZu9_PSUllTc1k_HhAd0bcGcd1LdE0j97S9vRtNHSSdTnEppNes2LHSAqdiD0rfLHcnpe3jj1qHxs_VXAIMJtEMslRRjjc6TI_IvcjRPQXDAwijbPzYO7W-dIR3KmwLZf_NC27TKNda8IDwtWUBINjxSLJlDeoh4ER_zyk3i0ER5qMnftduPsrKp8-8RQHnTOXVcofxzdkxjjAiNJFdV9c22tuekLUy0bvya-KqoUJWTvqhDfPVFu7brhVCZB8CUF7eI9eUETj2P350dBBpKG421w0R21MmZBFeNhpCzpG6bEw1oEbsySqXWCPWxhsxDzc-B7ifBF5i9RYEsOugQ4UDNE_V_uaCwZYTfyy7c4jWShvswB2nrXs9D1ZuVih5F5TVad_af1iO2r_xwaZ3i0mY6Mdwe5xhVGOAi09vr3oliTwuaVsrTEPVvfiXzIZC4moS8tLQF7yLK7B2rpj5-qVTZ8RSzIFlkCSBxodEIk3SPNL0Q2jbnHsfaL-sd4Xb0MoERJLkLCyzeOJe_CtCr2YbKvI0nGH2flIqV4I0dLHo-5NMDTA4e9JSjOTM2alNufoj6JjGEKjQXUukSq6xeprkrcnWeevJ_piRafr5pYi-z50OUPK_-Ek8zecUHzNU9v1Ti9_aQlfyHiqr91_OcEi_kc2PAqgvwlMTb9mxE-Zi6uEMiDPax6B1iA6iXb_PZvBbxWv8p43dxeqK67TFnsyMiZbrtx5G9hZb1UnpaEB3rhNvk1VoEQ8y6E3IZDRwvSnt1CXycmYmYFEJB_KMwofdsUqllnRUpKo9r_GnsaXuNmoFpeqJ7bSCjWUQRhIxG6Wf9FGZNUQpnsasd2_4uGN2BQ26i-fVKp7Rrop365a2YVj5cyPDiuKNEGAa1oEYe2dajsAqspneruoXpe9XhxT4oxylzRloRqedadcNNpzk-9nL4YfCkAOkPv8YDq_JrZ6QKq9S4XihVJHGo2m-mzX_2rR0Xq3cUOdJztw_WxsVbXJbU8x02zwadUs3rQZphsOT5YbofPU9kytPZZrtOs24n9UaUJUWGEoYA78ulM27HzFAwUiQFm2wnUoKTYlXqMJYRnjThSluNdu5woo0NXj2655tkvimYlpN5erP-DfvLyRCDVOr11kuruinHHjdF_u3r9cTI8xEDSIIArRh1KBMf5tpjbBZ9cR7K66355X9VPwjs04fbGS98dKn51YNWm0NIZB-i-cEJbCmWb7nU9Ok94ZeYTeL--TqkYOs8-nz979ss9hNnljlVZC5ae4-Xh1527-lEAPlDD1ll5OqdiwYzWdeji2JiDSAKvbWzxSUABycZc9hnukrXVEqPhefXAvYDQVTrmwxv69Go6puZBjtCYpxOgjw3Dr4_vNeXHgQAnGRYw9VSK_aBNPH6UWmsYafSCfNJRGQrtqgs60qh8hggY9j3u4p_P1e7TRoBaoeQMUXXOtPN4YIfyDat8R02VENfcOEX_9Wlj9iBAICqmT_boWaF5aeCfEPkxT2qgIFF50QpXF6w61CLMNViEJYXTZpgxrzkzQQuv3Fu9Mif8hbdLiMWx4dfnOVozfPspfwKHhbh9uUUvAzk2U7CLzB2Zgxsnxmd0Xj2MfUa9TNPsEWz-EtFLJ3RGEVMHCbJMkD0zRmVMAjBzpv5nXUuoWhVMBLiuxaXtfT4JB3csV0AbRBB_NnQ30HLPQ08JsDGaDvq9pfIvZ0JO9Qsc04-5WigQmfRRs1n8tZ7UmH9NvsbEFsj6tTynEksZwfm6IXwADoiblWjz79mYZCpecwnIlUHhdHf5RWWnh7MUJRwjJ8iu9DEkombbiWK-TTn0d9vvlPaXMuvw2pVWEuUVdhu4gp9qXs3t0tAuGcK5-8ldNOaeeDfPB6BbpAky0pI0DJup5WEUkLy-bacr2_qyFPdvXLFuIMTtzAkN8hs3yOTyzGOhd6Miu1U_SaYR9vp7BOgkWBNkLJSME_wSPr-SIex8o7fAhvvLUc8GMBDDqeyvWy3SX4LfIvFLIm8igEJaaZTyfqW6xpbibYZthaCyJY-anR8pkl3Z4KJwkj16hEWQrjO0jYFUWo-PfqRwo85L6PQ4FPP23o0QjnXa_aJwfD53V0_W4CtNe4HsgcnpdBxo4I0WQsSOS7ODXW8cn04hIPNQN9jffxi6-SdW3OOBhX5xkuNt8-n9efZpBDnp3rbgAor8uN2dfulJB_Nv2IY98z7ykfzKJmkPThlt0pnPGjTjaboNcEnQ4VtqQMmCIap7C-aZqppAA1LVGLHy9Ufj9ouKwYJ6Ejdzw37vQdsBANTMoNg55oL3DdFk8TgkEiRwWCDLLvE6OG5DUGlwSKLFbxL3gbPUUT9m86XwHp5xwLh-PU6m3WYNPs30S92WIBVFUM1fYP-4GPvP0FeNx6EsbHXgsoixS81rubgeMb8a_vqlpdhCrIB2u2RMmdC7oAU2qqd2_GZftKliRhFTFS0hfdqS9ysSxE2C-EIA6EnvNAQt0thpZhGrPi6YGWIaVJz8Q4HzQkdPEOFUp3U4Zi36RszStlGjlaj0DXafCchfldszJrG4CZOtUERlL0yDwUyjlR_u9CfHX6n2edqCihlD9QTnhS1boXuFhYmgJ5w3cMIuDChrvKNEQI4sh7RBJ1nHjbgqiN6gHzh9MUe_q8PjK-JG5lure4O6gp_eq9fZuxRiK_rAF_BQVgZdcHIbIXJH_B_mAGAdgW4D4FfE-nV2wwRD3-BuHC0n2ulzkkkx4GW3WaQtIAtnjE-kdySqz3ycu1Gyh-aruQcSXOJb6vrIh8-1no4oIemK-wHc1lDFoLdNoiaBKgemHQn7YDnm-8YUzen8zRDuabUK2EeIcNVRUtcyJXxg4IU4FfSHi8JgXxEtm_XJdGLZjMMh1jAx_k7QDpOb2NcwYZVZubsC9-0EK4tgun63CX0xFcrnmpU0jNLQ9wlD2hliQyf_Ibmej8Zhuq_pwtbv_IxVwVZ4TQlifeAqewVRZFaMVqgoMZzH0fFamifn8R5pYRFVCHuDaKeltXRskETwGxL6jiIUZcI4iWQ-4t312oFCajujM2OgiVqe1bsnzZLDScl1OZtdYIK5kTEZILAjU5-Fm3s8zpCPSaCt1eaHuLnV-qKxk_47-dZ9lGhtGPE0X3yUmARj8nH87SxVV-4QizVICpvLpFUgDGtPPHJWhI4X3TssrIDm0MRCBivNsGtyMvSrjP7RmjSAFyd37OAh0bad0bxC0OIA8jUNJIGu_XldsGau7kv_j5JFukisiwsJGObopunFuBD1E-o9UavHf3c7_FyDme8Q8DS_1ddCPZEpLP6HfN4fjVZU4l0fync4gEb_S7IjRVZyrEdz2L1F0Mezosph63PegW0c2vOD_eeb-G38tS2qKwfdMD9JTHyzOuA7_a_E3QB3uYJ4zpitCZFxOO2sHQ7D-P7menkDtZHW0-uEsst1gh8YU9da-cg1ynd-WSw9Y4yYsA1AJe2wQ7oIE1zTElUeMd2JvJUy9OqkxJzUfwVwg2XU-VJucbuwPI19a_LYFgybXhgc_QVW5MlquaAEEB-wcoeXjlPA24f8_RAfpKID7q8y9RRm7MslmTFlxmtIzTpAS5VnL13Vz7aof1BgoOpr04XZbrytbQPKQvMzuNwWShtxqiTv8yu2dem9RDSLQV2nn1uvx8mzaEsV9ecpjImlg057OKwK9Yjj2tudb8sB-ibDtsI5slxhF0Y8ExjrhUvdRsHrNKXHbNP5LL--HAVgK_H2UkFg9JZham_uO4EG0l32JeBIk6cB4KFzpoxUEZ4HRtiWIHUWnKM-F8CowiH8-4i9kQzb8haxRGYTvbDkg2oEE2wEuUB4Gyq5dDiJslE7iNMZCdFCeeis9hRCCuF2KqisguatfJ5h2D3vWGgA0sHoHpU3cYWuGsYFhfmFXnTYDiVaX4Ddiur8056Hkh9sJJtH-4ksuY_vVmxPEugdsKh0ogbXwzzaZmPcRwFXVusijEqWO17N5nGSgzCJlNFwcFvxuP0zFnMSO6YEoobeOv250vRANGehAZsJ5VHQcqDRinNlNq0w9mmseaxpWtMPIX905bK7_MPHDwq7C5uQjKdCmuJZHKzgkDn3CPEB6sCpTm9E5nChljrxY-0bbysd7vVxFZ1knjzkP18M6CD4u_kuxNS1W6LRz9uODOoMmREU1wqySMmjfnp_BvPTPlblz0IZMsMTTyxCm7Uzjvo0J77jsVuyhyAUzXvSOQo8Xtn3ifIpr0l0HUG1gNCX1OBWhptNgdW2mFYfCDNw8GCv3Co2mMNHya-T7-4FF24RqtuqoohTG_zK41q_I_TcaE_Drliidm6RgBIN2K2xpUqMZ-h9R5QZC_nRIJ_Nli53-ZHhkchb-tVlrbW6neamsgUNLYBZ7Q37hqikoOep7lXHIn0ULxJvR6xPYYcX5mcPS_k4oWBlu8WgoAOc7GqROfjHCtAedeldgpft3CZXIsSTchJKfs9nNhYjHufiMYlVpZrvRaN110-vxqhAw07hknWI0LDX8hceoXkeLcFyITMuEsQPK8IPXAgsv2V7xJEjtOH-WK2kEp0sSqe19fbcslEZ7u4c8SC7RB3L9GxSSdP-Fdh6zAmzbcPMas2f06OcowUktpvQIE27UGFZW9JeirmZ8DNX8ymn2eZBVsWHl49N5ejDcSy_Ub3Fq3t1bV1r1EZK6GKVihceKEQDeqvdXhdzE4NTQIWH7GPSn0qMw5PZhlIjhFO-9N0rC_egc9RuvDcSK-Uz8fhc_YVNRBgEPpPoEpoxP5DcdlB8okqkL8xpO2KaLg5bEfALmVExPtIfLbR2bY_XHiJ6DbIUvu8RhAxHQWqEuHf5rIZD1ySZ9em3iY6A9H6LYhKlRBD2D994moh5_oFd8s7Ik-uUMVvtpqqkIsbfaB62l_Fq3mhYaYbSKom2mueIlERoXnI5iDTc2WV55KnS2980cAIxa2nOom951kiG0tne2jq5KIf0YqmzYhXLN54IX6e8pV8cNflfK-t_bSmSmonYOQCIf0wVfv43tg1-QUpd_2ziGoXVSvqJdaPctN_gILeBMhn0Pj9sEgoYxEU8R8FPPgqzFoBqvExiLjHbXK7Qra8-v0ADjOvo17eAFLfOPwaQ0BVZWbm7U4hrSCjW6AP2bArraDDwac6NxvAxVgd6HijbNDzKrDDad_08jRYh1PMd0wtLj81dPY6Z5fP0V-euPfAxrqemf_7rwoBrRwaNzAepUoanlrZPhXfZkQEGgYE6-6e8M09c84_6wFEqPXPi01nJI7NGdz463YY4XTlmmm7IgpCfg_6Wneve7qSLBE2xEVBtqeF4HXYGIn1X89dccprZN8zANwrjInX_vQzvWh3yyWGU1diXn3hifETSbg3rjCQEYdgLlZyBL6z395v2Snb0U565KoHsgVusV6AKiPzw5iE6aTisol5sE6u627vfy14XYRa4zN2RRmxX5KzX3jnd-pY8f1k5LN7cBB1_hr-tM4NS6k69PvlLUJ7PsAs-CxXF2Lws76hmWgk-evkuWJYZ46KD1_1diOTz57z-dtKoMZBVgbLRjvUqfWSwqNZFI0c_F2fi7jG3MIJU7Q7DqekOCNkVewn3IYk3ySAxmTvL_rCsuMAZ_xJZZ-jryAaZDnBA1nl_z3Miz4HUpglmj1BqPFQcqAZWzdGez75FYVJJffP01uiE65rphz4CROmff0IbpwzVutJ0XsjieL3nrOy9KB8UeERGJ-UnroXz13McJW5q6595sck5m2xBK0xT2mD42nc5KFGzqxkM7LhcHF0xQJIM0gitlyNhs0C0KfODh6iShOguNXYPh09qg2u3fNzWJ5XrHsiww-cM_KymNfwwRY8UDqRYJKXMX_fJd2RTQtbVzEogA-eDnRc6bZyhfhwqrxW_XosYmNplAYJ0BJmZQ5jaPaMBczjA1knMlenqf7fwlTsfidZQGh7vLmZHGDJp0rVCs0FGct2UObvfXHvFGim-syZArodt2eSSL_S8hFQuhIr-hqoq6sOAr-si7qitt-C5QplMyUa1-roOMzdudqkl6efEhHz3gf_XO11UaVNmBG598X3ZQCNUBnl5or4_9SLy7I5Qve9U5qZ-olTSCP5RbnlS7vGgfS_zSmRjJR33I0hJdCY_t0vTCt1Nl669NWbac_pQ_jIRPt9vvgRW9s8gwiJivixdibfizO5yEJC7XwWo8rZngp-3Qc8oC-z6A0oD9gQniX6NTCgiFj7EB-nI6FZJLrxB89DGQSYt8lHx_VLbgZ7dlFOPt9oYSfLFfQeQdxl1XWQ4mfMDMMNUTtMpQ8fngIxyUmXO5SpoOeNSI8jJ5DfGJ0L3MqjspGoaTewAAJP8HeUSBDMT8NxrpWi-GpOfOCOt4jCtWR1tgZ6zHTpXSmRU3sbbRiBXRaxDl6Iow0r3SabOV2LvO9Q6--ADZJUGgdaizQ5CyXgo8bSLQ8BLHvxB26Jc6er2RMM_vMy5zLt2IizZr5wXUFlwnFoOxgLzffF60a95m-rGZA4mwGKW92HSvFaZj9JalqNJUYwI8w4eMgE5hlAZ0BAIw6wQ0Z3Jwg6q70tYxsAgFdlq0TekgJAfqgLhJMs-TKoZsMRAwlJl8xqauZ5bR1aovLRYpV-fWjcXPE9jSqxJTMvrbNlrzpbwSatvv5O4ZflopjBxhLfbWiT25uzYVaBSw2EW3VzL9aLLbZPfHK9Z5wnYMmeOjONazItYt7r7TA4CMyFmzBStyqvF085G44KnKMD031ehuS8mqF876QUsgXr0cB4hwr9MVub32O_RdW7CNdjRZA6Sp6lEn5b8NHjR9uM1sZk4epKVcR4bw8qxOWQBEMMNh2-aJ6YH4mCstyCe0gshcnrYPCu7aYLWwwKU0tgm-AxFmWQYHnS3MLPLuq5TY_ddcFp_FY-ysI2_4fJFKXCCWg3m1ti7eXE-WKgUoKLl0bgpMxdY5D3PzG8AqubbMPFY9EvOlchrhy3idwDLqYF-jAiMZgYfxlTl9YcrNCKdoyVNtFZ94DxmrqlSuU9Ig9VJx8_-ufTvmZx1dz4VI3aDIKY2J4w4snryxflQUSpvtmhW9DHeOhAa348rboEz9VcWKe8pcQzsOaRwPcsCnwtsJLiUZQWFlaSnMaH-aPp5TCn1ncexvZseBI33H5ixLX0tYITD1sPmrF_TD5knetU920AtneS5a86QtDPhzTO3XpaA4K-gjIXU2j6kraqPpFU3x-9fLmW7JlUmS7vxLGr6XTfWkjaApzMeXfbpmCPTMw6GsHVmPS4GT7NV9EDoodatQhnmdN5Tp1TWp-jykyyQXlE84s7hKgsXXrveZgd_5aXUo1z91YO2eAP9eMmQFrjOSHccVLy9bIDVtVoYkVLKYPnUhFHcbxX2CIBOB6VSKDg8a3gyjqFcbSdqB2YSSjXG1JhWOlsGlSGb7Z5yHtawEX_EbFO7DkCorG3uKW5zLiIQYPZFh_Si3NGaKF2DOPVrC7SRKxmoZZCwXzkpWWblUaIaM9GkDFV0m_JN6opWfcrPHOcen1SMpl66PLHr7BfAhUAkkuqN-OuZhrTjxslTzW78N0BvqaAd5HG2FKgjSNQUVOFMzo4_yeb2sSvNUZqzYKHVekRMI04_s1BfFz7IKycVOqB3oL7VQmII-WF4h4JwLIdD-Xi4FjUY1uMqP_KvKj8T6bhbQK4Ue_CxYjKTpHd7Ox5rY1oH_l8GK7aozS902jmOvdu5qfQiptTHgV3yc1W2UtR26QyIyLVLF08ozQwF2pXk5UOtjg5SERIr8Emz15fxe0maOUqRzCCvFN9UM3wLzizpeA8RPSoK8S5wOKw1reS0-6-3u9PfQ8SOnxkWx_1aiZr4I7OK50xzDkheyQs60ko9Mdh8pG4UznqAEIg_5z5qlDcG-IqEFCG6OXoQxmJSb1Pm0S9E0zHWsHpfDnPKE9Sqdu8Z_mllEYbTVNER57UXvrDCYhch6hWQNgi2YHbtJoQsmppot71PN73aDlECExyWYRbSxVgcYz9-KB4BCcwZ-nuM49cHZQeqHtseenl7UzsbdoHyq3IICRlNmZ-BSrw_4kD0TMWBlpOuCJeKSRtbe3R8Si0_t5QQnzWbOfgZMMHqER6u2-X9NTcAa4rVESv481J-uo1d0JR8TuzTYeCf7K8cbMjOgR3J_-_o5odLx8jtqwObYwgGmo4PNxzW45wgFo1ShMk5ojGJZpETrudsLP5Ajc8WauzWsTzNYwNjGpu6pm5HVSrZi5ApxJDQARdddUAPOL2xXCHYDsvuXm9p_YnHeoV45kz2AIE0SKw51hHNGTupIJSHIehVtvtQ4HQygf7Jdj5T71lqWi5mEB6jjNn6QmafGVdepaiYw8Kpzl9RMI-ziSf_YGMrsSnaBhLAk18InuSLRArtrzfodS8ZSuHzvgMzpn9egfkgNk3Blhdq1vTfj5HH5Guq4LoCHmntEVPWHyK-6s2SgW96PWGDFceJ7lRvHbBgbHp-EV-vHD9Gx4bs8DDthozLwKFkw24a5jROBf-_j85hHLOyf5pdOWNQGT4rlMHWtd8f3ii-_ruRNG7kROSZkNILSTrrZzUG_8qoBJQ7egWdyDpi8lUyvMBtvyzF4GCE_jSy1oAknrRJFbeDm7xJHlwDjx7U8x0EOu6_sAToXmc9ea16GENriDFuHnNb3otyKsJq7qYS1_5Bb78lhrdwfpmVc5fdNXT0oJrlujXbSmXCT2iENVgr2Diy6rQcDHUlx0YyI_Sfr869FiuBejqItP8OuKf5a_l3rCE3EdSD4U3mLx3-0dA2qFgPrPIvW73p26wIXbWrc3gntAvrT3z2Dc2T2yiWkkDxcPCYWemTN2cF8OaCx0YI092M5pZ5QfiMDdFZFarekfGJtJgmIsFFsHSJzEepn3jkqxVI5QOqB_K0kQquYZ9IDQ9j8-6zpnC3PrOfi-MzSsgeuYR--QBpMgeTVHluTujDfiaTofGRKbxFOaGxewiS83Fnm1tMr8NfaySvtb7OjAT__TywlV55-3-jzwNCc4biwSUiiJyLMkgWlgkc3Ym-tj436vPWoMPBnMSXxi4kwnNFbix4pe-2WoFDtry8V9FyqC5Tm107f8968Mi_IJTt_gbkGM-EQ08UwtyfZNSWl-eWrLb07X3IrOxmInvD42qhNP4PrWWKCmH0T-IXF4r-K97mo2JsPSpB3YqXC6Zdqk40g-Fvwi7L2K3cURQMc4rDmCRwfezcIx-4Zd-3O52nM2r5-7kDEg7Nmg1NQTJHZDfGLulO-wpqzMxT1PziAq_UJ_8VzNOZ59xXt3t5LJet6LmLh3mKCP4Q3i1GiIjGQkPAdjGL7RDY0ooeBrWJzQ3MmsDlz6sxqTFfsihKyjeJOHyNxJyRiQojn17BfrjwTJ_BHuzvCkq_vxZF_kd_s5-IHN3RlwOFwcR3u3qIZTSlFXY9Y_fFRWjWiOLmkjnbJ1cmZ9rOzU4g11QHHu3KWzH5R4vehen8LyjQI4qokT8z6aEU8YfVoWuGwJ7P9wOrqTdMNbN0VbchGmDO4iQTv8iXC-MhmIbQfWN4DBpjUN4JmqdN__clEr-QDf5Fz8QgA9d3y91rIN6vf5xATbkPAxUN5sVq6edMC-cd77rJteodEfBRgN1s9dsUwbtImCAZstqW9_kp3Shs9ExXEHhtVblOHKwfRVlJAu516bkKkhdBWXXVtRqAFuF2BCnQ-pI3Vsi4FmS6t9uaaU_RYkKgGsswm8hzGbuFErvkBQHEufjxe9-a8orQwh0MTf6ROBsfxSRH1I2f6QrAP9AJV5FGR4QILLGboCMje8Dj8UfFkbwt318i4-m_aDoqCkPHLDUPRpB75SHHdmczrJQq0-GExJ6cJ1WQ0PlZtPEMfid4wuoUqxdNg1rUH1gBmiFmldBqqvN2OVbUhV0RGOAMYH_1SbBCbLsoDDIiC40RZ8GzjH4-uuZsZ2rOgMh6Un65kvhxsMGLkvGo675kRa48FL_cxWsQUFqOrOQf31hZDiLgqK1SbBF9weUkjItqGoNy5maJxBS3lxHIS9wnuUz5PEZ0ngcgSqQ1B0awlgpEtfkMO7rgpQ8KXLxtA2VI_-bGCiy95i6PqciQwTifCsnS3RC3cF_TdoqXi1LBdvFCtrd-rWZZRtWddFIVTTSEZrZNAey2pDafo1F9kA9Btxr8JjmoOL16NE3xhzfT9pRptnlugaFL7-awO3pI7PC5Am1L7BMSU9mgr9NI1e_ocaEN-hUe-rhW5xD_QUDi3EoowKjQ8fClluVv3qcP1vaIBPGSK8IyAH1fnTBzPZWZX7TJkdbZdOjrEnipnKZ3QjG0zDRGuoqNz281JnaHHoxGCMwofV_DS18c5_yivKJTHztYC2RrJZnEQuGq2R1g3KrIu5n0isxNRiJgwYXsXbbKg0kRb0rfhWNkBXCa4GoA8qbELFEav2mSmjhJIJmMIGkeJTfucMTBryJgwAKwD4cvv7TzG5aKiV66s-VV12jkHrA6zZpHMLeIlf46pDmwCFRbdH8xktoMKoEXrsFArqdY5xD1__wRNYIcjewnSvCpumUCY3ksHtyd17SaG_IIboRvWyEAqVAPbCJDPL1jMz2zBUVwZvGYoxR3FehBvNrZ_HQUPChHRGiprSGVTtt0DJP2OzlQHilOVMszTEvFGicI0UB5RjWTcmzx60Oz0xjZG1SsheREgr3UsnjNYomEuwhGpaXJiW0ErSf9C76p93Z8zXJF4gEXH7JfSzO4EmdqfHtk9lmn2fYsqzcGsk3U82QWI4BCTHwdCC4DxUJ8DW0wAIR3BE-biqJQRDyNM4g1koAki2lcEMUSvBrI3RQkYj0n_kbqKXE2y0kCNFC4Iib2vNK1-sb5GmNL2SFlLED_I0nhWlG2Z_EVi0qRZwt-2bC0b5xMK5LSSIuLovHA5opPNjHHX6Zd187sPie_8qVHOrC5s62WyOHttWTjLVf-HD86vqJMkko_A-vApy4N4M1I1EHT4T1lTxB5hkw8s5d6odrQMZD34GvwI2xQtI5nmjhYE9JteoN01O0zPuBKRxHqua584-dhfj8nTi0sGMm9w2SAgpTFs4JD-qzIgD_zzpZ07sTGhTS0p4MTXiQzO_ErwPr_DEANGsZIrRVa10wF05Z47JoH0JwtockDQQJwLSpYT2DdRpErR8063FYe27iA6pXXWf9ZMr2IZEiWOyERUe5LWB5XJu8QQM6iJkU2KkCJRKos05jwmDSuwmiR4t59n-qS8bLxsa4ZfLisv9nILLqPYDbUcX8XGrhjreoUgdzAeAPuqoZdKaBLNHqqhaURDkz7lnKrCV1ucgBn-Cq87HSBYLfzkTmrjWgNs_P5MQ2jYjg4S-QeM1uLKvzjQx-4SumKZOkyS9tjO9FSOonW-CZcoabijLtVx6x7VgefyNYK6odaXOUIxcfA2K4PnhEiKCLh9Z4iS4QZGyNfNTlw433Y5qnT6Jr2agimFcDQPSXGeQ4Wq4B8MZre8jIiCxNmhgxWvYT2Txki2vIIjpq33f3OBpPsK8wS4J3cvMfrN14TW3SRt5W1cZVv8VryKs8O7l86sLT0wAtZHCeaKBynKotPKqSAnCWYQc6PB8Yaj4Lnlb8-9czu0opLXZfnN0u6IHTvgRIw0MgUpk3nFkaxvK2DV2XHa_fbjCRN-KwmqOlO9uiZu7KyCQcqQ76stK_sSfrIFLwXfkiZ0EkhPFyYDRsW8Zp-fsFWGqYnkPQs7V3sdxYnsiMW5LAgL-wZ711d20o3mowsKX1ufmDcNUVgLALi1kQeIUUr86AeO5mwI8yGPEM2_WyH-o-Amx1Uk81JR7Mt5c1iMolo7lbhHMpA2yIZLQsUTamU3eDfXG5gigJwQaXzATxp3LWTjMf8AErZ1FUVW3PbRC3xhQ7j66HonAA96agSqD7_pnLEtHMfEebg_cKWnirebapF6fCa8G2F-GgIAkY97pa0FkgtXQKtcQu_KuCNFNQ7LRDf_NVr_SWtUuao3SY6WH1-R6KLqY3_PoTX7tkz2Fr41FYkdcJQEjX-BcMfp63EvDAt5NtRSMQabYuJg1rc7or-IDbS5Kh8p6OZbV7v8NX-Y6E9PuU-yeNbqQYaFFmMP3bUkge4M7wWHMq2bKOo1jDCIleaLZ5QFjUgI17exU7jsxGVdH8u2-9XYKqJUZctbYzbJTxygrtBFnZPihJ9U6fboW0wmTtXHDClwFSsyOuz1NyoOKZrU8CbY1rCq_B2YEtpdT_l61b_8FfYahJZtSd9_hDxQ6uojx1b5g_O8cdLvM2eKhd8y5mrevlYrzE3kRxPvlAmwWGUoTppumcfk_VZvBIZi79wYztnO90apW1w7-0wJreXbELODEZewNcnpF3yGF8txU5Ta6UE3cqpJPOpYVYoLtbkAUsQtaeppQlA5xaDizDlxqkFDvPLP6FUPkyYgSR2yVoAQ8TNkDAA26cx42wyvQ00AK5Mh0TEyxIsdj4SkVQtU7fIF-czod93i59FlWuBdgkgYiNxWxKfnuzbvSV3gJW4bfeQm8tq4JbPAWd3DMZKNOKI099ZDK13g0hGIxKPcx6VCcMPHTm0MG2f0CZ4t6GPoWtaPMbP8kXUdBixTRlcMwkXKBgc4JBf9kyVgLw1OUnDXz76n_Ltwi6b3RnOZjpyHjw_0tzxmlEw5PM2YoGndlVJkzmX_PjWwZGboKT2QXIPGlyDGT6kL6fdgXfZI002sgkKjvWRN5dt4u0Sw9-7gbi18miCvlsa1maXeERakLrwOgwNwANiWqVhXyPYnAaUSQvh25KuFQevSF449TFLVw-sa97Y1pynPU8N6wyQTAYDwBFiKNXofAAdI-GgUAcEXmcdAYapOH0X85EdxItVlYMw5Ms-BNFbN_29yR5hU6L5ycx0TA7olngZXURFrf2mixqA54AgBLaISiCvq2heNKIx-ZD246cZcE8MEz_nNo5HCwdLXzolrQLzsePXrAXoqToluUZefHTWskqGeMQdHx4hKE8hAZ60h53UvKwOmRDMQ8bJOvPfdgKrRJ4xtTaOa0NmFuxgDFcyyJGro-ssBXfQlEkPYCTdgAevVlUu3IUqLJQGMstWQjkCJVU2kDCnBNAJRjOZn-wCL_cyLB2Wj3pbtv1D-WkZiFDF9Xcu3RMGEQ_oCnzkThFh9ZSVELbS8o43iGy5RCstL7Wo3KSbeiepR2RML31zSb3tNa0tYvUfEpz-DpES0mq-S0utjsF3dhtMHMMizGi0t0Dljbu7m04QABYSwlnIJJyjZxsiAKueRFLZ2vuf0a14CSy2zH4LizCT_260A9_-GBJuUdU0QOE9ka23HAw8-F3oI8BCyHAs7l67biTPpNJ47CZHl5q3DBDgvwPkJeWJHww80JyQAPhzoO80rHuplOOvyISYcbzXkxU5u-iId7AYHa01DiO9kblFvT-Rc3LafJ0uXFWnHb62OgSalc26GFH7yWDclvDpXjkOJQOXnWGmqWQ5SEoOUodNdKe-zG2MyYWJ0Kz3RaRhukZKcrgFQFCyBDTxVoJcP8z6-rLRYCg0J9MZgyPzilFXKbq00yW3n5_MrFJh7oAOzpVkaau_E5AbtEKYAnQnignRSzRHjx_lpq8jBt5sWn9ozyifYhiDZ28hh5sGgWWmgPFeDnyVk89nNEQt9LwGt4lrnpJRrFyu07hqaJHChMc3r8PgSQtef1K8BHe1e6PR-51JRPdBmNXaTQ_LpaS0FeywslYJj6hYxz2mYZcJjsnwON9lkCLK4qxhoFTw4PyBjZn9PYBDYK4jr_I3aWbWLhr-cmFlch8Bbjj7lJVMgreWxGO19MQgAt0uqcQZjP4KGouM4RiQ0-DFhbmeQY5kipGfBilK-KswWP689M0oxteFNco6LsrzmNY8QJS52BHn5eV8_eEztYrwNuwTMFujW7E4dZdF9ZuCrh3iJIBWMNVFYPq3BUd9JpX_XPpJ3GT--FM_nBnU23IFln9tn-bvSvPfL459gE1HYu0IKfLDbN75iOkrJsJ0eLBD4_VSzrGoqMYZJeDN2F9VlptLcwDpacbCPXqMSqh9Ars4Jv_oXWL4axCeqrA-y1atDvB0wIkDGX0yzLxzVKl8mw9E5Nl33_Q5KQELCvLGe2FTnRpf8TgWg67iWt2hCn-605tkrQlfJ62NoF2l_HWp34i0em-u020WSjcB-vqKiTOFVirA4srT1AgWkL1DTdPRAeZf9LI9pSX2q89bDxL_atwMHuFlR113OEyaQSBXlb_xKtTg3b-uZAUphoVoouu8l-fcTsBf67VJ3X1GSHYMV40pt3kQQtVnedFbsRMdV_ajFRQyZBuTNanByrHsU3M68KT7GeGCxAdxEZQHF-pu-oUjL0rHsPapHAmYP_N3i-SdxwN-j3QLfMvswP5B2uV6bb8zQ0FOACUyWykmsTrpV6PQ_Nvh_A_CHgh1aCf-e-0WtQOYm64cs5dGMPPj46EsiudOrwZIwDSS3EBMnmKIgT43kFjsqYJ4F8rEqetUKFHJquW85CwG00bA5VSe3JaLLD6vng20LY6hMlLLW2O9s4ULVYvuuq1t_e4P0nB4M9fV4pGOEikiWKNGgnvRiUA4R1gRE5Euq1zBiuqOwfCc7JB5iuXFNYlX3h3bYuu-bkSSh6KOfk-iYFP5oCxtbPX2OyReGs5HMH8_CByky7LptmCSAUHAKslya0CLOxhtdfvk5gSkI1A7T2TBiMVwQVWLS-CADzxJAbyObjPRl-Voo3X2lAZgCh7mNfDPDpdkcueNmdCWIzZbEBzbHotaHDkwHJe74JTR6UxDGSbmXn-MfZ3pZXpcHeKIRN3t0mOOwG9R7rWxjoeGobWXXN5tjVQCsop_y3tPPSyJ89sutdkiye8MngNZR2xenrOR1E3IKfYUZ-rvX3j2vEfMYUvQonHid1HYjc5vc8QQ5JJNKMDQY0iP_lTKK5i0hXen59f2cqCI_mdxgWwVu3yG6_TRtmoLGOfftP5OvhjIGSkL5WL_P7lbb68c1LKxbHJujlvx2iq9btmPHa6rFUA5bDoJK-8k2u5_tPZFY-BujiwfCwL4CSmZBQsMdo3qQYbW60JtcbGAQlk0L2Qk-XhntKnvhUExGWP966qVTNUCc_LKoZN12n_QLeGOEK4kXOtE_3iGBXbcUxTgi0ygE6e_dplw181aUTwQDPm0wZJcX9HFqtiZtdxdkQ1ut7jyVhLUmxhHiMch0ZLZwVo1pbzYdoc17LQcyS2Oi_uSn64VHAm_hKkh5xN7JBlcoo8aj8w8kK7Lf6QXPttERv15-NJqw3cbWOz5UkxMf9AZM5A_asE1UhVdfAzlUUmTwVVY53WLrdC90zc7rBvrzueMR-o4fn6TJpYJpijmVN2DWxTRDP4dpd_TkrtYafBoe4EZzzYTfLfVKH9jY0h0ITqQZLKyBIldFRu1kqDnNv4dqfocGyAlIbq4Pj2pcKltO04ZP9cZRDORUXdLL0500O3cErybrgRWZz3fO4hKHmWjpPoKSCSUGtyHAeIYTM9cIUSQS6ibO6p62bkNR57Qn6_tfsSiz7x4AnWMAH2LdqFXzEYlGy7_j3a9oOogawvXm7Yl4DS8VYcd4JVhL1436JTjZxUWolGbG2e7_Wzv9PKNClJ-6QOqV2mwEifHIBkm8YdZUu1NYI1NoJxDSJWe42Ye026OLCJ11Gw-xaNGm_fhGtOTC5_yYhfFJgxmqJ1FWxgiTYRbQOVpXHvD5BCCeAi8sW9iH9ZSP8odxvKgAqXS9s6ZAeqbtd7UT96ZyWBUMqO2LNAu4iCj28hm5ZUDrBHzzCJKa3nJ3BDN2JJMQzAa-ooX1pXuwWAkAonN_1UF24H9MAjy35ZEvqw7TUlwtMe8UWLZUa3KrrXB0CAlGYwK60FPZf-GWCgMo2oI3M2mTaLQP5fWvOo3ijqq8AzIoK62b_TUow8wc7UowM_iMPZwc4kYiXsr0-O35BVCPtJwduFVatcDrUvR4VkjyzOD63W4kynuxxyAHtkdNam7F9lLSWHXiJohYtbiy1ChRSAB2RfrRlHIqwlzIHmbwNFxSYLbCOi3nXNSMbBKkGQsA6Z_mMPABss69eBR-mvKoCCvQqCIL-Y7_97NSjj4v7hRZIcmjJ0CybuzqlZQi237irrPuHy76FR8UidaSmFfoinApUCwai2ZR0k04kVya5IZkd__xDHzrfOKeRrMm-Gsppi5J86MSR8U1S42mBz0WZlRImJ3fJzDGBgmRIIFpyFZ6LxwP-mm0K2mW_9y-Gl7SCXMcCSehVn-Gn_JUX6yC_ypLqrT1ekKnw5gHmbGwDihCoCXmkygFhnebrVc37oXJwoXr0Gpro9wa-4E2bGbuAUNGL-tZWnhY50RIn1Sk7GLbVWdzsQ26NOTAUYmOI4RiovsvRCE9ZoPRalOq7fFSSoslKES0oIfKk-rSaDhbHf1X90gSReh6EMWDt-cw0KWRpLbqwDg1a9zFlyj3sAOXUD75TG2UfUHyEEeGSjpUfn-b01A_A5OqfW2zdTaNSvacpVzPvkw6oVMI_rkLVbouApE2mZz29sQ75rWwcnEVQBprMNcuJJLVxGJPDh1Kus5ZRcZygZSQv1abouvNOpC6gcsxnoLNExSooSL3D3zhbX8WJzyMmMcbsMNAMhhmBfho5m_Co0AcjVX15IHOtR0KnzJzwkc9vM2EYoaZAlGuLJb41ys5EnFGHcZBDZiQy3pQQpQFgbf-qV6FWZcZMTv0W-Fu7nC8DiOIEeIgAXjmBhP6xxmi_lPx98ahpRt8_1o5oheGQ-8mWBOuLKuF0B38F9UfXLHds9FUe6EcmS7p36LA46L9AWWyJBs_3mrI-9DI4Qfwdh6TQjnPkQEJzPPekC10yztH5QyR8s6Tmes_QvkrJ0MhrZrdx2BUaEWqy9EXcSOsEXPmmQh2hm5BwZN3HyH6MBeGwOx1pPuO-4lu4az6OENz7VHYy6j0OlE9bI-LlJ3i-DszpwJG0HdLXoAEbZ7qSOFUUDANooK8bI7g568vxC95eIYmLSpqOXy986BK9v9rOXiDApNRsLF9nyxU3H_Vle4GxHKVgDUJ3s8G06vnZoIGbuulJ5dMLQ-cs6cDxwq1uswPpkll4V7wO9Ci01WFbF167o05v0-oJwMMDvD_rX4rfq8LOIctj91q5OwZ5cCXNlwYvseHE2O4vYNUMYEEkbaJV3ZZivUrWW_TlXkaGL1oRYfMI8CsTr9yj4sDU5d-mwyGObFNtH2TQneCgFdBl1rhFnmjkQ6APgf6TEqQJckpzPOErmEroDLuhjtapaZq-zM5HyOmxtiGaroISUDok-0lWfyPgH9vhUB8umHzt33z-YWl7CnUPMqwXO4JiVsPhr3iqbEZlXw9aDJtSSJfSCexY5XUSJW-x8d8rV17a52br13OxKAZkJwVuR_9Nbd_kC-3DesLjb9A3vaZw4Y68t1bEA9_nrQMvfomlheahfYewXd0REHcpK03Ls_qgoAlLkUA_hZa23vyhgE3idfQ2zmrb4lHYoE1AvLV9tPwB-FgVV4wDrk2SQkynWpG_EIerbmf261D-a_3OUTAcP5MqHe8IQgt68zpan9tviVKxsGgTDj7owQFvi8eW6rXCXm4WE65IxVTXPH9KzcsdT_80ixhqXqRz4mcYh48WbqO9grOHlbCUlLbYuxvut6XCSAc1EsvYSsi4KXoDHs3haOpzl0Pu72CJgVGXYAO-KDLClK8Q8fX1ZD_3wWDmPDUiwkPZu8i6FqACv6fivW-cHxvgBQPZztHDQmoTRvmatySXByBENhaovdNBmhUbxIRNG-k6Zmx8iyvQ96_9hw9KvK9xGu3mXykVqo9E0-mTo1XkCBJ1n7CI39t4IcL-zJNZA0iHvQLRI-jWA7qAVSU52R8ZYVKIcrUiVOQ1OYlE2zEXtPHhMVMyEmYvHcr5d2VM-TO8OJcOP1j0IeolD8ZCdcQG8AOR81B85S6Ge3SetmFAtfXc1iugdXsqQuunnQQAApBBvAcwa0jPyHoSlrmDUvGe5xZXPKekjC47Vtl7EoOgyQLhuGJ480uvLuvM1u5r-Cclv_WJXCFGMG7n5_Oj7ZArhyzERZcrhXisaHqIH0cB4rENn1Y8fv_h1_NXwxzy45O44cUg4w8l_0Pwf6caOglULna8QI3k8qzf90htsVKmLdioBP5DFpVwZmm6eBZnq-uU_bMLN5Ia-uVBS8NryDZJ9QoWCZsv9WpwksbNs6UeQbh8H8EQXyJkIl8tkjIx0EtBpvZs4oTSLZNOYso6C1hLAZkxyDq_dDiYdxkj5ZdAb00fVVo2EH_WFmPvCKTclG_R2DmKvm-oiqUhMDcMgFG73rZrQPjXcBhvFZdKyhxeZbnEpnsblDjorTQykcMOEssHt6GelEXAyy9Xr48-EIGk_Oyz5ZO3qrWf3Geq40R9bic6NRppaRHMperACTx289A6vry7NKVT9DFUS0rv0QZp5iS1q5HAA9VrrV6KYJ227FSpaKYHhrJYSuidhWRt5bLxAKruScID0E9GTNkOCepCGjxSi0cjuDbogiT6KD6WC-2KQBe0V-HecRne3GZTK-xwxC6BvyGE17cH248ateGyORiDM_jZfSvXLf7c-pM6u_FLNKvzI8vefWcpJWRiqNbALNBxjY-Ml1rfre02UuI3bf-HsNAJqSN2H2vfZKGAIktwQdDYQJwKwDTExQPJHYoDhThctjn5j-Z5d5G7v0MmLeh7fYhnyhRPr6DyS1GKlSPDDGt3wp-OIv5sRSbHl26CoQQMVF73vXJZ4uId7NIqfad7XGgcCh394rf1iWpgS-6GPncr71KcbXaabGWJJHpbTXDAfnY913VTqEO5sHJ7Q4uSnXw2JNYJK9O0vp7KWPfWSAa3g5uX00jA69ML1QbU5AwyBXZ80C1HfsVbIrq_Ex96TjkWgj0n0Jyd5_RImzkZJAUiIBJBpIDxL6S4ZPwK_2Bxd7PoVixYDnMcWJUUn0IVUGKW8fVfYz3jdif20fWGiS8cyt46erS8FgAo5DnbReMS7MR7plXU2rBLUivpRDmg5spDum3lQdDWd_MEZa3zj_iowCVoaD9TM75IhFUVPh-CZeuiswe-QvtP9a77S7wlzKhDXB1GI-dP4FTfW2ZuDdziAoSyswNzAtD7ZEHTelf_JBv0v0Ka-ZZBEjKl0T1TUl6WTPM4YFOyOe4VCVyojpJ_4aH0NrYxpKDO2KOGhati_Jj334LYNPsXsXaZRQbvA8X8H6lbg_akFKUey_00_lNBfcIRejO4YylF0x9W0jiHuWiFvG75P_Y-BUUlQIB57jLQTW4KitsXoIOpNQ_Hu3hZD4xlTxw9WSeteaimT-1-9Vumum1KNMF40bsaJ-mZYNuRge4Am032WkjRzhTmGP_Kfb0lo9DCW55S1KudWwG8nobsxuu9wjpQ8OEgmtGIswV-VCHIJapuh3RZRaQwfcdLvWIlk-XnBUO4xQGL73pMJE4YOc0zSZgY8Sg9Zl6ZhHl0kTI7n9wJ4OR8N59j3OSWdlI9ri4WImkIC7kg-bWUMAjpVJXTHUZ2jfAH39rgpe5xI3TDQT1HpiUUYX7Vp0lJodv4penB125GZ2BHyQqFdtSTlVWlTwNezDgyVR96HtOE1wEawMOTD-jaoYs-m_mEmrD_sdEkU0diuxoCujOI9wKA_uLdzIr5INlG79ZJZ10XFGUvw8z5h6fwGpbjx0vZrBHLCM2kNRorXprRSfEkRGIuGEeogpUaiob1COFJ1-CyH0oFDJm9Oj8ASXGCLUljVHmomPambGnqUJZA6uVPEUc-z3VrbtEMT8CtZyC6qOhv8MkKL1AGba5CeKx3ZXH5WGkkbO0bBOWfJ19zJQypCPnwCIMAPSw5mfsdR0oDc41pLjfaSDHyRZlxp1_kh8R3J4L8jHNLD2gvXluBWltIG3Gv0cjh09kNgKKzWaq2ik67EIqBcaZKcofcpADP7veMDfrtK6nWyf3bg7tUY1V7z6bMqKfdaNroNrd8x6OQYhIIofIRIiZucmH1ysqMylv1gvce0FNGC50fXJvWiyXQuJ6QOX12n92zpSM94krmrnZdwEBwJ54exEEIf8XWgpXu7dMIwQx8VxXw8rUUjEora7GQqBCLGcwayICcDHrwnoqTsdCk4abk1sH8M43sdL9ebotyKkQ1L0MeXgdkYfyMa0n8_SvwJiKXkz37c6DKl-rSr4Rymru5Bo06YKkoHN8gZrKUAQ4OPMqo7N8IbefteybjZWucK9x0F435Z2xI5WPdRE_FkBme2p5B_nl4VFOLPc3JXa50yeULGZOjZRGneOlCgk4AbPjnQH21ANirT9ybLArCYP0uuKJlVeZulv6lgzPLTL9cNRJ8y6WHhSi2p3uC86tHn9U2UBUsvozjgp6dbgBHz_hF_2bsNkG7ubXNNo_zRJZifyMzhhAyrMPSKMhgidd6tCy4BTfVkkmUmB6x-9kvEvxsBmzzX3iZHkYrqm524kZBe1oKkhVK6v4bFW1LgGHbWIADfRw6KtbGZ4A3o_8RgNnoYqVeS1WsET3f7x7i1pgNO1j1BBs_Nd4niannB7RB_uDl9FppH8hSjEIQQDX_pioKrqn-l6AZ5PM5e9C_PsHhThYRkbsngRAey0E5T9cYWNf0XZUHwPQ5YjQNoj4fYh8a5VcKLvTV0UjomngMSwur_wb_o3bjqpG2eH0o7nb3p7idgtlm9RoYziVTC8--784A5xoxH8g2F7nSDY3xI10PiTuWb6kQXdR-hV7_7PDUSsgluskmA4e7Wy55yTjnh8JFxi6Iz99Sqw0Rc0QHcz9XQJWXK_wp2Iu8QfCoaPnQQVtsjPuLCh5HgdqOTspZufVnLN7lpXtd42ebIsnakicyHouE6Fg0X0pAhw0rxM86axhxL98ThrDFgp0DYiTWRrm2RpC71C_heYwyqGCVlMPFDiMDWYoP0zDHBfimeZ-lZDq581MJfGjjMRO-P6-rDs-grJD7qNoV7S47ATegl-NpMNX90iFoF3mPayi2ggvcJoof1-F-8UT0EqLfQEFny2B7IRTxZYM-ebbFV4ekHCaWqtCq4MC7nzBcGHzbAFV3HqpAo46_Trxyo-f7oMJS-p6o2GuAsh6DE-4_ZFpxM6hrGXW1CkfWv-rXypCdlgUH1HsT7u2bahzDz6fcKrRSRVUWJGzcNVmMUwE7-XNkt45dDAceoCE_C7KVKS0MOozqJDmmonqMYlCQ3u361yGgAqIt6KaI_7Q6hZT-XK05eRPRA99sURLveiXiO-tg1BOVrqqqMaCZxywFz7uJmaX5AJS_nn_MeREfp9WfvLxcTjZcJ-5q-CTH7qYwuFEf2ulbkU0sy9CKpYgTNnj8WCYRzX0qjrePB3xAxSyzv0coizeDh-YrMy7PRewqgdS7vUCov6WgTFfaRPcSH1QLtqFXsS6iH2mmGqONP_mDYl2L08YhDPslNxi9U2a5sf4J0gSVNSywVqc4ufXn-zVgl_mqtr138cFju47pdlnrDHccrD4t67qIROo3T03ksNuK-LP9VLsuJpIvAuXrd7qFfp-EIEv7gsJ6oeNCSAhr9foyHuoh5FK8QCsheaAuyGOC4J8AK6_BXRQtnW4EFq0BhbmR_Wym92Ghzs_u22bw_Y_49iGiavU4sKj74lNJAZJPAWENNQ5_zUadwGsAYPXPZ5c6LCpISibCSbE6CvLspo4vxSVXz-ImWUAX-cBwrzEdnsGTcoLmkQJrVlPyppPasp0EkYiLJM0FuLIUv7GEbfCTn53g-ZyBiA8WHG9WRvjDuD338PaOt9p89Qu8MnfM65sgzz3UvFoJymUTsEPu_P7Gq0lOBe6BBD0xO7QM3a_HJn64IqYoP-I2LTVig6BgZeBQZHEu8PyTa4yux8HFlBVZqSj5UCb1sdDIUaCfR8TeJD8_Nim_ml1hrxL1XgQdFfIC_4dciYzGtwAUQgOUXBaXSyExJNcZnkTX31YIIDwSb2kmEhjv39hfOjVknid977hCxbB8zMpDoWH6aFwaN9WpD7aW5SY876hh-A3rdqkszLntULEFgLnrxvlUTkdZUCWJ5vgoxGOK-aF8dGGH4nHbS9pFtxZDWw94Yx9DdFP9wyO1wFIxPPquy9L2cu4Pu097bgAKiErWb8c8-btoXeQ8BJqwetar3zc84BfzVUmtzgaaJT5TdEyX6N0oHW1WEkU1bZHn8ZXCUgwtDF4cWw1ikGwO8clABksVDLMjRcrjhX8a_DnkZ6C4k2kpQl8XYt78gnrhpa2VZyrV0jiBL6cSSvpW-0c3wzgB2YFkplO9jMmT9npepV5NC3Ae5i_Qxy9E4TQpoK-HcdU2d-4FeW5mXc2RqxpgMf3Eb0de7hHjHdu2ML2FCJpUbBo4aKzgjn-fxYC4w1HGXTvJBNVzcLCtnoft_6XnqM85gaz7mp7ovbklR0SRMSD4ZgyFAycejTADsjN_glrZtX4VjFal-8zGlrFpmmNuDAbMGRnBHMDOqNdIBdktHqrCh1CA-oKStTDATkFuVmnx5d53gKufIqUvqUC9bh-AwGQQuQZn7nHgPKmb2IczaBfFUYwWpjNDl1rqAoBW9HGJZ1EEUAHFCAOInkzTsNEIEmU9Yn3N0D7FOmivHPLEfAb5ydvb_U3d2OkoS9yf9HUE38lc-o3-JIcayd5KdnLYoBt6VGHj1sFjtzv59PVMr4ZaeN6QMi3dSKN3JFUG9zsghSlU3mUJqIUreVNKcHyATy0tKxggoEWe7FSb_NmI8bCKeC3xOeCe_2OGKnKpV5uFxXru0v_q5lFE1rE82T200kjceBlFn3JRoqiWUYRTYldpIuMrcHuaAuxqSqC1kMOz8Yz8DGoPaTjMK_lPTmj1yiwIbMr-JTVDnb2izmTroNzYX_l4z_sN-n0G8Qt8FySahTlcF_y9oTiMXuchjRvfYALpqgxXbmijpeNJpl23E2gW1A5zDuOdlnZOG7nke4l-uilQRUuk3FPAjURJ0Y8pYDLTS9k92FW06Vub8cPOfmGiOQJBF8PYpDvtlqjKqHuy-jHqPpn6MplwgYPC8VnThxaDD9xY_dLG-tFmiwZclOD4uHccpuJYaFzhSR2PsThx_TMZV_Y7wdcLGzXIDTwmK3t9IoGhWY_L1AydgnafjXHprhhfbguVdwKT94FPjkNwYni6_LOiL9Ll9T1lLbkuM77t2hupwXhidkVyMxAKsIyzYiC6e2JeEBHzA6JU1T4mI0GArbx2uuc84XlyeCtH6hrRxHhrPYbLUeEawCxQfyLR15gBr6C66mse9ZK691xkzitkPN2krG9KaYUlTRBq6v0cwr3Hz_Zt706q_4xSmN4PFb9IGDXkZilDsH0IHFzm4DPCPV-3HpTRk-RgXx5gsFAOvW3Lcr2VwlqWF5jPNA2d33a9QRR29oA663jpHpmcMIVdjhl6X-vHuFbS8SQW5mSbueCC3Cd-gqhawE_rsNlLlgFaVHv3JRi18juhKyEeWX9hK_M52PY-HmDueQd09ZuhTe2nEPSkdbqUW3g7Ww20UDQXwIYyXmTUShf6tLNBs1owWqvaQY_FUWPTUK3nDpXXDiPLRbl89ueJWs7dehd76rTTF331kCIuq4uXA83naosZhVK_2SvKed5_V2FjoObCM4y6Z_5y5TAsy-qk9I9uXwdoblFMx_O53umOcXCeJ3ulq32uBWvTV4x3cEwGFiBg5JRBaP1J6oL3XJq4EB3h5pAli7wtjYXmpaqXlRvzOt1Yn-wTAT6rOFqxWr4_4tR4KHVCix_Pb2y2I3IELOBHk9K3dYXuhZORd2qKDjL4kH_XcGSbG3oqeElDKZyQsY9bku46GHqZnk6zmJC_HHt8NO51zglNunEkWX_iUY24sk5EFOZcoOyU7SbbZ_CdXeer6pf9v7gTQk17-sn5xXllIpNi9U6AOztBkwWvdmaHf3KNCCDrJ9B4-S1m81G3xpDbUDdHjfxvdO3Xdsf3oegISTMcjV-noNph5E91nite2Y4gkayEnvv7qZxMRn1EIw3IetR1rnJmjEG317lGkOCrh_miiBp2jolmF5TUYnWvc_K4-XfBZX848cHxIpz-lXXE9MCy9qzETJdhk5jsPUdA6jN6yoAw42PWe9qiUk9HwWEk1g01APE7jjFAC5kJUMerZqSnYpBf21SgUIrh44sWjZ27lm6h_SEHoHkFhqFzOZhHn3P8bQvYB0HzYoA1Q03VrZ09cYideNHwc9NGgMxPhpqtBRd_pqJO7Gh8UrFlq6uAFDeD-S7yRsh_GYpC1tqNSjkF85W4shWCNApQaViE4OfLItOwZIGI4-2SskAgUHytfSH7JMagejoAPWQUg3PUhaPYaDOyuaFGwEmiHSD7jOwS1CFFaoX-nRMs_t8sw5MQB1Bbaex665B8pevOogcfIf9cAv4o1Kjd_8y9d6Hni5ZX0qrgpvgDUhv8czpp7Nfh7UZPW8jUuJ5IAnydkJvvF1jeX_epd_1mOCahMtr8sAeJxkwTkMNJUd0wJmyiJTHdfQh3WFoR2LGug1qB8jhcB7kGCDUlgujDC5rxvfpD1GM8WAq8OTYjg5HTiGCcK30kG_HfFs1zGezGbfsCRRmYK73PaHGKosOz6KSU9Cb2GiQiw0VsmKUWKoHFAXFTYrfIMCns3TdnvHohhGOsu1VqS4eUYgJYz9XkrRxZzodIMQaN7bNPbVN6EP0O8qyZaHWGYjOfMxvjkZBtje6-Oi8OAt6YkyN9oPTF38QjYsobEBm11u1aANZd80RyA5nmhXdL-EOaO3-gpkPYKfm5sOfHpA8kderLTF20SOeMgANJ-qbzNGC1xHiTSnvDwOJLn0Dwj7CNYVK0LedLXU507C_By01Ra9fGet_5g9-w-X3n-Y7bcBBbRjkeugT2hR6pS28rCIykDSLcZyGcGixcxzDkybTu--sgQbRZ3GXU10nArPHxVdx1UogOyq21xatALJGa2n48c_rS_4VYlkFVKnXtx0VwbRALluPumB_eS5onu1-ui-SDG0knp8sFcLAW2E2HtzxNNddq4Rtpjp1hjBH3iuziekRek0ClBTGqsiPAg-m6Zaq2eJBIi6QobFL3yDO-5umv4Orko1GMhbvGM_Qz8JjTVa9lD4iE7KNCzLcgQRmNPCpLIheph2Jmlqvk2Bl05eIQKMTpcuvW8DwbWFQWT_fqOb1xTwzLqGmnUybsJbqSnVu3KDUZDnXv4qkmz3DUET7di0KgZYzrmGqTW9pPU0pUt7Gj41pXJl5KIAPF8avGAHcalDxbPPAuD4nQtYjYrqDNF_zhy25ympVDZAXs26c9uO4vYNUjJhN1L1GFvIgJeaFFBLooe6EuFGQ72viu5YQkIuEhfXeNtXI8UGhgH0eYrmedd-NNefLBPvdiJzfhsGvf_IjR-WeJTvfOaqOtvpMrVZkk8YFCj9r91Q0H-2m41JcElJgxpkeeKU0wAXckSTTbFqPQCJ8xKNOkn1Ry5ASjZSh4HxxFtVyYBSCJJ9SLLmsawP8Lto39y-Yu3ZefP1IO953lICFSRV5BKKwbr7EOr-8gbDe3Cdfc2WY7QJdhy-KdHqpRro7dknvs9CpP3djsGl-0a178yHdH8V4o2fzHN_vtvr17xu2iJQUACEvIlmkeqL2ZSTevO6tO4LlcY3PeV5mc5km9moC33iV_oCdKLpwt_0mryjC9oLYH-cBaJqdgLEleasGN45301wzV7za0oSi1yyJJILXxD1jeKFYTtKPjIeDssORU6i4D54Yvf5DfrCigh467KGx08AMGzeDgi1qrIpeHyiRhctMCivchB-HsICuP7dw5s3imJRh2Gcq92E35XyBBrlmivucmswQ71NIUOD7dmCgoS7rfrHYkPd9L1OybePC-S4xXsFqj7Bm5CPv7YvqHZeb_757N-j0wAsqQUTXXOzJoXKdOtLATGds50zHtvHfd94h2gM8dOHxtXNjEN5xUZrRVcJzCQ_idW41EFppyXOkUtLkz-uyb9FCaGpPpMTcFfFBUWXmEjK3odxO1P03ITL0_RmtT1bQdcnueu5wk9Q_SqY1RkE5f6HbieBL5oy-iScSLCkof9KsU8zg_bXCwohSc3x4xMvy3G09srAOLWTrmWlN094hx-DJCU58HG19ycc1k1KQF1-RHPYCe1LbLHEQujroFAOs6-wt00m81yQ-o7ecVx98e0JJqL3PxF6Y8tLJXhIkhxarHnpFObVcJ_8t6wg35onCTqBK0Hb7vrU6zMwm6v1pM50edsC2l8MC5eQlXLauYPOPJyNWNvljK6IK93Z0olH56LJ7fOmYpicOF7o74baZg-xZw4gSm4-uLlv0uGTO_REC2VJmIDFJkOTjMecp9a_f37tUuBnT_hfKGZyKzE2HmsE-4lYvv8J0WalBgBYdtkoCrNfelAtRkfUzaN31XJ8sCfdgXjMVz3uxNedqhEoQlfOPxFYqn9XHjjuxstxdYtMnNz3mtja83absdFP5H3wDA9fWFyuZHuU2pIsOrPlhuAiVJ7N4X6wM1BMOykMKRTmpkXiZT4QLYZlYfPb691-MwuOFBPYXY4Jb2u_ankZKFr8LY5Da3PIOGRuUV00-x6e_A8x-CJcMb6FJrBwKZYmGV_Fk0c8UlyaMRASrm-q7he5uBwnybv7evVAV3xrjvRK9ZSrNAXX2eEU3jAAkf9rO4Y3ll2dWo1nuH2UZLYZjfVnrir6pwuW4WFKZyBUeikjzJ7buYnrqo-jE50yuXM9058u6xvu6RlwO5iIj6raQETPgRmhheXUKYC4jqg1s2-8uMoQKAC6Zlo7FOgsTh1S7kx7lBZ0tbjA5dxT72EyMizlePmE2tf7DA-ut1j9IYmq3bIDdP9SZIqYyz-rP8koUmnDNDbxGkXIonutZMtCRnRXQbBT7Mlh52elPF5EF-cNw_T1XUD66XTciAyDzw1zwtn90bqOlbcC3v-f0OKiZJUkZ53VZyRHutdOVyZ4TRn2bD0tejbyxSx0fAnsTyEpoEB8xytRvXMucRbSBxr3scQR5pW42HyE9hk13uC1n5M1PLoH86C-fVIIzCmpwhLpQK0AY7D8tuzVfH6GP1PvBgvwfnFtiadEMmBCJKu1wIz19KPAIXSxGXdlegERy1AQWlONP83NfppO3saAXBA8iYqviBNaUbz3I7QIOC47vi-GbGIwBHTuZLk3sSVFeZaCr-hV-4s9PmoFuvcbN-396XhhQU6djBJ2wONKCJnLtehY4Ip3YDMPMWv2iUNfR0NQZXH2D99YCpoMygNjAGQF8febpOQ5IpRgHjGgrZoGmwowUo7HMBVhCsUEiivr6Hj1sYsUlXJW1T5F6xxs8EmL9jKAp6FBt91CW3dykhaMGZ4g1y1BWoZhltmQE_jNhcTHY1virUd-fc21QtwnIOj-8ABGl_Xh5DPI4dixCF4KoGi4vyjs5Pirn2dTcTAWw3NhUozQgACYKe_W6NtHMr6QjMfTaWev9-aDD3ndRPcyspZqg8JtlSk_kIYC5X6SRssJuNCdjOd_7OTXU3dixIS_O2e-baxqMkuHPIXUUlYYKD7RWMl83inosdhNdafolmhv_IJ5NqzsXYVt6TosS2vWLeIKP5845ilDVSjyL8FnhwY-oX6ER9DxRO3XybhsJ8mshEgfTdofCmzx-v2nfpr9RnelUnW2kFVjMvOkgi2hTsZ1itxx4k20XbbTTK0hf278qWm9LV8wWYBl8bXVNq_l8geVO6HREj4lwBUC13u1oJlUL8QXMM5k_su0Wl-XW_43sN2aMdNN2XQ2N1wcv5rFNvxt6rCmxCOBQFenocZsnDhEB77o0D1rBrhtru_78ldduCBZJeeKXEf9b-LvNYMDYqaK0cGrQO6l65Y1kjvFTQTYnv3wspevE5DA3DmENCnAu28_Xpn_wbNKM_jNdQw-ngrA9flR5ta0u-YWFUL29C9v3h8ORAulndlxyG_RM6eHTY-8ScEVdRrf0HgevyISu8JM_ljKsOb-4TeCFBF1r34Vw5xpTjT9KYGh2bzgjcU0QL-3n-MjW0mXuhHfQTTDe_i6KGlG3ci8nvbGxFUxYWLOl1GJKTptBfcuka5apiVerl7rVzP46DTUoH95pGy6FGUE1IbS-aLtSmUpQr9HMpDi9-frdSYoZNGkizuTyPigg8xf3uPrk0l-b8CceVhCBphLMw6E0pByQlUiRiBlpsNzPBFxrN22hEic1MOD6EBwKrsk81y_NVEapmnf9Mh6iQFzf3vbIASSGBrEGoo6AuuZNY-9hVZsuFagOzGIZFs2TZXU3CPcW7LUCBt4aKNPPd_V9BEc_CXgmWa2Q7YiyTZQvqt1y8hDPgrSfYTGwpMGTDQldjF2fsENrMeIc9oNjXsuqQdIvX_hEi-wQmUgqc9apg2GrPhtsqtd0NVl7JQUiRhRg_aaioSMggCcIBN1_zbHRYDqveZuMcD2RR9W7HjRyfFA5L6_pABHAEa8Uo_pMvCRKEEhwZVfV4M1Tkxoy2RmJCRDCWFs1XnMae4xmpzkGdz4OI0ff_MqX8I7z06E6_ZygpJFvfAyLhJlSulcBb5VxyygzS3uQXSqlzGAkeO6c0h_FUTLCP7JeFb3wAJq6IhmeXtJRgQN3FBk9jgLo962mJNU-QuR2a0hTIXH7eXHRwNRgdXJZAV5N2ejL69zFQuseD20s3JkudsAMoW9v-bwKA2zdPFAp7edy-H8sBLdmsTbPeT28VdCWPy4-b3P0NteCxxZl409AWowGrwoYDLB9bRtQw4SAjErFlZtdQYNXQuUIZpfMv0Ws3rHsJ6fbJJVdO9OMKIAsaDRmuuusGX5sV2yzWT2I45PZ40pB8sUUWqf2K6NZUl7cagLEi-bkzkKNbQdySsEsg1KkJ_t5Lqa76L7wIphBdo3k49LuqM11MF3gXJ4gejObn6ANAhOLAM0Q6kjG1cXpIq0g0qL-iV3lrgejh60FQFdWsLJFaipFSbfs7ngjLrDDC1fiAvmUk0EuhWNGiVA_HTUUcFXcvdYmnNyl0DKmfVpGHI9gQnBbHzPlkNrKXMuyS9kT9tiGNAWRhHgDu-vAq1Whp49iQOGugnTMCZRuDxkMWoH3wttKWAwSUm1Tc-_uUR0CM1oum6hnt6QfqycknhZmNxziyD2tmbzkbeQiyOCoIpmi3xIfxHCA-TBqmTNCgbXMDazv8pL9VC_38rYX5-FNVtn7mJGq0mPseTPHhSOrL6P47DutYnLpqOuhp--1h5qzlyVniMhQMjOQo_yRpjrl6X2olNMgS39LCnhVD8EZNALPx2bTy_4_lVVwC58coHyj27QiTTmXMArXbK9mw7_fbzmp8aKV4Tv85iqfFT3y3JwtBKwyCWapMWlZ8l4jU_-t8FLNqd45ynvxS7tbyyHfrZvPyb3uezJw2PUqeuQY_RferXFQu1eHDUTGOuT56h-cZ_tTfzJtAq-M4O1hpyDgRjWjwFAi9RveUEGnmbMPca6J3DuauEBX51GQ_NBtjFdEmEz8zOy6vBaetE-D6P1r6hGeYXOyx-iq-HMUnWb78m1emqyNXASSNZdRCF0OF0n4aIILJjxk4JDZA_Kx9uNm_9xy4WhOwRjOeabkTQ5UYQM5PQglLWsqLR7brQ9sEX8DC81-yUrrhhPlM_m1ev_fL827DVBe0K9M2aaSIolvoOFhnN9VyYIkWT2hyD0nbMrPpJbs1kP1wfXMUTK1FWoC5oHCmDZaG1UAly3i-JOTcWoLWJ2wPN3sCITCONnwq7whANQZCEgyIK4a_fOw4jIj_8OhbBUu8QZfEkHWqtZ0zmiTy3rpec0hnWAuz04JSt4WYbJ2tsR-wqiN5B9dWbhRdcAiKO2Diwe5I04h3qWou8UdtNayRNQ5m5dqbNayLw8sAIlMKens4QSQ3jVZ_7b9yh-RnMwUpwABHCgph1EaHtqVqZAKRKJ6m6NAfuCHv-aEzAtKXNDfKqEpTPulUD7xfOlQeyV1VE2xp6lgBihvICHlhG4GUtD6TD0qV3OV2b1H-OfLyplCzFxTfoTFlAd5E2pYbP4u-vSOnNQUH4z8IHuh38ZJIZ4Lkkya1hm1NidPBHs4dEx7pruqcjAJMWxhuxOLPYXG7wAD1Rer_iZNkYhRYAxRd-4VRV2FyYZzpJ-WneNivtK8SIzZyrukeg2feksMdIsLQkY1qck6F4HVhAnyCj5lM91Sv90Vl-BWFqr5pbBe46KizBG4DfaDh1MlMc4waQQBhzgK7jsG0KNTVVsFcahEj5fAdfvRNbiG3WXhu8TAfKZ4i9bKZWLNzh-ZEBhsHimhXXEC9XJFgvMKBR3XLIbnsZKFQaerSW_rAuFC0hww5Sd6R35iBODuiLh_zfqzDQcNdexzrrJHxDWk5939Rc0GM8NI1ZaBAmkwAZG-AMn5SDuQX8ODGHaU299WhWBp629DQdPYHF30wGZyc96fGboVVDJI2ThdyUPkZAvMMZKRN9n8efPLmeSvL8RGsMUpUnCnnRv7h9MqRuPLQTgn1rKNHPJUO763cqSO4j8jbPFUUjdr5t0WOzxn_5faoDXfuB_HBSWzIp7QiKDjJW6TL8q-S7WpAXWFCvu68x6r_PTk7JyZeiNQZVuzIY-4HLnPOMH3ExcA3mJ4GjfzGJj6PXRgz7v_VMtIDnM3dSCUBthLENh2LxYqqbe-8BWgCaBG-WQCDaScRnrBo0FXtM4d56BgaqrYXEL68aL4Y-3wfg6T0Ido2Mdphkg3eRI3kT_93sdS2FMYW8LuVcOfb7PuWZdurQTQQ4dDnRvRLssUz19BGFgx9qhtVM6UlSMXPNAOghZe-Kg7njxZILSMNfzoi0bhCW9Kot5lzhDVvfoBGwOQIVr2DFWIFJmod54ue0DLwePmPET2yvbrGQi5SVttad3t_Z0gtB3D7-7zUv6ofdxhXe3LKnDDFsVs8C6smJiY-lNxC_FGRo-rLIDAxD_btY2PPm-J68HE599PAhwFPvVOxzGXf0GMzQbHUYIDK0klQ2x2ND2ajXSIp5fexsPZArOqvz9w6dykyyxnV2jTyOpgR_OUNtt2MhTspg2gI1ewbBVcJttEb6RDrnHo25kRmr7OZ47ONACM5S1B8V7a3TYNP3IEp432MDimB80b7YX3LyKMM5GkDnswx8PupG5f7RhkeP8fXWDYdj13I5fb3VeQEfy6ODRMH9SHkNBLHU7wg36xSKxJuqLqwzFQjTjGDBbGx2tHkUwsrs0kYKWGOIqICY7z2QdHp4mqS1PHLxBX-8_GWDHOZwBgPNaMcQ_4vlOR1Wu71jEUN3VHXZJ633drvMjvh8823oNkZOGuZEjjsIUAWZnQ3aEJ38vHtlQXw5jBanMUUYM2zs3SZn1EhpLWqyLiuFhv0JclRqqg47KmaH4fh-QaWTWsMVQSd2h9FzaBbWx5ujrSBWZi6BTnYEAQQdPnR4qOa6AgoX7Uk0wYwbFrPHboqtYE2HREv5tVBKd9wpJnuZdwVEcWINRBHA2fnFYrchZQu4UADd0RkSuZ74COlPSE-a4s44YgXku0eOTkHal7t54YimkpnjcTmp8vG8HSqYQqU52jgwqUz8LUAEYFDhXT3oXZSajEh-IL5urfBw46OLBvAqQsjTUzn8CF3NSaNKDfFTZJKUM8ASHWnt0PWgNs5DxH4dWOyZMPRqY5bOSAjKakO6Sy_ThOGypnjmmVRmfiMZc4SPelUiFksCCyyCDnLkElSj3ehNRpSGXK-d0XldduJQ8a0ceYWB7n2zvZVaC0Z9CFuHlvIsnKsmKEx8URb-V_qabCL0KVJpjCh2R4PAX1uRevxFoOnq9I8nUOjdRWz7cUqfqsdSok-ZYhP6mhs9JTF8O4INsA4vOZGxcUq7EWfF3Mjk7Czg2JToWLVQETgmertSt2CzhhaPTg-PXaoAGe8l-JbCjaHogX7uuISnw7SjGR8wJbAJ1lkF7rpW_rTHTeQN1V7OuIWekVY6Bw9rj-H1nVysWWeJEc-ak3AjmjBhv_1h4uVHR6J6WOIfE9FZByaOrZyYmxwbQ-w3iaYaTgK1vJBFaJkDcPhIet_wSH9osLhNb6Vde_hkLE3RcBa3Yo5qfbJkc3vR_mWWkXGvR1srVHBOcTOtKH_g2CGFmpB0atmBB2-JlozgSVSI5i8y5aHcjNlX3KKkTgKUlNnLyohvWF_Rd9WNEmRRXzt34XnXDr_DENj6ve9FME_auj7nRD4vN_wLZPly9Fg13cRXpbEbEw-_PXYqT2glKF0VzR_XLQZF16U9oF1RvUrGRBV2_cQG6ssqrUsC-kTUPQFl430tZEwkqJ4LsHKZbmG_KXew_6v6f5mX6OIq7SQ0hkp2IR_je34j5AXP0MMNDhAYPrqm7BU0SyX-bLPJo9sbH2ELW5Ed97Y8wWHJ5tTXAB_d-60Ft7mkPaFgQyB1BZbVWEUpxRh_7k_bDOBscrTzlG4Q7bmkLUvJGjNPLzTyH5Ix4jfpWvpvF7rW8_t_MeiiI1SRQrbZr9mAvwNisOqGx6jpXslnp5Eo9yDkE1uO1UitVC2XgLphxI-Tg30_MK12TUctei4tC8Ii5dZZLs-pFGW-z3zCjJ4jFSU8MmA4Q72EwJ_dTrOuKRiDcYzsD3xIj56KdfDB4AFgi58LkqTqlU0F7CB5WBebCB3QmrDesJVATJfRgevUeeXDZ-1z7NLXS5VBTzd15wqkRyggWH4mVExcKuDjjxJ6mrPg4cmu-2kqVC9M5KvLOFhw7uxB_ILUHQ57axFsWoaslIbvXoFMsQZGIqTYMFEs3lQgKm1DigasYorHcWketWY4o-yW-7XX26mpvSnAywbEFQU5PZOZBAJlOV41ll9lK7_vlGq3a-PDWuB6v2e5L1eKpR2yBFbO8wrGyIGLrLJmYTnZNIiDw= \ No newline at end of file diff --git a/backups/backup_v2docker_20250621_174144_encrypted.sql.gz.enc b/backups/backup_v2docker_20250621_174144_encrypted.sql.gz.enc deleted file mode 100644 index d1a7764..0000000 --- a/backups/backup_v2docker_20250621_174144_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoVtK4i6VJCYK15iDhZOT9oqag5DMQgJO27TbQUFtzIBpDxC9bjFSrWDExaUeGl5FvQ9pxa9CgZIwPzdJ9n-Caozkj0YzfIjmSb02pWKibcOsORxtMEwKvsw32Yo1PrfDk6YGXUq0gbeNnRXH4ZcWwm-hYathWRm9YbNXfnA3q1JXurzY7V7bHlDSdBbtW-ZbryFZWgGWGEZr1Po6MTMnYVBW40M4D2nZGDVEfQwY05Mdl1dEdBv5jeSuywRzdSu7Cbn3xQk89D5Ts_J3QEpoiDCtNxc59hnNx7xBDHTKt9k5ZtSl7wP-F0bVGstFZdbusBWeKWyMOq_2LhddPzmwFAM-1hr5ZTui5-3TxHPaomnMAGlWHlUFFfyKTrVtoYgQvqlMZtn33ravyMIqvShrW2z7OH3CJDLEYG4QhEdp1kczZuSMFyGQKmZudD_pb5G73fIkmgW0OKnvOrz7m651B7xVYFku29pEiA3tGQ7m3g2by2CrtIoBp7g_1aWXYLrSOs9JPHcM9hlnpyctLga9pnv8CdFxDvwpXB8i4hNp1y0XOEDPvSfm3ZAw_v5gc35bi1mYs6GtkfllFVvBHU7g4IyoV5tRoxY-wm_n-Mh5vzGcWkAUm3vmfeKYJVzJ-7PQHz36-N63KOHiwh_uh7wDxPKi2oAo1Yu38xKoXn5dWsCmiRHKFwSL05NjblH6KT5o6oaTC9hr9dc9-Qbx2eWj_7zH0AaDIA8KjQijRdeeXFmxx8Ph7008VFMCYfQaZJE8JML4HGNMzifLaOunW_vgW_q-O-x2gsbriBle-gTdfrTM6oXaBQjFh8hEFC8pxqGvFeJGTuv9prlVrIwvbZuBglPwyNs1DiRN61hPnqYvpGrMlYpTHjp4Yje0dA24QCcjf08KAcYHSeSIsnS-5FSnmEliKYM13CI81aBDzxxBLKgrHsrJWrKnRQQko5eSaRMpUQW5gIedpwCdgpaQT_pE6S30V1mu_aH12wOPKbRLf3WrrMtrATBdWgshH3-vS8u2RupFbRKwAGiJieanJUzgqo2yk2PG6qNsl46ElLdg0_sAxAyUd5yMNSLYgoqhE7GQ8zmqBHjxOiq0cVhhLaL3uG6hyJfyhKfXaPtJ9Q55iBcA3B5DVFiXmmv3l7uVwgtjPaBNrns6eii4GauniIlkJukI4ciUu-GPynI4ezt_UmBsE2DVCPfL2s24V4W_824u3w-uejq3lhjkkiwozucecKMECdNNBQ6t84XnXWuqjf1ONV2F64Sy4ZuojYfkmtjvIQTb4aAdg7XEadJGiuxKhS_6Kp4BtUlDc62L2ixVE0YaAKX3O-k4Uh64sArGsGWvDaT0TLTzG7MGwR8f3CNl-xRHezvXDM7XeGx_tlLoM4bqsvx6ba6dL3P7eiaHysPn52aztSdVuYI8RjDpEAqty6LGmnbiPMPXFrACK6x1kmhDsfn6X2m9Mjo5TeHfcDoeA9-i7Ixw7IkxsNCbFq2cYoauaRk7zS7B99mHZAhhYQHjMqzuZZffwzi0iSelyY3xbZa-2n38QYCDzIL3-5asUZDY_OgTlKxHMNb6aK6ASognd2y6DHlD9PmE7I6rryWq2W0vShMM8Jn9parevMWFH-ZzAQntnW8XeigcFQzu9WHSEo9BaAbuu9r4nU2v1lAs2T840GQcDljPL9SUeGeDK8eDrvjOkKgdG-V6hV80rUCZgOQQmYndPXgiGrndsHAiNC-svs-X-F3Z5x6i-O_VXyCjEekVEBna6nShRFcwcdQ0coM1Oh5YvpeZ8nJDXKhn9gwpjNdC_HyZu4q8fwduWkiRd0sUS5V8gAt252sR66TUX_amy6_LAitY-dcgtezL_twOe9Ezh87C5IoUjTHO6ibFeVSXsOaxRDwX-dEATopgiY3e_lvtGO1T9vxf76eAJz1N2OAsc_PHeNWn2pN7gh58YaKg7Asar8O8BRoZve8Jn4Q-i5z6y7fzlSyJmoll_iBggfgtoBNtmSvHy9qF5TEpZSItJIXh0idoUTimxVXvA_lNXZEixMpZl6ehTNPU_EwDrXBO4G8f3_3ZnoIxODPljcQDRt0RyGU-muCT8A0GAKezAu5nmCEoiQn2D-48S7qJ6KODdmoX2fRPUrTu5044Xrq81cblCXTv4U3orKh0KlOVTt6229_PTxVzPX1c3XWEpVl0egaY3xbohCuGrlqyURsQrORQnHwSqeftZQBiTl35oCvy92TUuGn-6-i8s0Ke4tIwxA30pNc38n5MVQT0Q7fYl0YZjL6zPHMAvTwWjXLa20o-siXlTtxbN9Sza9opfn9fvpPF-UysAKO5ga6q18zln-z9xtfHESznccH8XXa_x68_cqMm3YHW8GfnbrvQL_OkBumxtRQbX-U04D-il8ohTIV51rNgdSCoKGGKX6VBNqhy2IAUsmNc6q5i1j0YR67FsvLW5uLRpaJrZF55N7P-jT_irRITCc1LoXM2fVrN5oxal1COP1WivG5v_-rmp0UQtw1l_NnYXXhJrVO2dW15uMIIbtzB4Qm0Yu9NvUUPKcZIRRNx6VqjgA8bPzvHMmacCUSNRSPtXTGURDVxr2tPQBdMJOGp9DWVADhGUClhOvwG5aHnoflmUtgpQIG7uOhOq1rCjeqrhssYezXuBDXJSRkAwyqT4fXvT30PDRsQvlslVKKfW1pm1d2dPdyeH_NesZxF-0APkPIf-WYXpO_6gSs-0rLJkA9Jh5418Xw7UGNNkzkkdWL6CS8OqwQm3-oFIfWOvBnYR9R9s69BNdAxyVyP7c3LAbFKKITSmzCGiKJhkwLhB4DfFRHtDQSc4-uQoqC6vaJEyXCiTY_KqTgIV5o4QsTs3K-NJJBIKtLdq79Sa9wvCAuLjcTnbZtkVtLfKKSzBwuO7-nYKNxwlWhgp9sz1vInxE6sgSPgjyemd6j_6HMXhKZ-I8hI5cCYXU4KOgjZVGoWjm6UyGlazzFnlAvt4s2V-80quVTkEWPc9KAe_nuMyUhBRjq98l1rynGDGz97saThdF955JaouJgXDqflmK8XUGy2vMbuS-72q10Lz8Fgx2Wh1dhmffYdl4PdjFG0ssnoZ4fEbXw8e-o61i15NgQIWKJcQWCUrBkPk7vOBK3anLQAT8HGOSmiH2ERme-6YT8OBwUMfmrX0Y3EUoB_tAGLL6lFLrSam438aV-txY9AoBDTQ3lv7m49Q5FPBZmOSBtQ13A5t0cQ7lDNS8igz0C_J3vsvBbxFFcTeG6CbIXtQKfrEXNpdqaqirk-xQS1C2oKeJcV3Z3QSQNQzy0AvNQwTvjAT3x249u7Vd2B5o-R5OElo0-gA4dhlTtS2DBuJArhu47ky32rx275iT1kbEcFPNCo1IuKczngOvw0m_kKSgY4CRwtAYRsRj4QTBs-uDJgN9cUwkrp6Eta_WN3Lj3IDh2f6IK9PR0I4va8YcidyGiNHCBy0oZ7XGWE-o3Ug9ptVJJ2cBtoSF_p7ALYgq5I62V_IvEn7AsQ09_mBs_84I1-GwKFqv_y7GCSMnPKyrOBTMryhraYPonFfR5MW8xp6IcvkdGeqQ9kVRpQelnhxQswJjUxbgInODDEPJBbSBLKCCquoR1Nr0H76x7KnEMtZd6sIUkVh9hXiFObCJFenziqzYE4jc0iyvT0SNeVlwiGkAUnZ5D2j2OI6_x4PSXth2-an99Gv9cSXJeuoqhCMpitSxduTI_Y8Mbw4UG7q-5LSXQUG_l2mLZ9A4XZ3V42EvoSf5c1x0ymtXk4IF_kVI4mDJqo2K7TVd9EgQFh-NPN4JcTp0TV3em7CYHFcVf27F_aX4-bl9TI_YtFzCa0-t1cDYvEcufbUkT3XiCvyO_aM-E3RjuZ-Pj-HnreqwL4zE8g_SGQmv8SskbIdnI5Og2KEiwmq9OJZC3q_Z9OwbYXArIvtaqB7RQMbI0u5kYDPEphXbrhsgxZUQdx1Zt3r8pbx3S4XWuwPlaRLvyqLyLMJ1f-1zc9VSMqPQb3GwynTaIP087bjWUNWw5pKSiXRTsEgvmoM3BjXBcdgG7SXt8R9ZirYgkV8WzDPAfsP-uWgDhujTd_UP0difTdDaDcvcf8aab-O8kHuXCoCMhvtk06rCW2Ad8RC-58yLQMD9Fg1dyCAGLDv7Ocy7e0D64vkZnd4AIzxpl0iuIhZDukZi_shd6GPkGTZTTQ9h1FOZVN567Xw9Czn2x7lzBnYIyK7hVaVuQ1roI69gyxXKjltk57GX1XJ-Cn6wEUq9r2V0EQLW3CffUafogkF83KsXjCqs5NB95wHrAKYhphoUlDZbU3c2nZvdVfa8mhmxlcB6UGw5ijZrpYnLfYAuCiG6cPPP5-HkUj6cL26QaHUxacStDBnCczFdg4frvIcHwYyghkJSNWM7DaSLGLJWtiLrAEM7xgC5M9KZUb5nmULo3ZggOcrQq_Xi5UIu_Ciu0z4XxiFZzOw9epq0_728e_ZWCWzu6GK8DMgKjrtabpD_ASOwaxZC9Iep2HlUyDi4BO80C4JS6_qQXj-P2xx02E07ft2pyeFdC7teSZh1_bCxGuJrgzy2kfZzS5U-FocrW2Mx6c6OktHIT_OC_vA3B8YiQtEy9YJZN9WOSns1V7IRda_4KCHCJVPd0lWgyekf9egRgYZKtXb7aAsN-R_d8gMvYj6vOddRMGmgzAXN4_NNoS-tXJ6Wd2fvYIPyLvR_hdSslGEFPnsX5RoUT6WEAjVURskszp-5P1JwnkwL3JOYBAk0ZXl7CXl7DXQhOpjlRpB4pWYO4Y3etBbcFB5w-oEG6L6VCc-h5lWT0d61FMxvCXXstMxnQJkMn9Q3AS8aXyE-RPT2GfcTvPRqB8nE55NurCjKtl5wNfVWF5pu72_TnMWip2Ug7F3ZvhUWL8tvjdUA5XV0PsUJW-h6HvxaaOgFkdBrEqcAiOs8kOHMDscUHeK8Q879HT8vRSe4mHL0Hvs9cLeSlap0BI1lnQUNd3TfltZeThJLUfN8FkXEkqinpVuVFzCb4nZ21qdXqIz6JoUKnBlgOcDb6T3mnt1eZBZ9kh-gj9H1V16VycB8zLuOz5-G3MImUSDByI1Oz5yANmnZh0lAKvH5xDhhW6ee-Ce2dvbxPRHUwSmTxbOP_xaDeq2-VTkmXel80Im8spxizk6bRWRedEvm1QHHeMXGY2Ku1i0GlAhhQmATgBMRWzGoYciV5pZGfQJxwRIF_ymcFXQMgZQqJYgrZxrbD5EV1c4fRpYya7jgAXkJIjZWJ0YqKVU8MIkA-J0BNwlrK6uQyR4acy2LENquGBJghoqYtQQvzp-4u-jV3iKuTLNlGdcshs7Y93_QYyybXMy8ntqeMYtvFdQCGWzyK2WgVEaONssur3DIfc2TjchnFCY9SlpBdItSEmmSmeHMEUNN7OV4m-eb0a9LEsnzKNF1HGjZpVokOarZrLcuZL5mNjl63xr4P22ZSMq7JdCPy0hIJH-e3MF-qpEBFfZL2ggBS6tZMyxJ5zKozw1rlzpIB3Fj6SKq-4hrRKgBbTcsQ-qULHHTSeN9JoXoY_JTHbHOtO9f9Jjt2XKEc0foLLmG-xOwbOoBCo48GxFvTOBq7JrJfM3Sxzs6hgxGkyoqK52kOm8Qj3926v-DW2FjCsjNRyx5KYgkXE6a06dK-W2GQp8MI2Wc7PHwP4S2yuKk_5LZCZ2qLUNdAoyEi-hzBa4xthEiJvHcnvN9loJ-hRl8pi8dFChq0C6o3_qo164YSwDIGEJNNhsf1Y-yYHAHlc5AqQQbwMvFGLSNgOX3mELSwlzZvBte9wCl_2ovp46aQNCAm9gt5uOjfBaSJr-fBB1f68GAqOsaE6nyesd2ZAE1uZT1F9tcb4TNJaEjOwZSsh_o5Iv60YDq-2DACvfpwBUWLXGOVQ-ZBOecrte3bbkngV5map-zIgfxXzsG0ljBaWBg2-jSjdJemxZ71_IeqVEQrPDehFQuPYP2zZgbLVx3P2v9PkX01er-hLymXL3MYCqXHAADuAhusLiBczyBB1oOYfWNte7t6HxGiIGZf6NiJk9EXUEsZw9KdiOzZL-o8x0LsPSS3AmVRWpwzvSzWOmD-3DAQoBi_6Qj-O_PvnkRSBU6O_SmztpYHhFN9FGpkZ-YR2oateyhUzNIqKWx4VoNDzW95nTn2CqE96B7EKYzikwBBASr6z7GfhitEw4iBA5Okj44XF0j-ZoCbMI3jDJeMStKhdu46eol5Zi1rRXVcZNJZfxTJ9zq_2w65T1_G1OjX_VfjQGH2Pmfv156cielWRPK6OJ7KAJLAAUO_IRhdB0mbG814pjedAGDEt4bJ6zjaa0RDBrH6gY0KN4KHc8yWwNjP1XcNO3p8kKihY4MYr-eFyuxC89h4gaELEPwzwLq4qPNkTCVJO27KC4MjnD8TZgA7KRJP-UCCAOKw8blasQBMhrMIrpgtV8nefs04siX6SLznFMv6tPpU3Z2o1Fndey1qNz6PIbYL4lr-QWUnq9tw6hReUfyU023sgcZxAZEhd0IxNk9JiwmmcEnwIUwq4SE-U2-4l0pnjmQ5eYUMOKI5WEcrFi6NGsOAFT6kagdjcGGlXF5Ry2qDvvJEO1-Q9X_pqZ_wJuHv6DGzgIsjBa7w2mBNV-yv03FuRGXdxm79rBlRd07iK8Pgrf89qYWqAOMW4wvRlU3pLTZHo8OKoPhC_ziOJWoUhvn7VXT40_c6UHdwUAPhyTa4yECCD0OBAuivEvyhu8T3Cv96-38S8lnsxtU68p3lNTOyjPWcSAEi1hpni_VGHiBoqJHjXq-HI0M08yYQprn-BcdQ_eIc2ROCR3JCNchO9YAjyNIIpwv42LEK8LHcQxw6onReJLKXObdTKjJfZepKxIb47WGuOmGBRXALowLL82K7HRBMqoQUmHxRa920ltiDUX8h5LOEpbMyYnHIDjfX1JWU909cAW5kIzUHbIdi4exTkqOW-AjTKtvoinn0kLI_XFm-hUThl7pbXisvsrc0W2tBn84TatAUsMCXSX7595vhm9KdaLz4tQ3SN37IWcCNRRNqTs601newY-mt6wfc25zmPkSYdYV6qjIRdpq7wcOvohnZYfQekoiO3uLUXcvJgLrTP4-YWijKd_NF5SRPEGIbTPKFIOPFNez8JUjuTDOAdDu4I3kd2QeONQ4jwv6jSGzHQEcr3yS4ydWYobK36KHP359WrxpclPQahy7tAg3Iy-DyNEOkV9pXICX9ymZclHWxCZJ9KStZqUcA331XqryoloEIGp3MOj1blui5rBF5nfJl7j8Vcng7LraSnzYb1MATaxDgNE0Rn0_5lVq8mOSyB0YJ3oCvdadIZ1VZipDXklj8DbOqmte9B7Vr0I1_vta3qfpmKomy2rAnNvPM1QNhVd4KEcteSDYhjryba_kUt7T3peDilsj69BhEW_mvQP8XaNOdr54I7H_4PNs27VudnIrnuonjDTYpgagG7ayOxOKEp29dhq4ZBROJbuO89cx4AOdT12xVI9tyTTSibA3VD3PhYYEqr82oQimtDpi8yt5LTvLBvg1BnXilU6itbXpR_rH2sHVSwfsjGUTFe4_ehD2wfCUdqEA8I26gHUPxxTDQ72_NGySL-2KfWa_PfFx-83rHCaNWBhE_MGfXbDnXUjRNqPUbwBEMhltzzE0kfPUs1wR1VSPof_g8oJwyF2f00A78d0eiQRIaWV0_UyLdNBEpUHexdvRx_lJRdRJ1BecI5c6sW7TRbqGq3pXDdbrHV_1ZMkIJmchK6pbpcLF0EKJ4vGeh2BoVSS_CcbmyLLLrEm4c2AeXm5jIaQfM5wmy-2ZLv2KxtAi_FYCIOkys5TXqKnwaEJ88jPxunluJk3L45jb_JGPPEg3QnFbvrAPJGiwcu_7p9BUihen2PPV_vPauU4WUNi_P6x5_rflXc-rJ_iHYIp-2eDMFc83ojySX1C6IfIjFbFYe1qxpFGkao11rYbWrjYE4dICB5IsSAoAmnYhvsA_E-ut_3-ND3LSeWAuIG8hobT8E_FuwKpIy3v-UoeBpCOogcf8DUlAIUpvBQC8wfsYFu80XCExiP-YUWjaFP318lx6R36B7OKqL9sVV9MbbZypOTvibDJKuZozX6KOhQ-Lv-J4Lo8bEPxgx7WkbhJ97Z9N3BaPsqKgDDbBeEokCdMOJ8KdQU4d7UtmdWDMJdfD6vpImrbKoX7F79TOfzaFURNn58zHBB7x4MAReRRy_7nAjAKYH0ZOzROSu02dVD3huiKz51bYPG73k43gX3kNs2NsBYvQPZkgXsBd0Tvr9Hlywpgbi1vjWpIzLg4gCn8-Hf9J-e3OUwGaYYJFbxpnZuC9yHdsg7XwC1D4d7xtUcicaweKoEadRaU04JzChK38CRgxPF7XCOXpa_NIMn01E4JjEQS_A66MufOpbpABeBg4e3BFi8iZCa7_pVmj820muEdgSLYGEAiQl6Uxw9GxuprD4r1AaqG_6QCk9OdN-2dJHP7xID2_44wkyj8JZj3sn2bjZrTrTRDufVrVxF6YoSXcwXDMXtmXI-XpR9_Ek5twJgdA2DOLmX50BMDkpOJFtDtztT469liWxCH1gPS4_eEG2GAG0dCqXbacjj8U9a1nnR6cuBjhN_PObvIFhQPdjZ0aCEuyQKi4-4GSnN3kPlcz7U5QvgIyhp4_jY_F0msnD9st5R5r07wGMv-y4Rawj0KXgabYeNxePut2qcxwubhLLi_B8gWzUuwFSdp1EwlKTKOPXPTZFax0OaaVcjSI6FyxpyQEXau125o5Sq1BtrOwW3ggq7UZ5Wh-f9ECVs8rZ63vr01qRyoCT8OV65z_zF3UgyWL1JraJ8tCVEF09vwiTIyEt-XB5d1IMs4hDzLkpWMNsSUdAsPVRGfGIKbNcQcFd5Rxo-m9zuRGzIx2WqhVyXThMAqrBPhm-62U-wpYnYDUvtwux5IdZEtJhYRHumc6Az7X_q0-v05_sOBOrOPX_WBWhIAQC5hjRW8wDDM5DnWokdE2gVxsW0FX_YojMjXRRIBZ0POkeE8MhupjNzcxk3YoUASYZ9M13yMz91eCoBQ_I91bx0IpZaqg3vSn05XGddkEyrPPdfh9UadhNN9MiQxKZ7vRptZVnFa2LW7l2nj2Y2bF3Zw-uXpF-LkeFAR3P2MlGmbRQ2s8R3Hj-tlRKOvkyerMvlUtTQwhh_pof4S6xdiLzHntji7RWVNKuzV2h06HGTbzVv7IaWrZM9H6SCq85L_g5-lKNme3w3BgiQ7BrZsVuMMOn40Vc8vghuZ3kln0TPVEfqRnJlVzzKPdtMc30R_9yN6vVRSHkt-A7mtHluorjrY6UFaQ-Qf25roH4AE0F9dmolEF9cKhYiGUlbJk7k58cBFnTIqIuWE25JaYl270j8xgtl6h2HR40mJpWnrNTUc2FnaNiQuoLmSHZXUxdnkpDP1mpctmdze5mwmwyG4yuKtAO-1sckdyHm5uvrV7-YISrMMJCCeiUD5lcWF-BaNJQgwmhDAYsj-b7yNcpdsbdeO93mNu_Ic_LV7eXKDOf4avlghm2Cacp2RgB9Cuy-Bqd8sfL8kfMyQQnOIguFJEKJJ_v03n9JuFwH1q8yTVgJ3lvhalCkePix3-iWhLhS-n1xnh3db_x7OpSwl7YzqTVxdvkMaSxbjsyHWV2h3bdy2fT_6ady5wyVQp19XGEM-rOx-PZ1befuS54v3pwuk2cAXYLUxv7CQImWofJNC5kbHBI0C0BaeaXRdCKsJoFjU4Fwq0m2zed4OMVIl6vIx57SPh6RvyUJ6v8jqoEqcVWLnTP6PEyGh98YrYTuTYF9tJL1YW8ZhiKIbaXgP_Q7fKDJyqqCAbmwVsp2n9IDCoyZx79KSZTf8WBbzYufrsr8LfApsdKMzabzK5Z9VB-o1FTBkMSjeDnutMd99I8-uDf-ay7cdEKqjmLOWIDl7iud1q0ikbfm2kq8eh2rihiCybKPd0lCdOJpUX3CYLfKxra7tVOlf8HUcb4mg4GT5uiu7XTmQ5hTY0UhzU5EDsU0NlfxNgEGblXs_rnI6RkJGJ5nU9WFSTwdb5zlo9aeCtFuW4SWBAIBVtps--RqYsyzht5LEAnX36NEL1cAt5Oy46UYiBypf_Ld9i9X8Kt1SmmtoYq7VaozOv7v_JULNn5Omboc3XO2NVdf8K7kizx7gf4j1sKacukeqrZ29sctLamNcT9Uy7z_1vBAXED_N1BLi8HQIhoZuxfjmoWzNdlfFDI1_CsdDfV1K8eexJF31CI2P7YixoF6oyXjTReLT7WAKVgW7EWG9dK0ofA5nergh6JxQcN8BKR6LO7rD7bejik4AJVtZ3yLLMLSONQtWGAZoxsofP3HEHpCfeAZXRUg42CAYyGXjsGzRbpQONAaMevH5_Ad_WzLPnlKxIA7rvQxfMMZoGdPLOP0ZOoTu2y2y1AEgTq5Y73mEIgVoP5jdYVQVPqKfiNDZQ5fjEa8xBAmAonIJqy-vHe4W1QWU_uREhLMKU3EwjWUlkTv7uU6k2LbFcATDElILAa1ZkTkqaKhsKj4orpK-FMaCteHbFA5wplJt-00tyieLpj92GrY8KEGEvLrYdo-wwPBAGJCycjEphddFmd0LpJgXCFJSlJCa2YcXN1DvMvuuZ6xk6bwSx3EQjWOrxKUcbIXvMI30BhahJQAk2X4uuaePe-bSHbu5nsiAPpfpL8JbeYNU0RZxVx5nqTDWntWb4FMgTPQalYGO1Cps101VkNsV9FQ_lSEw-NchHQyf1k6meLmUO7UkGpzsCYNcCLh4-PcuH4V1PlN9ADle03L_dvIuyhdHdgLb7JEH_ZDbObJXkpZBYokNe0ewW76Jyx6TUUPsnC8gYzHfeFlnl2AkqvngIHrHtuPv0KPvGBl2PUUsUY7cR7rQfnsS_12EnrxtytzadY4MnmaJJqdic-h7OSXdgwDW0OVAjcwZ5P_xBWCqiAD7eYcjUz0VY2V316hgD9Q4rzQzL2uD21ExRGKO-c7IqhVf11ZCegeWWFn9uj-X22izYMq7u37JZE-M_5LPPhknGF7bTjJeiGDvmQEfcODj30QKPwWqqYc9Re1ivWnj6uuazI0Em4x_yjsP-q2tRYj8vKUfAiERMddobsr1MffhZQgBW1fJY5w7iCYRzDaiOBzZs_o7e_1Zhyx9fG5Tqw8yvz7jrE2jF5cZhnl6JcfGXqsR2tDMBt6iPpMMD5Ax7Dz_uEQqSEUrChpIC-gzqCzj-Ba_1e1WHP3CQJO9Z0DAn28MkfKQAmyV_A4W-rwIEGVF-tlxK6VcDWZQnugbVaoHO9JsD3NchAYgY8TO7jlw1yO2NTybhDMwlk5Z359BXznCoBHWXLXg7dJiUWaBr0yl9KZrqwlnYXdYi2DIojRcXeFfayOGvLH6_rPZDHj4s-t9gFezfWr7f0iMoey2GHQ5mk-PeFcsRZsrlmqrt6MyfObCdckjhbeJob2fizioZ1tRFRxc9X8MohY5wVvBW5hBHMNUtzjc06JdBulGDojfCLZQcRQ9hf_KVK81RkOdHvZZW5IGuWQy9noCfdE61_j86yXweEO6qtYiBVy7Y3BxM3BAChJZ1aEkYf-usjRE2ycIOdJPj8mGTigbrDnJLyNNrA2xFA4FKOhqJMKn-2MVkdFvwAovOM_ND5tjyn0S525kCnX9ipdHU_5Aax52iXfMXEnxGKvo9pAvLHTLBZdbE6vJb3r-xrrSJbwyid4NkgsZuR9x-tkrT5-X4B-EwwpiS2Hy2W7PVAxoSTRfJ3KLeOsCQU7FtuP8c3cfcPYZ5X40YEn4zZt_A8VbkHRgSFaLEzLW6xnFIYBVPxcSHNCfgyYqqNoeo7_uThUpm797Fs5Lfr6LeRVZyHuWPLiAdL-Xx9RH5pfd3aAUYNBdR53K3G-pS2BjD4cEwOjj3fDwYZ5MpiTIl3iXPeAUYZELf18nHKRccqxthNwO4y9MKgY7MPNmAi4fAapO7Q-ZaR1079pI8Pg8UERvIjz6pm7QllgF6-Sg97iMYJx5VWcCd-XuhI1m6_Y3X9P4onJ6WLp9t6GPqU-oWKMFuZTyBCUyqRuZpvD-Fe_O879c0SjGxjFcIya4YG05Ts3mJYShEQqWQmkq2z6R5LFopreKuhBSVv944HjfIo0aovn4ifoO_mJ6vrSt7jmArqszFQ48WSU-yxyy-JtSjEQ62box--AkBxY_I8Dt36iANwlpshVVatFVqpb__TwuQWhdNuFAkruwz1PDxuDmDnT9Vykyi5AaQS9igxTQ7kxfVt_NVe6N6vtp5b_OtbtVrOVGQTlP_FRdPGniw_flNvvkbkBK33lIfaZFVvTVZBoN41SRwrH3sbdPMVVm42VFJ2McqVYkUDVefsCXzntp4cUQlliHn08HCMXWfP9JwCevcLlT98HelmgKvrFgVZ4Fofso06Y00A73B0asxfYlNOs5o6hhF6xTbz_z1mPzaYrUfm5fTa5WQNlCA4WZLjmK7y9YXof9LezzeHkxtS6n0_WLWuYIQuUZutwJZAW-dAFrwAnS5gNiKRJ3BXzgXTt76nUtEBzA0_XWCQIE1heLVdyccaiI6tKmk-kj4C709sxmqELinEHl9ysS3ibDh7SsIXJp0q-KwORSoKGbeyX6csHWpfkkSR8SXUq8pK6zYWDgxi5B_5mfmh51cmqpYMLEwo9RvTmbZboSzxfm6UHPKFsvcNK1OvG064CM_vpJFb8VE-mV-D2mZZzOC6JKSL9mPx9aGiCXJLtOKUM1sEEsvSXaNKmXI6u-z_TcYfU8lmA50y06jRo_bouC9Jg9saAsKblPIkfZHnYxKVdW-9-3S0wAwbbwboveZUJVGZ8x73YSVzHJgQad8RWrhRECuWMj5Jtuc7ZEi5NjyUgMU09Jx9oedAxy8Y6H6Rs2C1WFmR9kwGyuiznSnHkogcKhJgzSFHg6TaD590vLjqLNuGn6lh6c2VJHihqlag7PkghU1YvtoY1nTPDM0uu-X5sFCZgeOOafL8EE20vvIdFGlwuJkH245pjSYlk3NVCBJd-28sVQRsqMRt24BfX7LVWBsII7I6fPpJcaQFd6NZb6faN125QKAzeYm28t8KPVkhio2GJeMxpDzbJvPbgARI1tRSpkCQ3wzmm8GE2KedJGcYLKiuays6Rx2mTUt0WzougglC9nj5_IuQNDmILq0jAGtuGwZvSj97dYQLRoX8_UFuvnEkBE9DPsNQhbvNoV8FLQtU0OFIHYbYoNEILniJewIQ89QQEV2NjtnrbQdhF4r6IJT-y1v65RYWdbn4AiiiaE4ZDoDAEptJvTNpn2eLUqdo4VtimSLPdz03JIuxdGIQILmWuPPmWQZROmHi4ovCUWBN-iNxBBQPio4rJ2cdl1GWfpILvPfjsc0sapX0_NNQlqtLjafinBf2MvpL0YCqJJYYXoRwpeXKAQa1CfdGTih3PLx_H1IoxxnLNULzQRjR1Fq8wIT6GXjRAKIM-7-h3pwkcu-4lq0O3yOWKeW012DjwWkvwHda-pvhiXwBrAlLmQW5U_JThAYBB6Ogtf0vZpp0maEOTLWxnpydpYwz2fcdzt6aNgXFYmRyIdRk1jgNz1gytj_yk8PvEmu2y5bnxdwQ0VIRFj7NVjZZ6Kjfj7e8OehUt2eklXTRh45nhOCPe9c9gxXbs4z1XwBkzkNL6QpZpEGAFNV1eShzwNwRu6qwJp94DP4x10v4O7xsHwqzI0gCIT8E1ICJmwa9r3x42utZ4dGR1R9nOuzOtHmZ05td0DwUdvJ_XiuSIqEJrAhz77oCyL7QcnhYCVZ2cca6Fqp7nPq1GkjkUJxIWKkf3KpBKM2sq21yofRmDrkcJ3EZn4F1uI6PUP6QemkBtq32yOu0vqDaZlYKcL9gAkE5enb-TPp84Jl-jO4e5oVj2ULbgyi3YJsnIzQbLZJsO08VML8CwC7KuOUTns1qaIjjp9HHm2xHDz9bAzrvL3lWkYKHM0O6X2nlqy6JJ8gV1t_dR-L4d-bBpoT7rmdfvPmH2vig0SXmRvIpL-d0Wv_wD8_XRtbOYay5dj1PL9AX0LokBf88d8uLfRIGbjWx28nkSq77fCAtWKD6V9y-TB1I3rSnAw4sU2HtykH0S61E4jkyz94ibW5Im2uMAsFsqYtfrA0QMC2F6ecsl4GKk_mmQ0uDHyTxxlzSJtGpdUp1m7Wi3Gg4YQlBBaggk75kSDOUGEBN-dq9qurH1k_3pyo1zJ0lJUmjSJ1Az3UbtOgc-OPU8AOxuBClw7-e73k75LrcOkhohwMMDjzIDxLL17bOw6oQNej-v11rfx0TMpyJ3YEkZLPnEOvDSr3bDMdDwsdbhsG5egXiUa-YhtClRDMqfoEWzNeQsy-oo2yA9pz32hEcyjM_9iVyf9yOIRxEL2SK5qtKve0sbrlDdY9TnnkfUUBpbKNUePtnIKo3Rce1pI6nAyA1rLW2knWUExOHi71mpFaK6VhwVka19sGO9-nUpEK6gTGphoc1p_OTr0G3sNu7jYo1_8bEoy2MN2b6FOj2nJPALs9F4oDovn4Ipis-Dannls6SSGfIf7Yb5rhcHrVUvG39mIU88qu4hbrGr876sciKxcHEdXbq4yKnzOPpauc_pONWFxMdsW0X26sb_newpsu2iARcNTBkcP2L9oKYVulX5kMX1RwJRCBUpTUUIaSs_Fd2cGgXa840Ezcy0kzvRjbgfMLAUbwMHMjzsqMjXP7gle7HP4mhEQAGqikBG-a0bn5f15znIQ4mSjKT6JwLNR5LA22zL5w2jmBV0qYB-yH9LlwHIO_de6kZLJaaMDGp_0jPHFFqVkIUDTVohHYwxktjILBSeaKO9KetdOaj4ty3REdlSc9ZQGsK_y2koPx71oqJ1gyCdchtZtU8qs5fIKG0dgC0JtDqBX2o5XQ2sV1AKijqIrZyqIX0nQ2Iisar8ATTF390TwvB2JiVkR9SGpeXfBBGhLsSEROBgGzD0Xzhu4_6Ea9BANN9Y7VlNFDXI_roQKgnr3GuHZEYhcggMOJzU_ucexxBLYp8zk3UyXFx9MFptiWumkoj2RWE4CdH_Q0vf9x2JF1W6o8HZvRTW7G-PONa4hEZw7PUjcgQ54cAdGVkM-ueBc7eUN2kq7gB7LS5Nnm6o_8nWZp4Sfc9uo_jcTnMfxaeSAo9sykMn9By-UclmbbXZVBTaGDtqT2ViPft17_LD-X_A5h4fdknUwvjkOuxcittawU0Murkmg8ZY-t-6FPS2gC4hdz7jpeiMMnV6BzHyPgF5fXoLcLKDjZ4MjEHMBH1Tp_X_dxsbR4H9bu_xniISRk-e3pvhrpiaqlBwDVT3IIQC9s-9AONEnxWS9CnPs52zcwN4UBc13f5et_FkSjK66j9snmBs3wTfW8THpqKl6P5XPi3LFF2EzfSqxSoPU7CE-m_vo-QiQThJLgP8J4VzbL04bm2oCEvnRUlnGKoeFQHkjADLrCj1W279QGogydgLrecVrKY9CIPMIS4XMWkO-RyA-RZNw3j9mr8Otq_Xx_MQaPotvQGU-H4svkGDYBupdAR18KvpuLH7aqYLOpDrnCVjBtrJaaK1ycpaNY1eTeHNL5H5VlSCIqAB7_3tCewG03H-PDUBRHnE6hC_mfTXb_jClyVWDmuDZcxOB8vieRTYF1CkWw_TKfNzvcdtzSfpwJWMcthu1GRqJbYEYwKIWZ2yxUpnR84wCEagSytom_S75q-6vNmsmefXbgnAE5NZxXxlGnTW_FRyKGZtdgkrZQ1u9_QCbwKJ2hqxA1c9rPo-fosAptXRT_1xrhIjvxKENW0gWT7RSxC1yOCBaYc8f-GosSM-fIp0DDfmmxg1j2JT1uvA_pELE0WkFq9_gmeFaO39_d1Yudqe59QMNIj9R36D2dN6fJz8owowFvKJ0rlesNEN-KN0SfQXAcA_L4jLE65kC4CNA9S7KNW8sy3cyRPjNb5ULiEwSs9rYAnNZgd9Rrdk-X3_fcolBU0n-WGI5mBcuQIJ7Q9oenWxI8Uye0JdNOx2smqBNyO3cbRVHJkW84U0ag4-3RhL5FD7emnwIO7Fk9upC8ppX3-h8oA_MNiS8jorkAaDFzIChRcOn4EZQCa3k_3ebuen3dSPXHuzgzH8QnpDFSBk-0cviFJOt2c0KgMtwjgBoekyQbyEusCxxofiqAzOpImxPmkW2eYFVMy7cBSF6C09fffJF1o_JwndjEuDj7lsU3IzKd-O7fCibDvmmyoJbYGowOgwLdb94hkuTg4Hs7wnXN_yCg9-87yx_X0_kiWAnf5lxeAX1a6C4GlUldBKaQKXrNpWwjA6iLsB1FcilrHiHYsJUqXD6TrKVq3Sm14mZ0JZbV4itIdsPFlTDJvqrB_kzgn3Y3yBAJudjg0RFR3IVLgW9ehJ4udhzeKrCIJmK3nDa9jIWrpKwjRXabCvSFsrvbJoXC93Jz3eDc6QG4vpqP4jkmiYrxz7UaOorhqRg68w9KSt5OJZOX14sDyyDu-AWgJ-9tQjyF2sp7fdBlfTis_4pVpe9meemEAHG7TKlYeypHyBLIlvSoJK3uBcMNdvx-Nqe13-jzRKNZ_fBkKg6dWco4d2pktQBr_AtOzgByDv_XJK1U29RvzToHP_KJk3eEe0YjovI_NsunsodWbMk-VK0O1X1_vlqRyMhA3OA_7qr9mZFSs32nwED4BGCbjCLT4gB4rzZgk4OzO5L83TQWNVufiDCsKeCoaxenjo77TOJzm2anTUisWlHC2m-LQS52Pl3PklIEwZguXQSEVaI5YBnI-wT3LcMMTwT_xNBg3NVT_N55DuYvjr0Lmk5IcN5WbnKv6bzommEUu-0cDsbJW8nScu161NPQBY-KxpqEl04rm5yHcGklGkKwvJqRe41U2SLr5vYvJzgc7xan2sDHUJqcuBydTiaSv_7Wrcb295zjU6ZZugq7auRtVka2Jb3sOxA-Bc3VScP8VZUuu2z9LKC6NaGS5hk_0Jhnygupb9GpQK5qXlOy4CvyEdXieOTXbYJePJNg4KMWE1YAgiF_t-K-vdgnNCCGAx4dXVDVeKMTD3icKfdmYt7a25ez8ac91aWuMa0dIxmTfEBeEfGMm8MvhuudTk1HqmDEftagI_WITA6FrShwhyPzHo2xFBxABtRmdJia0FaU4Db16wwfGwyLc_Uus87_VA-5vtq4V_KInCoyQ0SYgJqewq4LjC6mizyhK9e8eeChCjw9frwSygx8YXzZ_NpdCqnvJDXbRYeEyOgS8-BelFyWbsCdmizs-x8s5XM0TTR6xUyEANAXZj3vAdNaUHib3RXIv2soDWaaXggrX7spYQCOqGt7OepCBFNUoY3TRDE35Nsey1ydovwJqAxmchEprMBq23Y_aaw2R3E4hRYX2Rs0FoXdlq9QUKT-ZM8rvpqco28SMeE8ZPohJVMRLwIvqn0gjcaH-Qn79PFO1z1M5XJ7svfr36t0Pw5Y1r7Q4kwDuCIbiYIsItNqJBs4j62OqdB9pM_l2889S91gU7kT5PF5jBf0y3tjy1IZE0fqhgXV-qp1trK9mXfsBbX2YaEHckvGrAG7Zix6jEAlTwMJjNF__tIoaohIsNTcBOC1nh77CgKbJNzbzFPrKj6zNtZyTPHvJ70gfJpDglFY3T11qv5sJbp2b3wn9_rQG5HalT8jxBbmOjy9frI2opyE_NeoSugD7dg_VEWH7DJEqXl-EUZk5NExgnY2faoWdHS9oMCp9NA9-WPkADRfOchC4f3bi5z8FQLJf195ABZDFOyqYtVuN2JSMYnnHBVMM6MiX0NFE9qTiVKfTeWsIKcJKm99kElgIMCWNq5liICeD4ckz50Nv4zO0DM1pGwFTyPPVWJbZ5hTWPQkZuP2gMJ8AkRjur0fgUvqUk6Hu6_rh_QEg1_Gjw00TTiSUvrjFJt6htQbSc91jAZYsDjP7QpGPqZpq38x_fclCeE8wxbvT_p7ZUhV5z4_8jnWPw43YQmNFBgQnjOmkkMwUBgR7vO98qsZrqVgVOkTXUmKTy3dBeTNS9V-x1GBNiSTG8rP9UBQPkm6KSrI5YGv6NkX-REcK0V7PmFzw5ZAN1ZkYKZkISZ189TE7BxWu6mEdg-i7uGYxyYnaF0LMj8iNT4WUPp_IcJepoIMSaiNKm_WG1n-YGndJs0447Diybh5yUPxTHryE2poKPbirj5H-UMEJ4u86PC1-cKZqnEUY2IdFEEuyfzobcP3Mb0IGIHBzJ4MFzq0zxgJyEGBcne6B5Ql8xJJxFF34ttljEiswKVfcW6_SSVBniyW8TW21DUXKByUb0x4bsr8PFu9U4cKq66WLQTzWPNyjXSM-jmL9Rnpjele8jbbJb-RaZEIZ5UFesrWKPfyoG_k0of8P7F7uNwNFVvG5VAVaHIaY1H3nimtIq9bvfTJvUz7MSupQN1NmdOaHvRCpxnOllZ-uNlYGWKRf0q6Ssh_UPpuPCgSRhFFMv-Ik0E6xwJmRRNJE6ZzpyvdncU9ib3kOV1IB5rO-s9xgFf-UXqE-0RsgZpQBac8-TPzszgwmG4f7i4TsGdPIsUlvfQpTFvGE8xNciqMOJF9j7HlsCP7kQ76Obd0tjOVoGeCBdp0hQGtidjk4kgxIen7sa9Gi8-siHMKNHoGTRL8GCfyL_Z4ol4HF_FyTntKaijYLrLpCM705Yffc7TBzHq4vXYWAjvQUXebFeBeT0DjDBzzB9EZxq_13BsludGkXMtR9WrP6xuPUlFzBJQNqnWL4FJR1U_XbwE0MSNCPxOIak8yt_IHxZ_H31RU5kCeg3_hL3isZyj1msJGs15scweF9NWc21rV8BwOqeF0potklFLvYOAehW3ACgtGOAnmbfM0Dusj88L7mY5hfMxgFq-5_X0W_teSG3IHbtMYdIBEzo9NPB6L5vnzXQ0tM73LDwydHwhmfWYyZHbxWfIpNfkXcrm9o-dw8RrxX-REK__RI53Tf4DqHv6MTElxJhZUTUTqYgwOjsjCpq0gTyxml1ntRdtDZz3KVyOgAswBHcPS-k2YW3RuaICB8DG_nOtcJN5O9tbiccV_2UtXGjTCirWmv7geYcnbgMShUYWdqqEX2m_TD_ip1Gkgsh5xf4d6gK5efDUGU5DUg9LrccsB-ps0RsoqGd-CDKoY6RT_mkQHEALNBfjlrJrUttNcIbHcB4KasyrhD-7EtECPbzFvLELIWxtEL6Ys3fJg3RbgSvVvwwydvlmnTDmpP2W5BPYRvrpYOk-ubscavoaMfBZhN6PdbRYzGnK2DHtoQ75vFn2XvSC-1GOrVDdgKZnHz3Ya4t_9HrT2w12FkJbyx-TrGVAoqlKJnZo5C3zPEMzlzCHGczQos6BFi8MCy4FnP_Wuo0TEYCTndsQ6e6DNrV0gV6KL8M8ICaFQRAzumoaAspdTHq88u1ONheWi03YoDkZHDvZJ98MVwDpSnWc2RWdTB-4P4jZStvKOrXOZM-ze7k6U_7_hmG-3ljJkJANVFUCmDDgo1ZhU1p-vnlqx1-FIlPx4M8RzoCFkCuSwHaUNLvVIKTOPvyszxQv9PtdWSjrt4er7cmNsDmAiO-_ZQ-FzFrNPRXJwxVgwJG4ulIUfTLwMRymGF2jQTTign8LMsHs8iP1gJCqDbAvoY99OoSAa8vYtP1MgPC_w8rF3qoJHOONVCgUMPfeCi-_fJdlGq-OGIQh8bzF4JVE1W--VD_biB8eetKgW-KEZkvK4ciy8hia66G-2K-4lO6migJtbpwa69p-ac2rlyeVi999rwgdF2c0UcgQSv7p9O4UF8EeJTQrr-WmGentr0mxyz1qKYHP3zpCKHyL4CCQnr0AGI7YL8r4XGtGkdO3E-RNQse_Qc489UBv8_lY6E47R0xVAP2b0gSgRs4i_c7HvCmS6iZpJ6V4k9kOd_3UXycUYupCWhi0IavYuQbkfOFp0Tt-TvdpK7U9iJrVMkDLILk-KpREI0qJHQ5snXzH3yfv7gOAP572SoWUO-s2P7Tk3eP1Cv0FaMfKT1-oOjOhotm-cOEGEbpDQUoHSwKdPfbgfYxAlxbdNpsZb51ayUf6sJwPQis2OEAUffRd42cGkJX_A2TLhnvuYBMZoSYm8ByK6bKB1bOR5nBOFhDE6Mthl-k70bFx8nW_Qls-D3jaZaicFtieRLzmN3FMQuf8I3t13i3_HECaeZcwBjaVklEJ8BrsGnmBwDPLT0Tige1_q6g-9WV2hqwm5ttXkFaXJ2CTSwQTP3piXVJXvr8InEk8wXx1yDQYUz50krmMJWvjazZ9OrH1UyRODcldHqB4nKt-nQZfgyzEImwXKtmafLfgmbzd0QjQ7NmlkxF5NnA28KAZP2ky4iCzK1x2641EsX41_AVDzmVRy7THZrSkhON1YYHiyL5Ep2d1q0PUqkZEPHE-CDvpkzY-C0nStzGNcj0sZWMvnGCfPe5BTUT3uUszoglLvgtunxEF-mZY_3T6HIBhNFnlEQ1-g2fNVhcVVcMiyntizf15AOdCT3rwCPffZgj1i2onofl2M5cYtiHK052bbvV11JCW5cjt7BzlHY0M5tPPnjfl3DsxjhWKgnOac7-FB1Gx57zjCVbMW_sRlM0wogccS2AK0pYPuuTS2QZ_7Lm6ZdnipdyTVwkC_nShV38DduBFZl7klRoNj4C_UgaYdIbfgGDt3ALJzzjHZNdUp-S_utRhfWnZoU4cm1Qhax_P1N7Lp3U-8bueaaYtWJwNBMpL5Ubh5ShXZp2i-dnJsVF5rYJYLy131dAUvSYCn-_8a3R5XST76GTrOqJpIi82jjKRI9XObMDEwkpSZ8i3PF_FNK_y_ExHn_JApcRGcRWGjOzGu4xeef3tNsBt4rBX2FlCC8bSi3s_RtaVSebbj7woKcssyVwe8p3BhiKC0w1eMvLHT4TlMBMXvqE0Bm0iIzu6Hv8NlaXjGi6ZjfQl19txnBoxrYhkNKo92IXhuAz4wpQpAGxC0SqkbDgM6sY6629uy4lDLoRNL6iJ3h_edtTLThtZgQ7wsOY1hQd_rGgtvKzGQ6-yHA1oZ1fi8eEUM5QlrtK2Bq6354KQpIF9qDHKtycZEQqOc_KKaCw5U8QT0YzFDIqxz_9gJXmBHfxfWgPJ-S7UkP5Q_hLhUxQcLTfgMdfqWXTE5_uc6slKqX1bvfTzk23U8Xm4dROYXMyMkk7fMw74v5WRvzhmIe5WWfsnHdhb_9ldU9kgJGwqqZg-1oLNHZDFhJbbNJnRjaeUd0ehuLczbgp4DA9gZEFT3Y-9f5Q-Ro-7bgLwcowOeWgT3zSNV6SprhC8PRgMmmdPn_d_DUtdUEDlMb3suDL9_b6radK34XAkgec5GcTs6Lanjd6NiQhHHQOmL2XXo2c24D-nDjd0eCFi6d9lpqX8BYOb-JdZY6n8U0bCB6vF2KgNLl1aYk_6pQZ2ZunDt3MIjIgrx-rumpcKTaMIJD7Tu8CAW6A0C6y81LOgAc9Q4VW9Q1GpU89pc8Mhlb4r_5ykjNj4X8XyW95op-wFZxCNG32o1vQ8r8k6vsRYWxefUXDSmmhfjvSbvoZtIZUyA1f9tKxwgdv2wuESKpn4NphCe_7vrMK_Y8yILHmqa5hyuOiJb6VrBzWxFsC4NHOvTX6zE6CNsplApx35lh--DTOICWmeBEZssP5PpL7uTYQ2MunFHf3RPSlv0HBQvFyxG1GE0SYSgY93Th-ERV0KbHqZG82tDRdNgRtkrYKwjc5TRXvIpSmJmVmcdV2RfjOMVUH6Ebed577UuNyPbzrv95ad6XpwgmKhEwNjjZ_fjxxmDjzuvXBAOvgbCvBW9mV5ei-pDAtbJigrc83e9Aq5qiWnKDxvMT83uSSJKNKHPxvQyD9fNZHFTnFy7GWzcUaiAO9JpahEvljyD31xUXbjs_7OdEK3CmiNJpXyMmCGDy0y-Iq82quAVfSOAJLENOEFEFAWixuYodbwdHYkuF4bBUSrZaECDPtgDF3P9FeJLDszNBkZar6Opg7eqlC2Ep2SX0wy8urBtqeKSYxH5n_XQa8976AvJ_QyWkHQx-17Ku4bzuJFTaFzql8V-Y4i_Mz5lHgvDZSf_7hEUQXGVagmcWaWRtE-2RY88PF6W3JiFKB2gbSkrxN2LmpXwfOqqJ7q1wW1orQKDK1b80bf2qTWMcEpbLDsEHjr7s1HWWR6j1koJVGlobWr06xcF0zISb1FE2wXQ6rpuaSYbDbhqwMGbWmAasA_hUCGa7hHYiZylh0bUV0Fdf5_zyCoFd6sZnBxvom333oVzLLI4-C9ibPJS8OhejgQENo9ED_wfJ-bJAoEyqALIT3c2A2BawmvIJxmyR5E6oafLtmkUiO_YkQIRm-pFcniXAWz_9Is_O3IhdcDYX9K8qxmHm9eXh_CeTSncwucGoOCFce5UIYoXaO3OIMv7-Cgj6ithFx7d302v-zIFVXR7cLizBilbJ23hTfd8449xJ7Q2Xdj5DzvvjBm1Pp7z65H7peBlkqA-dq48pScLhNtuC9Aiym0XGPtEIOcmSwuBterkav-pnPYZgVR_NrTlfFpxC8HhBmKqAgffX-iHZFkhvgQgkdcpoO4minpyNgZ8U7yuHzDDgHyMZYYTZnXBMJ122lC0HpJ_Mwoc6QCmUfAeODlweIWvzREQH4ip2MeVc7GwyQRpTqzj6LMkHbFu9lgQoAx_5ze1aiW6oxzrpRGHqNy6KR9Tpy0lUQDANtXzzeVudA-AilcUVvCFoRldromHQykrtY8Tv-n6yzUiOY8AI3EjptBF4exDvM1Bej6gdpYsCymN78b27DIhBxZ_LoNtDC6FaqnIPEoXAasbdkdj3PQaeH49sQPrcNWK1zq0wrBsXiF_I5an3BcOEjd9NcpuTZ2wAR45BiTlwn6tMRW_FWfNvC6CB0-xREmeq1QvewB8wTBhLtzxT3GRlwEEWl3knsh-33UEU-sqE6uS5WM_peBVXO3rsvHr6j6P63-LziIafxJd8x8J8W1lcq_lGBVRwHZSxaNwgxnjFa9ywfHc1nVWFxr947lWtm6lDYzsae5rf0A1UOLisjXOqy8BbZHfuj8D0qL2IEup6SweJg94V5a8SUTodviWCMA_OjZhlRx34HkfWZywQcuy0f99TwzBc54pT1QwO8oZKRdJjKKL3o_SSlLyNFDxlxOiiZNeDvj8AYHi6CSwJWY6nj67XnHQS3ET170vCUqWuVom5YsNjwwb0vPx3jwY6FQ0Ccw7KverXfaUwmOqvu50wLZlFK4H_PevIc55UGhB9kmU3AVnep1pAhqltJSQEnFGy5fP7sXPRmP703quAoF15uvr3B-0SK_PGPVnJosiM6CYoIKxQrBKj9Gy9pErrXJowFxliV1nGksxB80TsxXNHgcgxw5atvUz5OMmVsasZjA184dxjf4vDX0n54SUP7CVrk675WhBkR6wIJYXrce7-z6LjkmqymQPguz5OcJ27-TSokroce7lwjYjDaoTrPVZNb9oy29_wlYK2DTLYtNTLtlB1h9IlJ8KVdKKEsY6nxHDKR1x9o53EBvOA2ewxhrxD-9Scb-unju4G2B1LBMQWw3iQU_ms4l7VMwf5DBAZ5Wwc21Q_ZMI2iWiE1UQgPbbzYTEOD2Ecz07tRGdaMrH8WfGi-fkyvqp7isZ74gyQvQ52rZhbIkVBqLTE4tPt89AOn4kw5j_rQbzqYkaXUTeTzde3foPx0wAHBUsgVpNn_xAeSnFk1HtcS6cPBDle-sejEMWdWbN1ZOmYrS_sQTY7se3VsNFXoerKmLg5g_T5XeTPa6JGzBvxG6ZQYU9iASoeqdH2xiPo7hxw8IE13WE9Tnsc1vv0Gm6fFv7PXPwr24xEhqi1Es5crnuQmKwfxztL_QhrTCRn9nu09hdodYrSjDhqYJH73RiXHfWsfIbeNeO0oWC4MH5TiUpKclDxyZhY2lTSUOHclDWM4nptcoNs08wdK_lt1jdXY9fzMUVVcMHrHY1H2y2O4ZmoY8nLxAmWJBc3PDtNcdx_6LDAz_0EU2qBJ83BVR8k5mTwtuE2E30ccwOp7bovuvHxgKR6oOe-ZyTnJn2PeJ-wh1OOFHTrXDMba7S1twegEetwNdkz1sVc0x4F6_GvNfS7k0Db23rM-ngc0WnApzmdQG_gN6gyXVsiauDYmFsGHg21UCuYfSyDnli5BNV80PG8ZVaISjNXO4BZB1UD9VHbHAICfGH0UxlfoslTl76VufgJXiZ9klsVyt_d2zC0UjxiUOMErRTkmcfZB5sAzEeXefy4-NaFyyZL5DC91d2wdW2AqYgpy-qmgNV1qOjk2zv5X9mJznZYN3qED07WHX3qPG-5G_3zzayiketYqvSmtC3ufM-N8PWXeFizPiQFGVv2oNmfP9Vk3HUC_hWmLWJoqzsWdHwHLgMZ9b3qSKreqljE5ziiUVC6PkhRC11xRV7ElaAaiQ0qvauZgOTpq0aUufHfUCIDfKO7qwucvuneliSSCSZKtKSWFiPic9vh7fmNWs_IeAPjPDqwmrw8E9fKvTvuD5R9w6X8aOFGH3DAi72uQYZhQOmAZBSeIam8RAua5Xm6M5AXOrM00wTXAMW0ELlT4aTFWiVGBSqMeuZy-_CJF2sgbAbZNtfrqFbtpy-bVln1Q4kwomCEJentOfJ6VV2mjivR3XRUdIujDw4BwzCxelBbe8O7VYk2xcnGI7evFwTVyqkXolh7v9WNfSjS7OufOUdAdN9zit7EnRTlhKvItgQAgcmLiLJ_iBgBIDHhd4xvZbLiYtsKcMajY6pVgQ0rOBNyCe6-kbNt5l66JvlfapskxQf4aV9Y-W5yAkzBgUTSq35_nPDbTTDLx_FaxJxZ82LgI5blIwUeG7HSnXeUy-KNejJRhYo687rM1imKThWl2n-dMx5NxVhqPZ6XQL4k_NXfl0_oU4KSF1YH91rBlrqt93TNCzjxelBeCQJR9uB5IdumR5JS4o9w5D8HNnKA722CZgu6XTkNL_Hco5lPMNz0Vm-0lA_Kvw2veoZL3Y9gdSPh8CkVWzyH-oGmOwBTfWV5PZ65eAfbF6Q5KJuryDE7cXqhFky13YBw6tUwaHgBM0PnoH5LTutxVbbFfkEG9u4-smdUX8m19j0ZhqZCfiSnLcoc-TPs9EXXkeEiMz9-TdJcPFqgUB89h7JdMGMS6x8uwWEH6BVwpOTdX6AQ5lVteP21qOtVTAdM6W-nOxky8GtqW5G3BB-hKKAmdpwVodJa2onhB6mYfbaK-5InzeuzBnCNGvwEmPB14hw9m-XyoPv1PsQfetC8StO6wRwvSiY11bxhmfrBoQxToT0nQQzQ6iDjghuDMZXsEvLkoXiiTgZIMqmT7Qe_WPglC_eB2vgs0kgfAyGrUyBeBZW02cMJ5bakGRz4U1awnwGvsIZxxxwaCGJ0cbVzWqW0HUJc0JbcPZarrMTG121zGf0uvUk9ppIHT8U8ps5hv0w6EgKHNCoF7S8c8TDvN2bmmHuuPVDobeNpfVYHsFe6q5J7zwKSUoSAp0hqMelZx_T7YbqgMujQcTjQm3EzrSyz6zBabyEWKGCdJDgALE76kst6uuLC5Dnaf8n-U2RKf6nXdcTIsAN3lT1jAasZ0Gejp4HdQRebtgolrv1K8MpIFxH1m9GqODD5cwNNhbv2gMEiaukwVPrGd9-r2nvrts47-3Nsm1sviH4E6zYCTjVN-vNJMKa8bZ4EKBUbMW20lNeaNKjZgPYzusjWyJPajNQyjmnNSORhbgFPZGTaHN0NqC-kB4mN9PkMIk4JUaJ_buOEZjc4_e2LSgv4GnlD-4h0p_A5HlZiRa2f_dRWLawm_YDUQOFeCPC0i1IzbfjF9J6_2N-oDcOxqHmEpDZi4HbXGXSTddDT91qZKrO6NUJPMLvtHtS4gfdz4rG5YewOXO4w1D4luxS__T7Ov10JtfXbqFcL-omWLZePJUnqhZtFR_lKFrbe2CaHDJ46cl6mW9Dw-J-CEG4ibaAPng_gBKk2sjZB-bsIPFuXdQFe2stVC7pquikY8B3YtJCt77BkNBWa9q02Nw1EvqRrAQaoXM00CbJ_IL5dVqyWUP7WtuW6ihAONt_1HTUGv92M1JhAa98Q9OtffF4y8zFDOrRcM-GzqskGZ5I9O8pjeGvDeIoTIlXJLrjRH1a0SuPCis9purPH7npgg9B88D_J62R3xFKX8SA9dVRX1uoolsMATIis_O3n8fGz2U5W7ufshPuVuiq6J4vax5xCThhLbeuKVLtSwrrdeWHSMz3xoV6u9GoIyc9L0k6XLUTZUbWYNfzE4WwmdWGVFPQai51a2_YmEGGAFfk672YPMI0nG_fxMjjpo47MemnTFUTKw4fLPwpPwmWV2gKIPzV8iH4j5qN2q20qT28fE3P78WyluVfCdztS-BbUP-SAqo2UIzyHj0VRv78qUZAVrjVIfIX-XFxWrTQ3V3BBdk12SrWedvFBUwGRF8ZxSeEdZK6YAmYmyo_Myu01PhcGs_ahFRB5Y9rEIBD7YaVoeHvUcMwHTJc-C_XdMqo9kOaguPKjIYMHbRnyFyh9DvCA09t0Rl87zBsTl_Ik4D92Dm6KitxKs6yawlZC8utzC7lQTr39QLmQ_jJbxf_ZCY7WqMjz4cftM0mpuxwbFmfkLIH-KIbRWsKbKmbjm4610jCMwRXWcxQXV1Ca6kAuQbLS9KRRO2nq02mGn6NIAMjgzML-twYA7rguAcVGxX-JIx77slUUjnVkc0p11UoreYG0PjG64FZfXBkqmScjIVkVUNPlV6zM-LKxpJ6Qyl4u982pOCG5-iGjc0V_toUJ0yvHKkofuPpwKjmrdIH0W27qg2vnoYShOqbFwUnzfeTCHocL6nyhADPBufNnzS6wI7Dn78ZZAmVYD1ayNqR5RAT_BYw4gAuGsxoS2XbkAafMdzhQQRo9zyTSrjE4Teb9TD_t0-yOWhMFBwqF450Xxs1HnKMwCAew__CB5Yjy0WJ1q7g43rToBqgfZLLa8hzNFYqKXTAbUaVT_HREyqglXJJjGDuzlEOZHR3xSFSli1YhvzZbCfoZ_Utjpd58H0DRbiEEf0GoLt_jUF6_PZ46rrReJooDHR1jBctLIJvFx_Sur15M3T3kwDKhNHJjalXkIxBwOdjzlOS4TQ009YNnBh6PyR0xcjesVBS0j9HhoG806cxoAcyfbJ_ee6LEi26Dza4a5gp9HLOwtIoPly0zyiy-9UtvRLGRuNuSti6q5OEpZL8A_EjQX4e0lHYO0VGODw0ZFGYlKjMC5fe43iMmJZnCWcNtJ4CZS-EfWgYAloWw8Y9XzdMGv6X2cwRrT-M7uitQYK0mQHnz0WpShl7LUV9de5gvht6peYZ1te-LyR9RISOtoEudpmUz65fHrJEk-ew66K_roQR5eC1t2l8WexT-k5o0_eVcbZCSQu9P73gAMuSVU1SJeeMSjKEGsLLdjrcwxkpsgQ2i9ZhtYnt6YZTSJ1hZHBfaAuDSbtSVqoP6KDmUbDx-fxehsH5fSuZQL94X3HsMffxgI49Isk3KcxZbfzCibBCZzJcDcZnIefE1dbERiQXBrJqNwUqHmrGfCwF2xSKa-1oDtEmQra63apmfh-qVdhZg5wFfeM1n81aEp5shTjyi-5NND0MbAKobtCKSecdfcD6pxdGTYttvOkgN1HXDyXBG4NG_uS9XN4bqQI7gkjBZ7Hk_65UB99weu85C4kMYslfUiPbC-XPZv5uAo9yUPSUpMZ-vxWJv9JCCIFcrt75jyFFsIrxnAfU8ctmfC_Fk0VGvfraH5LwvYW-ZMGxH3DPdfSYuJU0spsiYhDieqNBlOdOLljw-gPu8w2jqFlQYHeogpNW49WllosWEM0JXMc3S9j-D7So6BBBEJTUj7PYv6EjsJSUYUe6RKWldQH2LTOItcTg2i_MzV1-jzkAIz9hqZ2xIgO-36FPTWKeFfbmY67RGeFWbwBU2MZHOP35U4agZUZdgKir1j3AmDbFn5HB0yd91k1tO-4BWkm5Op7QdwLGhRK_xt4rSVCAhagjpah6B0ATMQAKeIda1tHrdyc1HGuNh4gyJ15fixzVZTEO32tpi-BK3Zy2JkES1mKsYWUiJL3QJCIJZZ0yD_XYGsLihS-oEPRrzmA3a_Y2Hn-vGavalmvsFNMwOna3cmcNud9a0xWwTcifG9RxbxD7hk2wZSBasHlYFEeHJbTQfsBCPCcReYYamAdQ6jM4gA5oL7kpaO--sVV9lv9FUar2hnOdlhXneSN1kBFytSTuy_4Fk7yxAf108dzoSVbrUErkjjel9XiH2Lv-TbQjmnwgPwdGVBCFzUMp1GvxVRQ-nWITiu1NF5RLURcIawEXXkKs3XUFf9R-GkzEzT5cfkXWf7wqiloT0mW464zs5FMA2jX50J8CJRT0GS_DNGk4w0LpGpOGp41_UZSvFvH0Wsb-JIX_mXt-qP1B-4Oj6QBg5IXGmtzzNrIyiUzzkiqUuYjk9uQt4chWuHWngTPlj5jrGFOkF5SLWQlo43SJiT82vK4K7JHEQ7DN8JTWJoJ9rg1QBXRfmAZB8U9oDvlQOEzb09cblgT8nC2GBRvydXVCVyJJecclbHUHgOO4yLteTPPOMYA7YvDScZIB8UVgyzQ1caEU9uMzTXSBmwkFIAt2w538i-CqLM2jePX8BSukfVuilPJgxW-Oh2MK-wU0xhT1mOBM--Mk4wY30XLpqa2oxglqpICraDQRo0UwuAw4g7o5K5rhg4UfS06Zn94CQJNwIfVPZ386v8oNlmK2g55OfxEHekyPLdpH8wxXhSRXR_zmLHAXX6IWEEmXIzYccISmNbot-F7YLih30uX9f_ENJjJNXFLzkDEhdSPUPkfuikJ2q-B5YjKwyEw5hap-emMi8IabxDI6JWihhTsbtR1EkbaB_QX1skWPdy1yV-Wlc7GY3rknThC2IC4AdOTRSMNDlGmQpWXM26msKMKrkkfITzD9ht3NzfqaMDs659WQO_HUcB3RJJV9dhFz7Rsr-W5AxN6vgeWDDqPEsJwuj_BtTY_0Khsu6mqY9e1SHi2E5ucmrur7Wbcz-tyIGMcDdRAtgZWvnlB2h0PWG-8wCdKehYSySsqF2yFrjXYGnXDdUHYqh8hTsas6RBPQaTSVecqlZaFUEXIeYz8jzicPem_PqFzK2-xTmcEu9iZvvjIE4GWdWQqkzL3YF1xGUCPcaevUGmCW-sgHju0VNV1x-0jS5y8LWV9poZrZ9aIDzALs5xWnwfAE6Sj7jWpWoRgFQQnIGa_HG5IHr1W_mQBn5bDqPJ9IAkEGIU0bjNs6emysy1Sm32vdDYGV-oAaMYwlxtFGn4STLvrIzmXnsqKi6gZ8mqMGJbqrDWvhIqR6xW4rmXXgAV6MXvHY85NIKJLf5lKV6uOIfvxO3fKjXu_8vR263BTkufqSep_2dLsEFclb2W8qaTX24i8fLL7BU-L5ep4IwIbyVf4ZzbmfkJunHJfVYHra9Df9pUIkB00sTcZ05kGqYz7g6ZaOXH1YqGKOZfIrB_CPaCWDhoeOYla5EamNzhGlJek2zEaD_vW3Gfx9cAJ59CyfOuimJohn9qS_5iARAEfMmD9Rq-BEoD3aDmfSuWX7OYLl0VCH7c62EmZqPEXOkbpti7hxFy39w3IqaVWEyhCH5tSrnVPRwSmuYSt8f7sDjXqFy1sKWx6p4YYujE7FUEAtyWyCALhFwAHuWPoV8eH5w3DTr6GvQSwiR2ik4yO6pQ9mx0Z-4FbX1TzLeqtufPnZBHpjybb8_G3vn4_CX6xXbHeUEVDQBDaEMdGCyaZ6ZGe0LrmBStaucL4ZygZI6BMag1GUwv1mikYBlTSt1OurpqjA3KhuSy82Vnq2Q-V19O0VqZsOPqbdBDexHRD79Vm8-SeLRvm9ctT9AYkaBOfisvgQzRr3JMPELCXBaeDCNjL9w58STHr0_-NW7ksKyPqEAoN1Zq7A-pLW89wB_Cu5tdBlN6MHlM5-vwF2C0FrC08WlwDEyhwmgkPE3RyrWLlM1sID5-pZgI8n49bXDeTplwzrUdjWQsl4XuSWtWWpUwqQ8PyvkVVqstpDkH-zqqNMa6mPvpPP6KsB0wKw5xQjy_zkpyz5TE8rQggrQLCGGhd8Dyg8ULgqk3Kgh7PHI89anyhcna_dH9nVzzQBE9TsPOShthE_u7KttWCFFVvB3ACyfkAmNRhSqOyvwOquI5lAEkWKiUj9phaqZwrW04lM166t66vMavn031_lurVuy_m7Jx_VFuStYCVINCyN9QrzhMi008pS6Lllh7TV6fWBoaVhTnJpZM1a2aiwUrMejcSu6zsbQ_yAMWIKcmqBG4qoTk5FSPFja9OX5xrBVouTsriE5MuUkyQfNxfMk9qllA3dKsSwMFCTLA7nykpOeP7t2SsQTbfi349WXjZcKwk2N2OOxqi759PNsSpsptoMDZ6RR_76JNutgZ7IxFckogDRiJPB-gmFGT2si1R1CJyVzDnPcnpQ9cTp61WKzGzo8OhhJXOsjRmktlperUaNkG9bydz6BvTI87fgbxNDkBEH6TLwgBir1dWb19Om0W0CBr8Y73iTQXI84Q82ilMbNmhvvLisTmcgf5ByF4GdurncF1msDPGk7uSB-_vvkdjVYJ1kzrccARDHrbxOffNQVyalJIa6gGdelw3S7-8MpS7yCu5LAFh4Oh9FgjjoG7kiboHj_Os6jeY06ziuuhujtOCOOftoVic9TOivsh9fwmwXu5DdAIcj7aLj3W1XFxtJvMT6_tioecRZrFeLhcxrBRAhJO_fVgc8uFybLSz7xFCXBGZ0eRXClqxKRfUB4AvrlawmVbK35SiUumqX1n48zuxRMU-D_jzpnlKZIiPlHeYH2ZjK2grsbI5CQG5ajWDOJ1D2Nj_4laCpGe_WV9oGqddCYampnoB0xTfQsrgwssVd7rzyhhvnBwFDGpOMv4sKEfsc6n6GsFJtTgzew-0TR-nLs1LDngaDEcWUPZ_CO4ZsoWu74tRn9J3NzVXC7CiIRXvSNnP8BRQOFSdasKnRv85w11xb2WVZLkH3q_hQzhCTJuc7Y4PSPuvH83bAoA6CWJJpNp1NhM_he6LKKtyRnQCcAWRxmcqOTK2A8l-SvTPjz1MsrfrEuuKMM_YJekGS6ZbKvW_l3ScjuTD8IZyLhh-iuMqPx80qTqwSiXMEjbdemgG-hUEFdWK_6pbUjZuj6AfkpQn_8OG0UvRGJ_205ygFCrhbct9W_5HmfazBbRYkPw90_uu4Z2yaQpNyYLLgiHV4T2OQ1LYSzgY9CF8Foql4vy0PwLUWi4SUxlmLD_HlqgyIv8X66X6rlU4RXEudpHKFIuyt5Tgs1XMVFGIgeenXd9-HPWj9s9hOnW-tN4zjelAPbYvpC2L_YxBJgaD3ZDguWqLFxQxpCyVx7cBXIA60Kju3yeYvflzIUcfFSGKx2zT7ucajhXQXg5w9B7JTJweuNNMLCwFc4xbuvrI9WRuFckp5Y7-yF5Bp6WwlHe_OlIuWHT9NUlWBEEdAzgMOE-pch_tjhfwizsTWWfAFX9ixwiiuo-kSCRz7O7OMb_zW48Wvqbn4BpQSFHtPCw1MD1QN6fMTHfe_atpKiutbf9TbxZhJpn9RvTQ4MM87bAMu_cOD4IRPH78TgaUlNRtydGmY6BCYa7ogMrETbx_vlM6744ITy3ZpKj5HQmOZE_cOblVuF3uzsv_2RlAFjh_duGPVpPIQbG_PFswksGnFFS86GViwjyj3IGIqIJC63_e2PGpH8DO-05I20tmVYEY6FeVKZST9Wcdh0p5TiOOQIvb5LxeaMQugtG-9eBIBvgZJKPsWSPoAvu8ZkFtA7lEgsoWO89LgI5_jiLe51Yq1INnpHuLSwPxpfDI1O-t3gV4AQlKVX2ZU-m7g8q9HlfkETXtuav7BMpBrSpngQKt98xWZ4w1SBK6ymJlMwlLqhqOlWJFIsfmKs7gxkpKH3nxRFdO62B5ZINmXz3wExu1Zv5bt2dZ_GuFVFP_5syJRZRuv3a79xTzMXP4tK8o8vnHq2jaD1vlX8zDrOhlQWZpQmH9epWmc2NLgmxzzxGtgXsYxadP_CeREXJjK-wxRrgkkMfWvKIjAeznzA7WYsjO4omW3JcPnWQnOIGUBSg8Y9Bo1NuNGC3RJNIKsadkxXhHhQeZFDr8emyxGr8pd7hyAzz-YkzGpZCvW98JZl3nET7TRmJKLaLJuH7Gfx1WlcdRnjLIFHVjulYrPYpYsLCiSemhHlXsVZXtoE65vxw3Q24tiH2Yrt3KrdPqGW6LS2K4vz1tTn6I2rVNMYdzXML1riDk8sVavFGkrqyul45rvsNf_KVJmaRTjdmFxaJQWIozvWX-ixaV7s63qBxVwLKoel-UH3kPqwIJMON-_eoQfgYLbfqL6dp7yx9XQK4cvEK-6yvcLSQkRYQSiruyDazx6wZgzJ4rMFmNPinOyn8CHNNWU4JV-EXYXRSUUojYjC3sqCQzNrEfViIQTQ4tI8c7DPn84elkOMHu1uhTVBmQ-RaScKBs6JAPO58GaXl5EDzd8fdbyaDRzyCM69GXFXWfh8hy-ZLD4h3Ey2nfmHwjcU18vMo6eWS7TzU5K6n-kTdw7pLvbgiyJ5W1khHipY1q06vgzDwX0DI984B9qWHGkna6EsUjnH6w8Gh02qN-dZk7WHk31YpDm6f0SEt1iAD4VwRpGeY8sk-IMs7bLySApyiqfiWRdEdLGsljy7htvv4hwG4F0xAEaIRrkl1KSn2IvQ8OJVUERpg6oBW1Z_DHmYQGi6_2o83OYPuF8R-OA2hgYw0miYe8YGQMEIg4boeCNhe1OYE5vMXCsGvC6PzgP-PfUBImfhszV0OKvZLJIKeRWBHnDaf9zQWL3uoK0VgG91hrce2WJEJnq9YUzkRmHn7afcl9YRpxDyAcP9JUCTwvQbXF1DQhjH2q_lkdUZqsCfedaSdi6OcYDkPkFxgrAeuHqMiTm7fqmPpFtq7jjMOnvBuKwUFknc2HR3iuhOEWUI-M9DHA9B9hqLgyPACuLynlPn0OI8l_Yud9_S3TXXQQHCMHxYtQE2B6dopokNOiFc6eNh1GfxOXCZto82V21Joc19O1MobVmp2m_ZYcBbtu7hDfH7g_f-z1Oizt_Zwnj-d38JulbnOCpBKvrCXCYBzhqSPJXLxGHQ1BvwKb635PnLgKNwEmufBuInx-ZlEzUpJ4T1Fv3jCqFrZu8si2CAo0tqq8IKNhRAOaUH3a1CWsFXuihkJ0KgX_FW5PC0OgZW9Q3BFEgZMQ27qycGT_qUofp5aOnZ1Dhhub8tnhm8z40bu3A1Pj2SUHgS17gkrOtZ5y90-wb89El35LwZPsayxBROtNiqLIgUD0gxY1ye68PlI0Z0c9ijLutHWQnOoZqS37zdfivDUGaStSZsXlqAoo2AMmBBCwAafpEthlXSbefLlGEIMIpYFEWXt3tK-Ru0brKSOZftRKXK2jOlubWg4_i8W4EDhMk-O8SW1eah7m8p_EBCGboZz5PBYQytg0QRHpbOXTmC8XmtutENajJDrj_Yn2O8nE66T7OP_2v_E54VkGutkO5L9Xj2ppa3ERBm7jeCssz3feeiTxAKxBa4XS6SQ7peSidwaQ_NplZqnhjcbbv8qSCDFI4nS3w_HWonKwr_lc9YXXoLK9Df5b-1-LH6hCSgjaPScVOGVpmrlKLJmiQ0fZ0kahic_WlsZM2UvhJGzMGUloZ9xzuUbhJyaVtLQ47IA_IPq6mmnnx49nECYbB3FTlZ3s5scyF019WgeAHU2WPo6bEpAqw8hGChOYnXbphaQTlgqUhZAdC-S4Zj9kBArTaYgP-9UzOlUD-DEjhI-ul_W-1LR1LMhvfzR4N_5aZEPHNGEFIzIVgWbNXqBHgivCNYFRWiCySJRh8Lbhw83Cv-Lvozou3zoTd-4tC36I1Ib67NcyJLn0ebHsVU3aKj1HNZmwaZIbc6NxRiB8LmmVVmin-lmw--X0CQfWL7SVPYfRzTMDqeQbm6XfwFDwKyCRtGRKi1g58v8bCPIXC_2J0Mnb3OcmLNE9zF6ANwPhmeHmOe-jf700oh7QEpLEhRP7E2rNhq5rtoJwoHfIwxC8e4woWHR_uelOijud12YhAKIDrtlrqPYZHfKaZ5CMafqBiIQW7eSCNdHnoBURY4cBaTVBYgHf1D5j7N5nD85LMp7O_Fg-SkxRBuCT3YqUBlKbemtlamkWqanK_TIuKIhtSgfE5O9_YMxa-G_cjajmlPrDPz-PKx8yJQgdNjJoER7avGVCEqh9Ch83_5MpDOFavGe5McXdMBmTGXnMSxHXXrhzjBTIU3vIT6y2urZdQnLF2QZN1-P6JjgvnhJ_E9acNqK1dFxs-YL0qyq7iklp9nyQgjRYSu65TPwzM2ST6cmG2HmkHw8eCMc303rpUjI0Z3UBoP7qUitC7TOiZjH8JZ8Uuh8W-8Gyq3h4WpmZ7kbLsn3WvXVofas_osyyp5LQoKKLfJoxtYg2Qd4v1DN3st0BTCNnaOvAHlsChwcFZPCzM2mtYf16rEXwdjlGyIhHM1mMESTxNTEgtgsfqtAOGwfvkiQnrWhY6tbeEbH0A4Y17lYfzZzq81zsgU9T0t2xGQgZF8PeyDQKVEzYbP0Oy-Ksl4ddMn-5TF1uZoxe0UCLhsTB9PegdsrR1FQftmKYMSEUPVuzc5CagJnr_fTtbAnY7ZTruvq6MM6OfULY06pIcVRHuT1_vTDwVHMjFK7mdI2PgPJY9Hc-t04AXj7Q9ZekcSO9d6aOB6dly4KDGaJGVBTfku1A4xXLKOJfl6xuMWrH0U9J2iGZmJVaMIOSkVRxgzOOGn8WHKP7ziwfCTYvg-nN7hRlMY_if6C3DP4jegTRIh-vBl69zzkUFjMHKrn0T5aHm1eBCkNz5psWAAvhf6VLzUcKy_FZozlE3EzTnQcnTydFspJwlwERhABp4cm2inwM5AZlm29B0M91b-Jw4dhuKjj8p7N9QEUtqFr2QVVg6WiE9ak1KcLG85C45ilL1XdN5uhVIa38CSGZXQpYwJrlOz8C4OYCkJwioUiMga1PTsoYZYgdLpJXheSxG8X_lPI8DT84R2p693nuYBiwqxsFRUqiDyK0BTNE589pVTGnSNCgTYOHXR9wJ8nXhpplvuA_ETBcO0Zr77XjAD262kl0a4D8dC5g9Dnwqg6ct-d-_wAKRYQZoZViiZj6ccy6GxQOO1BbM2mqPR5OfV8t83YsncT56h_aPkOSI7FhUiDs3N6ICzpEvI7Yv3BYF6tcZATqwlFbsLmHE5VpnWH1Zyw8Swwk0v9E4mBt3t_DFBV9thfXo8Gnk7S35L52-ApN0hNwvvpRNhVLHbBH01HkWJ7QTeEaMeEqouMjs1N9MCcrKiYF6N82GbbkvZDdGO1CtF-MLdySBgf2P3UHLzcPSUd-cE6tpYDluv7St0e1xrG2m7qhHBMqx8GzwL89XObtN4fw2A0fqXf8o8KLXV0D-fVclnbhxqXXFjj18VeBRXbF57s1O2BrnuMxtejOlL-W5XuFYXBLWmMQ8JLBj7eF8nL-S_Ef5BWFrWA8-FjuYU5xdrHPkEwamU9mNSQ13mk4YVtvbowLJb3fEnhX3T_moDWZFatr04lgQ1CYwZyR7iyetWKytC6q4sWtXXalbsqOLtToJXBUXde5Qd0iYnj4VKeDO31WEEClDzCl73qIv7vZEL1EcvZkmKsZRhIIJGRfSCK7J0mlm6Oy_3g84I8ydDoPIvn5fbuZaqUItK3iCo7AqQzJfZ47g3byb8s_sCmyYg_wXalSvhjhY4-V2zSGtccDHBXguBc5QgHTugmES2dEQKgSgcGIcS0B_uP6XGdudl_uXzK_hGGFQAleLDgN4QuD4QsjZOgOlr52p31UM9aNnpsdo9ke2BZlx24gDlPYVIwJeAyUCPHAGa4CQPYp9_siK1d9ROaG29S9IRX08DXC6_g7X6i-fHJGe6bltkD-QhDPkDgSA9ZDv8pVJuE_S3alTJGd0qSmQMIELcwg2llD3KOdvJ8S4aHg-MEJ4FAAqE4cXvilWBKtG-UUFruLLTtfm4RcwEjuA62dKX_5TwIxrool9upPf4A5pb51soWpfBApqOzzFsXljYXaciXN8UeO9ojwTpS25nPPrUdUBxTW-MhWibBMGDglj0i5_zEIIS3Mrh0iDOpKB_EKwRBJumvKl1vlKnoz3odlWPcgc0JGNuYrdD4f2hMD2HCc1_ALchcX8YrZE5jOsbHQpJAbq6jIQRfMQC6NXg3HqzlrkBdVy3NrQ6GRkRhhzkFAFfRSxGvWNVeXcMflXnay16acVXb6tK4RDYzNmvv70uDkVPYVV9fGjh8Pw8S7lpJuIRe-3409ZaoSWF_0nqvX2FxC8_eDitdRlQyk8CAijU8uOSCf9lH3RhpdUjzEgWKKe1XEiuzMCJ_oznmXoUTOjcNWAtVKdjAP2OD0j04BySHiXTmrW8ql8lbYDjenAZX1dwakFcc9-PI79AGEWYotodCHLZP4wPtik6wcVn8EhHNJwG8ZADaEzj5tsQjnwmLxT_myA3xroVA9I4jxJyexyC1k4YYhsNFy1RFCDyVc403rgzVttvK2DmAli6YDg7iMN-fLcP0oXlozTzLxAWisfl5PaM6xqssIx4OBAQ2OJy1lTVi5fsBAMr6lfBN1icxYmJOG_P0_b-KIvu_oOcfYbthKhdmAVavy1P0Ip0p101fMfQ5LK6L-Bc334p7BDdBsnWI9KagREhCxcfy_LnTCQR43umUIMF_1wWYLFR1zhtJP-ZrKH8Z1Ikn0vXYT7PcREGER67Ff-2i31LT2vcHP9O_N1txMwB8-DI83EOMkJ5lGGUjSsboIWAVxbSS-EIGVS8EjEnITIOL4ZoNGNwLr6S9_T6jTNUtkT72bTDDqyWyLHmMZybXcqDxOZK9TdU4GQ6DsjjmftqN_cWOkrcQLAYFylmGaruUh1usbzu7jgfHoFsctC4A8pvoIWbWnMi29cUj19w7AsTCTjaEDcFw4IsndBmJ7xciDgfIYgmpKdWbfPhq-VAz70SU3_o9a1gowVRXT0nOWoltiOohaR0gqsjTJYQyA-KE9ZQIxESkU13CMckGs3B-5mrLEBtS3lw43-QyscO1R2j9WIfe4AUfUq-2LHWazP3WEyEpA4jr1AgCRqE7QMNpF5BYO2RWpn2K-2zvVScgCWh1N0-8O7TTe7IKp0RTi9iBGMHhZckt8FVUz9JsMtkeguu6quH-f3ws9hyPG6NkiDCBtPLapelqtgC6esK3YwZAeeHfy7m3jzV0i-KkunRIt0poScf4JuQk3dgRZikoTn7ncbq7_vhc3hDUGa7c02G6B4ma60GtQUa3hLbl8MUUTl52_JVzeBA07dcTbmgrzpN4VTNIY-iDn7fFCThV4c55oBQoXLk7vBTfh1Sy1FV-TnPbGby4g1QXyBvFPiSHDm1fCxBaWiRoGwiaA8bkjR8bu5inP6iJKeGZWVd2QdYIJwZsuvQHu7GdYFI39-Z9lGXQqUZYDF6H4U8WBougHQdg82q4fnyd9F27aZU9pZHEYfEl7F1uRFe41-sMxuKFwNzIUE9s-ojLYw_w7GK6Wfknhd3GxHvjr19YDJDg1gwSWQwQ0Ki20OFpbyNZ7U5FrAlTNeafjzCuLXCDrFwQdsd1cQw7XrZKNtFASsuhpM6Mv06hQQS0iiG65Nky4pytp44UsrC6QqmvOYqw87KzIpn97LjfvnknDHIKi6jS1oE7JnOTN2Y9rU1Ey6s6W_D6wenlSXbOqHQ36urYx40aMJS6-HKYHXlz8M8KwoUTiGUSPUoyt_F18RD3VNc7kTgNBqsKgQ-46CHjCB3WgakIj36-uGf9p2kQmSSjzDxbhOjlGeE6ayYLKdGB0E5crfKBjs9rTE1dQUKRbcjITMdsyAnX59T-uQBQCFXMZCO4icAZ9sXVJyC_CaGzVP6keF0IhmoKOqVM0Wbqu9_jZGLg5oJ1VkL0WdTgbWN5Oh8zABaVrWjLKNpGX3xR2c7QjsMC8NO9Viy31CW5myOwLxWGq8BqdgOyx7PYkS7572T1ZBzUr6XU= \ No newline at end of file diff --git a/backups/backup_v2docker_20250622_141119_encrypted.sql.gz.enc b/backups/backup_v2docker_20250622_141119_encrypted.sql.gz.enc deleted file mode 100644 index d6affc5..0000000 --- a/backups/backup_v2docker_20250622_141119_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoV_LnsN3-SMsngEcIHN2tSUm95JArJJ5FE8ISHXbauJEnYEi1VhqTOSU4eMMiuR_9v-PYJCA8axTZmzvqrDVdbHdODqYPoaqd_ly3SNvKhNZNVIg0tbHM7xXenf3ugNAzBT09rGorlAabL9epBlSyBWnxmpS2Vy3WyS-keal01ODUBIHbs45xVsqPQls-gH1EfHuSpgxBrAdFx2BX9ttjL0brSZmzdLbpx1GW0TXrnhbC0p3AR_2b4weXBBUkXy52Ai_oDpDy0cBOV_QYFnPcNwM2Wo7WNdsasLtka7zo9cQ45k8OFKwxO7gZOCiVnG1MROlAQ6YYGWn70jWuOIRxtTFsQaWGs9LyfnvB16eIykOOPMDGG6DicTKUVktsFwpYl7Q2zWxXHFUWeCmTq6SIvSKPzBmOVsQVWc1LX1vNM5Vxop8ghwGhqPyFm0_lKWsjkU_7e_hoL30Itlq2ksXR171nK1JG6JHOX4rkEKdAJrL2YX3cw6WJqv5fQrj5WXK_tnZ00SjIo3iEZHTmIOlDLkpGBH_Bi-xFVAiCz_d5GY2BcCAqVHXTC6gpnZ8DwyMiCTCmo46TZS5byXcbzj5qJa5zuAzxbEoF1QYePkUKRXPEzXOKB4nlnEZDjwIpTazn6BHNKANbWmOxgsC0GpPh0Zy10DhxkQhT5xf6I-D4hwyNbn0RI_EQIKz6nwuUFW4Ki2SP-Axsb-_F50ENh9BIdpucp0BJHQsqew7h9KKx2vuYdSMEoooQsdMMF2obGuBQO7WzFYtk5lc64Tzc4_x7fLZiKEjjK9MtYHUKnGGmKNIKIxL7aCW2KLGnGCvcqgANcrC6tXGFRhCUTUu6OYXxyinZlFH2RWvo-RCqb7SbzxVWsXB3SotN9BiN-QBFXgm5qgTFLnJ51CmO3Q-3MblVMvi1OxV3sTB-pl4OpiV1ppGIzLv6eQaIJ0CgbkhFkMweVBQuvAL9JarnAasmw0otgI_ztbWFHZj5LDaBRkt6xenYiSOhNNARQo7j7UXdHG2oYWcC86v5bxVLjFjLGv-azcZ_Cl5Z7FoW9V9GXJllbdNCh63vHGBNmDnTOf0SxfywSIViyx8ivpCCr8KfeA0fULrGrq5TGJDPtgmhZIZx2ergVWyVANjOsYmfdjfF2a0lSZ86zuafNBbBHdTNQS7TBUYJK6FAVPH8nRRvdx6BefzKevYrshs9I547c_1UmtS8JcniV9iY1oxtaq37TsmxIkQyiQCVS-fkPdQ0klXzBFPC6J3TP2r1sRZyL19jlgzwpaIC6FDUxL-gAOY1l1fM0111tRNQNCg5R1VjqHuZbH3bi7sC1Sx1tYb5pW4PAhaFfEB7s61FbNh1gCglJtewID-1fupfirzBV4FpOh-0xmREd48a9UtPQD-knax3NHx_-tSbSYgOocg8-1NTX10zLutnHAAoTdbKjIFyYb0neGLaq_fLqjHHu6hJxhDa1nqxsF6C5XRV1Ut8om0tUfrkI2J6-8kHianNSBHZ_foeqOXIeYsNmoO8KBtmApZNcEU5nQCee0CwVCvXVTfH6xLLx6SMB0GKNJh960GoP_eaXPOndaJo12O4FrOZGuITNN-fvW64jAUJhxNzRAVQ3x1Jrgkh7mUFQ5ctY5XhZyKcu3iETy8K1O3xyXafH8zqLEvsYlGDiJYLXZ6oM6eSB7yWcDz0GGBVxBXnLKS6NyDOloa3v0v1Agaise9eBM2Isf3q7iqSYHeyAEAS4bYdPl3oks1CRhacNIFTBw0tlDC-yGUr8ed0xkL7Nwz_WAvVcZauwDwrzkTL1xcZWnS4omYZfSsKGCCzf8qB-1Rq0Zsecpt_O89UbWhsYyjbhBaohe0UncwHGhurQ5xp1hTsXqRZjhd-a_LUPfSAfvPQ9YE4hEopBFmkwy9mHdVRk89hueyaKyg3Uu1TXYRgNhTwjXJPbRsXvmnFb_mUnIbKrzpqDrFpJZzVhs4U_uPp94mpMZZykewcqf7nq8WsKBXcmJnSBPu7-EFYWRcakKVq0NHzYIQTaHwvl8NQgCm-iu-Tzd_vYp7W5vfeTiA28TI5Npoh5wNCLhLwH5XCXklhdx7H8UCGKsQCKbd4mNUP5Vewndq5MHK9AkC-HrCVP4rdhsgJusNulmqOIpK-6id0iJYSK6C_seI8rqBeU2-NrHsLKsRvE-on35be7stvuryNhplV0CHI_Xp2o-OUNXIiYjt1vjUUAA45f2A0XTn80YF4NDMAReXTumyczk8_eBujaraRi6fmEFAbv_e31I7cLaG3bJLVCVMVVLUQIRIp7jGnWCcgWe9dm2Tm4TgKRQskS6acBVC8HvjijbfEmBpOEBN7-mOYQ7lBNWOPnmcF_WGrg3GIyNCp3LzYFh8ZiIrpXF7is5AQT5JokJEqupVxuxyRw0rOjJEmKrmLFL5bLP5nhHgv42h-ODesT0JDuNDyj4zBuL4sK12ADlJCss1i9TSQSQQTmSvZyKOA1hKboRGgRbbdX_p5S5W-Iw0NkC122-_EuXS5m5TuDbkdRWnx2yfr2VQGf34NUQMOaFORRqZNa0NMfC46oqfiNf6_6DZo026PxLOvkwsfiAeX3wY4MZ8mDNHPpKu3UQxvnmT-WlPQYRhj29ODnMPE3xqqmQZgsd34JQOT19BopFEaZpan9hh7_mxv-VyocWaw0yoq0heKbebpvw29TLS04gehxPq0R9_btDAc9If1Y9lEemmqavNS9QL08qlJpR43u-9CnDiaUc9Ofad-PY5IgOPVwC6Dy7rPxsuBhD13y4gOuQeunvONapfRviuTskb811oKCKb4KnJocprffPnTajhmOJjxePvTr_-E3IcfybHX4zYGon_42o8XN-BYvNsWr-06M29CkEfK0UQH7tlcpaIZnEAGCThPwQ_OVcVOQ2XKes-a2A4hNMiiNrv49kUGP7RLIZ3GElZEtEhq0-GhbQQxf_xCj_iLwqZOnYiqszrcXP-0ZQrfENcqdM3UCdrZhz2syB85d1ynNGiK1DtKWxxDQlDLWaWvbn0JkwgtXzm4j7kZFB3J4kuVSQZWptZv6vYjcKjDLq7YW5UpB3lKyueFw-60uKbcUCgdDkmIN8Ndcs0in6Z_k75KJLXeoC9xHxMSv_9MwTLrDCT-1eDH0rnomeIolPequ6zq2YAqZzO0sI4aEEQIFhxk7F5hMh7ap3VP7309SgSaMDqlZJ7t78C7o_CwaJvIR8WhEJ2P3IoMJxxfJGfXjjmKhqIkDA4y4Hnte2vxv60x7UxgI9sbmKz0vKRZzuNLBkrfHnam9r-o_JQ3H7rS7iLDctfkFJ-TBFgIpdWHdYz_IVXzPnWUEtN9sbn2Hsg5di7ATDScp4Ex5Lxakg6WXBaUji8Lwc9L9mBExs_-bhUIZJbF9GAsSCbV63s1c---IncZZqq6nqx4wlPH6y_RsnJZjpe8APGaY9WDpJZjp7kUIsWa4FLvkM2Uf_RdvH2c9uiVzuByCfqrYS7LiHfgMaWi2DgpOpnbEb7yNtzBq4iVqbGTVIIA_calniT6kst1WFCm_wNgTcdIPINA-6epxpODMpi3L9vBr0EnX8n9di7NGFI8te10j6lAFfxUm1D4Vx7Ty5agHqjlhBP6ukKGAylYtYRutmWCGsx1jOmjHT6qTpyBRc1eldPElyNwXuc8zhaBbu66pvBQZzfDSRF8-YwL5Q86_7vrs-QZP4u5_J8pwE51Hk57L7BYSKSIR5qXziACNP0Y0wuAdmrhq0gDNdkEklCqZBINEfZJe1gATJQbCA4Dlg2LaOGoq5kvbw9z3J--YJmOAuG8ooKoqCEDfD7-GZ28n65oBpAu1VFYQrpTmgHIgvtmUKOS5-sVEMXNSENaHElRQLPBxe8u_2ou87l8KwVC3g_MZPHH-pKVKWhvs-lYNonblZ60MzKHuxQ8TVAaEfQCzH9m5ibG7jbB7tzyscreodkj08uxL4wZqxwDZ_T4NL5bQignNCgXReZNIZfYW5hCf8GzEc0VK9D1P5crONY8gjjkUuWScXSrW-_2ahH-J5wH2dWUwn3GPYVj6HLHi01lGOm_QTty-lZXL9bbSPrvE7V_DfrdQ9JSMSnDiYRhprY5BHCW88xqizGAKkio1YMBwwPoM_99cdNh1MKsj3uuhMg5N5ZgAwjs4BcBgrpIuTmmepT8z58PnFoAIzLbdRmVkscslzJIjUPmCtf52Xy87lGlYv6n03IVwj1-EPDdSASugcAGSX1JRyhs0IjMKM_tO14mBvocBtSqYArYAf8ID03A3IErCGR1rBVEhVI91cQEckUi4dZ6WWh3KARMgylRKfbfj4UZ_kdfol9I8A-vrH-KRQRP9MDmb42HOkyXHdnKEylFrmOZ0lb37gtDuevJ4xOOvdAz-Oq_KDqn2SikuNU1cNMMr5-ILSPkoJI0qWDcGKqQC-pQ44RN8VEUirBoMvIV-ExUkVCrlKfrjvemXpIq6604IRMz5Oi0LON82max65Mrv0vYBaco1m8vPbjhboxmEohqzKpw6gfEP42Gvu_LkLbXGBhZTYH8Ykt-uv5iOXEqjQRdubTtz6o5kvMFX60maLV7dCfGRkZGR7xa7y3ZJ_KARGcwLjknbfWePzsZWLN2DP4-nHh_CV0Vcgh0oGPSIxat1bhi8RKrvje71bsPyIoPrupdU2obWBZRrJHkk2dK1bCffIcIVICLbmVb3Py05MjHPx0CcLk1hz1TvbUOdF3113ZrgPQLHr6106c45aHCvxwZMRyzI7PHgW01OtbMeIvAdu8INJJDk1EDtBAk_E6J7yhivs_hh_m4B8P6ZOOKIdozubY6LuUJ7ggRGWnlaxM7PEhtmH0YNchzu265yGvGlQ6_mckAVF97L2eZK86SG18RQj0wp6vnp9uWdNxoRWJpuskF-OmelI2lwpecQlMS-CEYGZYaXmy2HKGiJKbTrwqY_3Mvx0ZPD6WCXVe4U3G3nDeVKziIGSlzpc-75gfv4O4xDZI2dxImowrzjsDui40RCF3MfTLdQp16mLBU4LyJPKQys9bs64sjnGHaJCW6-aPJedci52GBFgcLnGDZQ78WrgENr3MDmYk6ZlgGB6oXdeVXIpFUuAK-FLv9OGBn8rK67OCb7MwVypVGI1C1AkLKk3KVCv2Wfdpl-6Gtaw_5lZfakVOQ9-NfJw3BfpY_rqqo5ZSncAddfzoLeUAMEyrymnDqym132tj74mawL3WICpOywtH00WwHie2OPONvR2kLCGAIp6T7ml5qz5FLaIT2T3aIjodhCWk_b-Wl4hWSLlLGPtCJDRLiBZx5aeyFLGVlTBr37oBNeot7imnUWRS-KxB3JHmttXmL_2uV_K6WE0CS6z3hlqL-Dg_3cXwLUQxxeCZ4rddfxl7fwBJMe-LajpXzbfM0M9lpVgI_kkGX9yPssuiiw-FU8juquDkhnGJ0D7tn132oUB1H-6BorEi3EWEtDo85REHRi8jJSyTHGfCEvDungA8XwvzLHCcDJcBRWjx-OdjWW-6gu9S4sjQ2hrQs8Nz2_KVYgMgd48LiOAi7ESFXLyu8auRZss-WlfWtbJ_wIgsIA2OQ4oCDUTeJZZTXyNIHXia_mSz7OBocW3uEikNpGmJJzea4Md8_Mef16u19Cwr-AXIcTKj6US7VBd4Fe6w3nK4O2OWiDNE7rv1Fg2lKOkhsAoaprCbpDkXZKOSr1cWykgaX1LLTVh7mNjb6Yzt0msLPyCrPUdOENWrlfP2YvfUeX3M74sPGshR2zt9GDki5yuKAkQcuDgr6ZdrckXlZqr4OZrumLNrAqlSjei4sXmymXNWOmw1log1t44b6Wp6SgjAM5LIQP8bA-PYpVTfEIgcAYkhCRMNEz6lP_GI8JMgMN3bkVBDUv2Va5BlyyRbFrqK5l6bkVc2hXAEEA5SLW-xCQfFmCYYXAsJtzmCSGj5JTYDS1hxcFEt4W6Zki196ZTVcEveBk0giKuBSC8ZFRQCpBqCl1u6KeZam7spsmS7Q9FliIlqIe6wlKgGiXnl19xHYw-EyegDBI29LGIEYtvQQkE2K43w9vgcfZMw1n7-ogf4sNa8Ja9zHGTHLMSpsIvtBLmRfJq3OYOnzh_pwDmjRBgoSM67WX-bIbNGcfxatKztDl8Dj4dRLSMFvKYX0AqZFCfVTUt5QC8HU1xzxUgZVkEepU6yOgHw4q4Izv_-F5BNNexnhj4navCyeAT3qbkjYsL4OELiL3rBfbuTXzFh47l1ukdjQo87UzIyTN-rdprAN5xg4oGgviliPesWb_jk9cE5RIkK0oVITxNlv8DATLq0SYOUqPpGUw5wA_lbigqRhIerVzW0WranjKmiYS-xh4ZJW7koDw8u914PiGTzkEGVNHWk-FMmDIOcNX8EDmpobC6j0yy5691FwksC4L2R9aeNY_4UUdwuylhpMjuUZVXL5cm3ZOpfKmTMSFACzRIjMPfcCqY6PKowqzoHlzu3cQYshvp4x5YnxPeSnVglp8ifnn9SgaH0TCra0Evo1GFLJHiiG1zghqRLjmb0_AI9z5-QbYj7CypAK7onnVbFo8EQ1uoeOHIidYNMMAazfYtz_S2YyZlJ0TJTVUI9GYyHZ07m_0Q12cskRC_VczhjxjvUhTh2IYWYpLA7VqqPF-7t6q7iIWgpNJ0p0r_1DOYw920cFN5lYt3nNlzkmzeEDuS8IV6aUZjdFYzjrQ61aKyzZA8SaMJRrE8dM139JWMYPWLRRrYKK3NZnCuWnoqqyeHCr0S6YuLrBQCw1-dYvyfZCai7rhL7Fde-gOI6xpqcQu60rcYeUqcinlA1p2hqUAztJ9ld0Ze78ZHEYwcRz6p0NMqohGkUGw3_qBsk8dVBp_KYjvDT9J0YI5vGaaE7qLIExSUpkGtWwpy6GSpESypxFkKn5qj4-hfXgHD2aq1JyL-Zj6WNdaFf7ySCFdHvG61QOJNDGur7uDubDfay-jm51q8ciDVf5cOnN7PkOzgGKICnb2Sgi-kspAjS8q1OrF0LBDWBUHqB-C1QIp9QX2Aa7w0AYvl5qi1iNUvptN3U72fVx3yKnWBNUtBPr8p1lBD0iWjQnf2MSk046BvWZN05-7UgbsNZzSuOE-iWj0NyT2OJbwvlG5rG7y2un5HUzL7x4CN0oVDzFqApE358h4dNr-P_7K9Xa2OiBKrkImFIzRhvbtQdMgphIUT5GRSlZAc8E9xw1H2b1QnNMrVudxCtPb3K--2ivJkvkYlgzIl2Ufsy1OedhuIv2T7eggJT-ePKW4huQP-yXDV_8CLVZHaZBSarIMxoHN12SaM-LSdr20NY-eZadlWcPc5KkffJtItPUUKYitBVsok6iOHqnl0WRfgWtZl5hGzbRsGOGB5vOIvVv8yoLC7WyyUupuSfUG7aKgq1FWBiLClJKxQr6mMoyFaQTiBcIgD53a6d-97DmeqOGJ4lggjSdSN293_lJ8hFbsRfQ4R-P7dzDPw9fZ88z2zZSioxEFzJ8B99Pyn-V-lbTNaCeOcXoSpq8Fylq-5GgE7fWpWog7YoTMhxqsezfsLEf2Gi31rs2cZ3EFFQ4D0Xvw61Afhh419aUin_8quGaFFIV_jt2vWJTmT7qE9zTJ4kDJViSQB25STenfilRKBtNRoB0e3SyXGwgpDja0GL4w7I5IbPxuTWNFHQThI7UiLIGdS8qtfqHlomYN-bUa0KLomUj_at5ns01JuSKvWKcsDbUNrDFbumK0LKN4JuHLTa-AvnqM75R0DSUj_RJ8SXTOPw3BOrCPo3RYXmy01v_qtJYKYX-uqki0B2CnjKFEbQK5k_FPZFiNZLgYGhHafLbQwxyzaJgbP9-5Ak5RYzMiI0Qq23ED1vKz30ccdePzalDWag33MowdExz0TcSVZsjzmjeJmnz3ezj6PLTRsw_KQ7Ie186dnfTL5TTVTaREURA4oCMOUw9Vcn77ryirHSeSHArlO0w54Hb1dGi5tuthjnuR9xPwQWxXz8C6XJs7trnwHfalDaGwJjN7oC-gTyMRjnCPq5JyQ4LrHpGECA02xHQkVDLWxJkh2rSRB52fEGkzJSxG-M2SNGPEBtv2ow7XvHH2Hiyg8wMXSRFyexjPPxtQV8ZXgOOl6MSJ-dG53-GehB-SOkRB2FHlqVOA7b_WsFmO8kWGYXOHbdafM5jc0dESgYCvK3W2HsC8txXDhlutmCPpyO17TNYlhnBiQuyD1_e-MqG1uFgvdX2gMvdIoHqoxNoI9tcusocDWKRkqGQQQIs6eQZd7OOJRfnOyMrblzTls7BUggcapg8WejhPzrArivpohKXpoYcJErfvWD88jgbr9-q_FbTMS-lWusfWMZNT-5glsLOgIq1X9AMW8mRTKXkEjDzRGEoSEV969ptEFRR91n4hn-EoO4GP4lOJprISFCky30lpZ8ZYh2uQZqwRioFl6JLnfCBrfT4OPljQW1hghD2wIe8HKizWO4mp1tjlJDnIhGGlA20hI4nuL1ekoan8eUY8cV9abM_EFEPDxmvfZJGB4QOnrd5jhQ21ynU9Fl25IG4lrqMysA9WmkRolEaB5Sq5iZqHIGZN0kryro7EUjzKZGbe-fh8xEaUSmDj__MDSVLwNon8iDQs19RqWBEV8L8lwvvEwIDeXya8KwAWXWSBt0dDIhTAyRr4iBQSbGq0RIqkdjAseyvLFkhFLD5f2VPs4azNML1T6Y4oy7__SK0XKmwtViZJeJ7dTTvl9tBDA4LyouMrEsSdVJvF_md8q4ofBql2V_xuQKbC8hbEK6PCvrc6-UQ-1T6FqO4Fm2DLEVx7hT0iOLez9jcB6NzfK5s4FdcJ0CEHBkLnp1EFDNLvUR5DKJTOMYTpefETiNP9Ce4FGRBcIu8RNOG9TBpGmsaxv-AROCvwdD08-nRIGQg3r4zoca__BDGywwUSixV4Edw-Bgc0uvlY2X01B9CyNCyJC4XKNf31NlafmsJWOuxC-yc2bm7L694aZNcHBh38bhhMwZ9EYZm35bXWV3hI4QBlQUQIhuAdD06OQPwfeHn0RkAYvgwEdwkNZ_eeCdxnC8GuBE07cv_8TI43LXM88_0x7qrQ-pRUPMQMk0Yy_0qUqWMfT8CB_Q5DQy9UFI_x2knobtOmUdIxg3ob4apFDWngSB38HYp050XLH66LgMTVLVa4up-DyudB1i0zfz4ZRyuY1p6y900Kl4PovcSMn0-pSa1erTs41Q0jRwjPfsCekovfGHrs7Q_1B4R2NstQ5kaj9PB3P4d_gxxvu-8HRLVAuVWufeDDGd5cn8Q0RcTtITEQmSqpHzqJTc3KJxkYRSmnvI3FOEqD-HdeoAs22XAjGEbKQBW26zRG0AYbX6SnYjDhpCpjtWuqrt89Q4M_1SF1F3f3gEbat3a54qjNkoQyAkxZ87La_7jbrLKDxIcXSTcB0TVrlcdbGNF-fuj7eXMwZZart0Zf-_Kgcn6Va88Ma0wqNpT5DGetaFWmh24z8SbuRbTTg3syxiu61l2weqVcd9IH9D00T1boVXVgppDBz6LnvckBqLrk7NPk1r-9NcaffyRjyCij0kDByeEnXCepoCM-0eDwzGkwWXewz8a7L5_182fJ-RB87WDpJMkRQPHl0hOIozf2xYvqCG27X6Ui-7F8MqhOknvj6mebAQCp7XkixDAq0oAO76j3Uhchm0cACXGdReNQCE5uW8zTrGRB5vtt1Mi_1Hj-uS1dTDR8lcJJTLgq5oEH3Jjglsgdxce9emliel1WIFSIrYLn0ZLiDaK1KiCx9X0Tr78GS_MjTry0fAMX-2lgKPc5FzCZN5sbpt1vGpxtLpvA9W5vvc23CsV8jEA2WvHYvDUpXflAmMxbMR-OKtvgUwWYSh5UIAdbxrk03yGzzBJaT3MZgg0zdaOELWt5sHXW7THGDG8yKE56me1Z2nT3CgehmRu3DELnolNIE2dvd-MxT0G8Ok8Fl5FH9KyYpLSUjLO4-Ir6Wy6pjSK4L0ilT0uh5mn5vQJiTNOY3lCpgZ04_hN4FVuXYMf52G1jgQ3pGXqamS6bh9uDdu6fdUlawG2GxTTXiybw2NLV5OSRpQ_zrdieyOufbhoxQE_MrCWCk9DQjqYGtpACkrXvV-38clMROplJisjpaUZqEJ0mEg3T847MUMzSwHpw_wLy3PBns2chuF7Aew-k6wSINyeWenFdHE8IT_wzCmuzo1lRSqAqirANT0IWStmhK6HBrv5mVL2lMsV14lDTacbG7EHK_A4VaTe3XmaBe8cJCad3T-1QcUxfRgwMdmPpaFMptAR7AlATqDldl7yQRQ61ooKdDQYvx9Itrz0NM9954vIIyqE59jHoNSeBdSmDQaRQbrNF3DqV9eWz_5lbAEt_NVjmRgJHkA2RBKLqbPujoCfWuRU48Dg5reZmpcafJkvLdFOxYmCQiNHEcibhauzJr8yQwSGcMzs6i40rQch3Wt6lG_lpFJjH78M40JiCQxgATzjILsc91IV8CoTn-Hyuhu0PluKXjoB2bgyaBZkJsRP8FtgEqh_IUJIBD1c3RP7aIXhlMYHrH464VC8fKaxOjv_hU6zIiBCTtU7Wfwefmm4EBn6uDPM7y_24RGpjJzyT7rFKBhUVUKyOmlZOtNOgzeCul4DlDRM-6vXYMXGd75BjalXuy_MU1IWKiQi-A6gvChLjxMt6nzqBmYvQdJmKlFkjaHPs1f2TgoP-IEF-I6xncOdpil1HwdOMb_M64ZXT_DkWIBe-kJJ5IE59ww3_TYMqXNcPkv5Hka54UzlbplMtQHHSJuUmyWkIIQrDhS2PnOfbedGhpfgEpulBLrNYYmBRBuBSPKtHfDwauzGrpFX_SaWgbZbE_zpNYvJsPQw3JrkXp3sxVUKD7kW5MoVCRQvsJIwpyLndPjv_YV5-lj2yTKSkLVd54e9YrVKuWkm8WjitRCYGVgPRNC3kdFgTcD06ugWg1RnwMIFE-JOnJADOzFARdMVpar5vH9o7HHxmQNBWxwG1AgufKr_zc8YIwhem-eA8oaPyHG_o_QiGpzDwZbpGGeHGZ08MAnEOG4MnLzH7yNhWJ_Q9CMM1fKQ44On1tknNvjLbx8bhD0DF6d7JSg_AAoN2HzhGhoV0ShXEWqEBEn19QDpVDkRBXgulGnmzrsugzFBJPuRkhrFnfPi9uNvlFMhRUvMW0mY2sRIR_tbxw4CM2sSvLe3VDR4nJQmgciUVD5qY4AAxWcj90PTVDguzw_OIq2fFQdVcnlrX5AqN_e0BhVk_-__tGox7ArTA7r8ZoQGwPZWfKKXGyJPvL6e2nVVd-i9R6g5w5BD0MaCpZdvZD4_qBb63QH0r8pP471dBoFiSn8iOumOXZ1EriLYa8rHbWdqgYjVqYIRGdYP1kRc8OD0TlGoMVcBUX0KMRwE-bDEfancfs7tKjhi3qk5j8Fs048znP3ottj5uoczrLgqFRuWo0OHR3UGctdN76vU2laIicrlxDln_7bzvtBWaqmtPtRAIth5PeWhB4UynN9Z2yR0aHfoxyI8DwQ2mNBXWVxYmthjaBh-FRW_i9eKOjJdN3EOg9HrWUYi5NnPw-D6BUEi906DK-9XvOhh4VGPVmBf-bPbDvrXvombMklr_2IWzLgjG4WEsyvwtt8SrqtiMDamG-lVusD21kQLrK-cbGek_1xtIEf5cQKq6wjVIRMCOv4rw6P33_bZHzDQ43NIp1yQw2hfi3a_7iGMQo7rnoOwXefRC4uMPx1GGSy1ATnK-Tpbanm19wrHnuWfeNehrwvgd-jg4HvRpJX904MuLix9d3YbPWdy2DVXQmp6a9rDCaC8JVozn_ZgKEuhV_P8i34TV8yROdezZgbmW2f9afHeOXH2Te_p0OMd6MASXi2s0BKJyySzuGXlAq7ka0WgdzqlYO7aAaC-4cR88f0YMWr3tuviSEFSehV6TYJWkfUGDU1JQ_YdDbawZh8erD5dSFFO_RqTmAcliwLbHIR2Pri7EZbJ58bQWGpQfRMNay0b7JElDYy9xmBPY6iUPgNQA2tGhvqUceZ_xMvZ1rxSJA6dLeRpNU8JmcKmKG7BCJkR0PVrCg9OYBrEzvSwXJP9FpaEm-dfwRjM-ZKxv_Zm4HDgc0VRjJDmEW25tWhRXJG0g3KJETl8M6l78ca97asuZNp-mW6Bldi0WHSIh0ttjsqyK8Evt7k8jESVDfKjtG99TjZPr9GCecihxob_G3oETnvaUgWEaN7k4HGlLZBspeOZNNG3DYMmNtTyBQ9kVk2zAaDfBlc28nQ1G1OnhZerU1qbk_LL1gYphRcb_rckRi_1YYUD06RYOi6wtAVS-0NCRUV6GRUDLwTY7ldt3dmqgb5rOCoKtu54VD74_scQ3VGGsrh9Q_nI56u98S026pz9skgreEY7MSuDqFgVNNuD_zSs_zqe5wkRz8dW_nqcfImmrhxRri2TqS1ZpFw-0xsYOZ3mwc9c3-noVqac28gbTnUIXA4E3pvgtKprlZvyqL9_WREvivf04O-JQBkONUvRZDGNgkdywt89H9pMLChvBtqF2DM58jEvkRkNzTSk_0PqszQTtF6HCjgr3NFAGV8CvR78aiISvvey9FWZafRJ6NDIid4-3Ay5MoCm5kS_phhfpGSfspxzJIU80J2aiCAhYNubtZXRv4KUMvaZFTnRh5Y2WIwNJZGx6PJfsXuwo9fu1LdV4G1-K4Dmj3S4pSM2DqnD3NoL3ION1Gpl41D2-qbiB_kkwmh2HE_GjawGT6u9HL59p87N8HSArVluWrmWs3j59-kHhODGRgjQ9RUzz-9t3B_Irj-VqwA4gfvYiI_zqFTmjVXpMiLXo2P4Um96eRcqDgjz0G3YiJBuuLiaPuI6U_I4mAdSWpLOo6vXQ_q_dXJY-hNmGETC0pGk14uENd1gXkjeEkQgmBNsrtwHTsisGv3fHN09fGfYObqp6gsHqgn5WcDlNSDkgGytSQ8fncMNWbm6dBSMvPeUsL7TPvpseFnyFuz2sKB1pkogWLnsGMCleS8abc_bagto8YW9GGqfeYEMRUOdoViqOraqEJF3QzLQz9DLchyKzhwmjVGqluk-7TLaaTVPYhNj97raM78tMLF8csVUhvdRdIb6BWUBcXXo0VEFdEVqVy866gEsldK5z4QWAZhxHnnrgDrwv6rfjAbjQmjHAtbMqOr7yaIBieJ1pBOis8jOMEEeebXZmq5ww8y2lG2M_-5US7YL9oK7-LfX_ETNS31cYD0eVzsR08VkjDDtpO4jn5x94ioWbkrCfX6zzZQa198zeeXBkMk9whpaT_48uh7qBmsZiXyw4C6NUmlqU7O884GbDhqhgTCmdMIw4QHDg78BlAKq5ncXdJDSM9Ghf1-cMI-jY8ssneNN07AF0sbmNcfYAigy88J87DeLtH0JMzCDP5tVcfl6Y2u0Cw6sjAAR5EpKyF5NJShItLF144yVjE6Ri6tUckKSbqjk3j8Pw9gkhIi1Er8hhNdPwQn8F6Mfx3gwyROyxlP9gPo3i_irhnDmdMTlTCFGOuLAUYdy3-vTxjnvx3uknaL4tG62ShzYd3kOTD24Hgw6WYDhCVS-qPEEn6XeJf6f_vv-3f3YvQLmcssLnFKq2aL7IPEK8LVJ1GfGuG2pPUm-PpAGfLiGy7HxP9qJDtyWJN4NR8dyN3RE0WdZ9s9VGNri8jeuOA3RRKhdmjN6INTaVp25hWwQChubT6RmwQex6Q299MJrq0UK3Pi5tJB5aIyoaeB9q6yi9wOS1gf0qCDgSUkbpS8jqcSN2__nBWox33q9nnBhGjDnz0LDHycmNMes0pFyLds9RAkkq4o4SRKuwVX3lR3bX-gkRk-NEJTeCES5aN7ArqSLIa7XIUGG-Ge1bo61LcOeJ0glUdiGmnacPBC3TwtMvqWDy92XJ060JNpLicK4aVjBm--V8d2472yw5kLQ0XgB5SzA6emSoaPYbg9w0_9Xx5TfsLdPU9rqarsEm4N0eFwLrUUDRQ14YH4X0JHSgp0DPAPq0NzWyaxMtpfz4oB6zCSjVDZG-bJylxi3m3zoXsNOm3eDCQ40SfLNC0_N5xgEElLZyPaZQcNENltj2bylHg9NKPDIe-jE9rd_awlymNzEajT-6kDgZHH2XHg155b1qBml0smygR5onGrU-bGanTgFVgmViXoEqg5uk4pRWZTL_M2B7-CBj2HkC9jEAhla2YgKN8Em0afYJN0BAA_VfQhCn97TAEjiqQ7jw7iPYnI0NhAnUcIZyxH0Ekjduw1lIuzx5w585-_Nww4toVIOyfWVyZxXSrXMwq1227k0diSKhWeyqhFWhTpByO-Qy9UI7AX1gpQxP9cdgaHsp8YHZpUZ2kq23zKOntZiPFMVGvRyorv26VRDuxL6dyXKWqpaDy1MsYkklYvOlal0fEn4bFN4CNtGAgRtZZai2M-61blE0uxK2OfRt1gsTpnAMu64tSW10QCtDAtYxvzZiiZ9xrthsT0RxBxfIRQNJAoLcEbNpk5D2X8jhARJlr657XO9mlWBHqUqC9L2uB2kIAv4O9K5c80m0z34PiqOseBZYfRuDAFWMOcrVihv3hnwInyLoN_BVDLVUM6oAsv6NmASsmEiPiDDpqgaojA2ISwEiybWqr-kuZ0n6qY-b3jU34MyMHMjweSFSlDEhORAnIHES4p0EqXML7nfj7i0Ec0Sy5faZ-IJ9A2CDB7kbvkH9-lXmLoCTShZM3wSJ7d6wL-QLw2LnwbxPxqN1oOkz24Aoo10N5pGivTdlpjCc_RwAbFFeDCwNkTUy6HOpeuVcgPvbYhJfUIXpmsdytwrODIiXZXVCMvHG8i24YHVTalo_EZCiBAI4L7_Q5zdXxsJOyof0jss-NsKAQKb0yHqX6dB36AW9RXFZEszYtkH-VpfRar4LBO8x_pBYNdHYtNweVRjAYWkjLjLm-VwfCw9TZTnXpXYoVaW7qQWl2XU4J6vFPcubNMbNfhB88asFHN4VtPAjWdNuX-LFtPA8LAbaWDJCPfPwQpop9aF_Ur0T9Rn_lT4JlyZgodAlOU6NL5kcc_VrCnw_wtbsw6SIXo3y1RoH8ypw9XRmAFE2pLdJBtUzyMduB_WtItW3cU_P5S2zfLk2ouzleBvTm9XFGQ5-1TTh0sYZDysgxwRpgRIDDaJs5Re6tRllXC8ETaDAgChEX-NiLazzWLGjLEGW4NKt1GQxb6h1lYXvvZimZx1sfqmwwpC3bf2dEuolJ1rWdrvOMFlngIxP-_ygecKRqJYpQxw5nvsWwn5xZFaK2ZhTKboULccgF4xOVT_HN9lKeQXqncr3W54PUPpez4NwfXY82FdT5N8tYMxTOGwzJliVEUD_T6YYQ9t2JMQogsQjDFCiRFwOZb17VSTCdLhIL2MFxyATAZsi0q5KelchacfXXRZvmofqiiI1BE3VXwWoGnM6c0vFXdP-16EWUP8wNocXHHgiWSZ24abt1weuRKvMh1h6ds6jKelkVBJfVoFb7RwOkU4yxLoX63Mz-tAEWv7W0PEkmP7GbBRSrnwLg2XuaiC5a5yWs8073REdKtxcxdRh4snJ5altwO7JsSUt-UIF5s-R1kAVeC0ocJ_r7M7gx1sqAmSKJeQ4llJco53fVwqVZHdxT651YeOfS8w8Zf4g1F7Q1PYUCheNjwUkKBab_PUwJqniEt14rKwMVp6VfkFXCMW4keiimAFhXFT3MpmArtxR9-6TyUHSAp-Xtnf42J3UauPmAq2JgK8PRO5I_G-2Y0A0adnxaLpSWNRRwJTIsOgIzCWrZrgDzyHvS54-ZXRsq_AB1f5lZMqcAb3lW4wKeaJQhLcj6vIbd6WWjrSBQQMi8EjHfFrqjhQm7l1iJECKQh4RkcIfR__A4Kfj0FPjnEHAmownO_wpwhWRX3h1Km7Qc5h4n6-VOUd7H0C_G-74mQuunr1YVp1PRCfMXRdF32M68rMAa8E6FCXk-KUgaj3Qn-ZQO2nqTXjNVP-5ZExlL0fPjT20OGuwJj1g6PURMuPbm-2_ZO6qjuMucqVyM8w0iund1Tdwtf3WCLG7xJXgoI1lAAFy9eBKQLkMDejBUMD3-Tt-YMijPW7N8w1ZT8iawWEPoYGIAOCVasZQsdSIkraAkM6bM1ZEKvOEf4ICwpF2L3wu32g-a1UE2Sy_a3nHiU9Rj5PbPJzpC6V8qMh2eqASvv6pjjbQH1xQgpRcsXgDQEL9zjTIarrikzo1RxFHUQFE5lYDiqHQGIktFEEy1e_Y2TxGTrjPHRZ2NKzM6yyqD5RhGTRlwVL-LL94-MjcutoK40C3xe2fym6QDtPK5O8rGhjRA-Tj8NlRocj45bdhBHXA8MRuIg2tcsLNoR3HkLnRnmmufi5CxTrjHAgyMv3hzCDjZ4Dy-KJJDhRUFoK9E7R7CRTfuZJIcJgwvBB1fhlOY4r6FdIrYi-GmJBw4M_qfHCEAbPUS0DtX73qc82qmwYpBn9P9499UhYNc8c9P7x-Dcb5FyJ86lMCGuTF31G1CaiU3Op2cF4tdOzF5jZMFH-8fKf5PahZoYKxTz4_CZeQBmYbN7F7PluQmfvfIbS6_xEyhPD-6NcuPryv23Wom332o137lhU8tkzBKTgDaae9urDy82kK0JbXvsLAQVv0yWn_JbsJZmwYx2f4WeHAPyH1RVWa8__iPxuZWJQ72eQKBko4_PjnOj8veVOHL9R2Hu_TVXRAFiCLABVtO_q8EJ6Y65fSm2dpP3UqBFvKualLTeGeoa1ap3uxZDtuiz_t3O6g2kNktLd5xA-AQETnFkwqF56jjw6C3_CRScXVdb9POejgw5p1fgSx712lvAXsk4xylKncl-3GA9M77L-y8989E72Pvy2RFMJVngJFOnyxJkvLRnfZXfU86jGYOeurrL8-CfvezobqC5H__m6VQ2XLfqkFKKcDK3kQTZadwcDif8Rt0w9rgn3I8t8g-QTPHYGqbPgNh7fbTzwwVKCq95YnhRpygIHTDT1Xuo5CeUQu1dRwYOzsft3CNMVETQnKR1tEsJNwsQPxvMGul5KKkAAOFZMCzO626UWG1ElH6eXSc5raG9Hoc3V0aBYRQXjMmVjrwuz7JG6Xj-lQDKXblJoznVMSG57QFqZMJanHJLYIgMyLtrz-iPhnvC2EuwXcjt-b_94FjSMe5VxAVUK1hjB4VWJZeZUq1ekFEMLqH6F8JJaml1YwDIbraVPu05uzuWdyDLI2erA4ZP5NUeVciYhggZW_Fn5NQyjciSW79ZdjiQvDvxtjLFL99fD9VAFtkjR46CN0TFMz6PJiY9mZEfr-9G1LY8jHDvNWtBwKYT5e-KYfQ0CHbpj6vgrq02ogQoS41sthrAbFiCmjyorQYaktSDX79xz2fslDiJPmg_FqoiE5IrpTIZ9A2ZcO32EnHnoNy99r3YpvQAurZHMtOJf-qpRqAXnc7Wea1Jtha9ct8yRYnAhPCy_3eHK3RUe8kj-2KSjtaVUo_kQ7LHf-oRBUuTid7qqosBfARJQzzbfAjuMMnH29oqrtKwHS7s8f9icvW4dksNXhkh1oHLmzS_GsjpsKw6NHAyJpRLsP5496Rbkka_Kxl-TmRitm6EVI77btXHN0pjcDNlVXqjgCekvnuFYl6lCtz9xviM28pi4Sui0vTiBp7XQtVTTwPP-XRyztoxXgprA-pggWm0DMKvhwTjzskB1s6SdeNARvYxZ1yixp4L7HiT3OaLEze3RNuCrj6ZJLZOcXt2JCIwkseSREJNLHNHyz_JlKYqDn1Zp200JgRcZx3movTR-yUqmr85w6BOQHECBVoEBStYqbXBguB3XWXVGD-_q32xo86qSfFJwywfNUkJSzn9W9Yah56VdfL_wYIO4em3BGn_FUfUKlSr9bMUsSDKkNdHDHMzA9E1EGnD-lh78PLemHWgfNn4Bf3QsEJVFlIPio9aAvLCXSR2xvkXzHjgcYsxuxlvWifYvqtsbwFVvZwXp2dvRfOi1Gyy12fA-zXBEH7I3PYlfO4F2FMJs6GiMYkVmtaMufdl_QyPYoRWClVOuB4GgAvm47SeLhQdpTanhs__KOzGwZVAEXHeBOY2AO3xX_-l_dWjNBbhS89h4KKDZY--j2ExRIXTaCJTXCX0Z34lkMoBmvsz-c_od8ewSKtSptoB5CJjc6h8czpRUrrqwMFuSyxdLZsAz9y_QtXhlasVJ7CuTwfcNM7ODRn-jwARr68-fi2lp9_F3WmusbyybzAzvSYZUMsfxgUVKkUEe0JaHqLBLJORfc9LvbBGT2GmTUInRyWhNkuAipYXSlYF5hzysZc72iPxfq5zLJ01z7zAvqJ8IY5Z3Lh-20kLcAT4J946j8DJz_mLRNZXwoY0WxoT6CJIG62SzwYkVW18A1oKJ4ceq1jvSisFeCmRSxMY21vZw4QIeiSJstxZm_q1jFscd3L4fywG2ZzNva8OTu3_3TEumZtASzghUrr60r725uKZsYLU47yLPZooPnhHnq-ONzOpzUvHaoWBYV3iKJ9IVw9NmOWgw-GJJYQ673Ldl1OGrY6UGy1aEMOs2S9CABBXMehar0npdKaNcuGTKFC9PkbVN8qnV0oYO8rT8PWXE2pAE3zng9uJ_udwEAemg5r9fghLMDNNnNJ4aYtVTI8LC4Xd8vejn1FMgKaOz_Ld-Gs0Q2k2eILi7DS9EbCN3T4mCqfF4b9MTET3FqRSXfty0iUx0dBfZhjnlAOtco-tduKX_fSdXxp_RCwF0xh06zejCyTUP8V99XfvEeCPImWaCFqglgkk7lWNXqxbhWdg77xhs0aNO5VKqCtkKBj9Owahr3ijUHh0LqQI0EFkuHqPfxHQGHcIxDy_VL7i_S1BVRL6kUcmtHjqzi2V_zSxeKqS1XkZVqka4i3qW6-NqEbrG9ggFyB14GNl1zxaGDcbg3BIN7-fPQRTCVYoWS2jlOL3wdNIm0M8544ZX87Ryf7QKRqSbk-tJgVgiCGQKWSniXf2_ybj2bU3wODxAM6Fj7VFAq913NIN_PEb5y-qYeMQFEvUsAaWpyW2KOBIIVwB56TdtvhexK5oIvnfRDfH8fMbTSEzsX_LrBV5ZslM7Zp4FRQT5dVZnlMxnvMBi-I20P9acJxGRUsSTkJwez34F9rSjHsKsuNKaCxSCDAhb-u9EfVGSmtQkDc3luJ4XmbtJfiLf8y0vyzrELEA_SX8rtywtUFfIS5Ztz4r82IYkyfmi74YXTLThAvsAwPvs_-P5ktSNzHlHGLaeOI-v7BpjsOBItVjNM_A_OcJTPhoXbNsGWLfSCZNNtAANSqFSCdlR87V-XwK4cW8tzJ1DyipMpPiFV8JfVeb9mxfqAkCfV8jx4MtidOCNucHTiHeATMKR0XfZt5iYFr8BCC0NaH7Lh-0UV2DvUTD39jjtaEXwPpzX9QsEYcFLLM2yqpkFCF00u-nfYgRUCrObQO1L_QUfWLLPcyD02qnjn8_5ifPGEfRjuln1Wua_m6PjG5R0j4bHU_rOad4f-Mr031A72HyTtnLmkfJsNs7Xv90KBTapxhj0yJlw3IHL3JGfshl5HtESNgOgiUfsi1CHr0JfGO6Tsa30D1N3TvF8FOXRYgoObGBUKoPqZUjcCpJZl0gmI7bJ2IxpMi4lgYoV14vv8U0-I2dqxV7WLmQwdXCXsBpLb04xtNP4YEYjumXwIW-VnVjHSZrLj-dyNnIq8hYKGLCA-t8OtX9wDT7AC9QRCgyMwKGrElodr4VkKTzVVjfk7VTJl5g3rZp3EPHbBrYxm9SbkQ31UXXEvbm5Bo6mspSdgpP9MNKIVpp3d327z03HcF7CHsySZwYKKbvaJxWM1LJj79iRPDJpKFMSv1vxRqDtR0baQ93KA2ddQSttYcQPOMn4-V4zVx35ZSebkFcBBmTINpFfcEPpVKT_Ok4fHdNKpnJ9EyayNtl_hVWdh3c5NZNuxkuaj9RqqlYa3bCBpeEECZRTJk9150VDzUMqZwqX8wrIFiG6jxH6OUvnwC3GOFRTwW9IpfsE1-nsaeuJquTBX3QTiqFLPtw-5O1cHC_jMTDjXv-lR1_YGmNQb1oz6F7W3fv-T62wqiVudUrGKiSLX3EdTNxrBLeGLCvKl-3GSILrePVE6zunlc7mp6AynQENuwz4LdRAJSsVXqPG60NPkw7Q3kgbQukUfMINRQ9KuRgHwNBbBykFkGTIePfuM1jiyhdaPW8oS4JeeQiCkG3xZ9jl6hWjAbPHoN9poF5dKD5REWEiNhMB9jLNlBnu2D2PenuDnuqS0tJ-Itt78RxoZVpqwh8qxI8tGAjNFrnprMToW88YuBtFNNCS1YnjCFq0AwWiC2IGEWUMZ6HuLV-dElwGt1CzIT8Pvv6XgwPGAEd4-EkBSQ5ZdT_asx0LKCFNZgZgjBjf8jrwYN7HHrPeg3CKCp0gLBfiyc3-PF29vY4cdH01Vu7lElHhsHYCUspRZsalCl3NkX4meKqwf3SaEWu4lIGg2-5td0pWXMLOCMbnC8WwLqSngz05uMBnj53hx1ReHFvsOQIvomJc9FuVXIwzUKn0B1CTlS0NmUMEFUOHUMDfhp_ky5saNoqBpqwZZzbBtOyMszAZZDZ4XB2wKm5LmLqSPtLuqueuamqEd9rCRxyFNxvDCjoXhMF9UKPN2Fdoo3YvRLibCX3kGi_r4XCAyLUTU6u9t79Yqhy0s4jwC2-y4GCS_jvC9QkziStx-7yGYhRGuQKo8qBeBYXGeJ3G8t8jYfLoOEPujyi6Q6-09RsnZRbX5WHcOBIzi7bKllO5PxqMYBG6MBfbRsFuHhvKm9LS35gZOB-KGaStLJO5lV1joNCjAUj8pamdXP2idkhLhEydauZRcNyc0dyb--ox4LqIZGUQJS9Pan8oWU-5MNKEGZCy-YrI2wSgf88BvSMoWtxZeGpN0xi7YRNEdiqDbli4lmZ0_oJ7_la4PljAQ2pu2Ga45eqXdmGUo2MXNcsHkC_9QkpnWTnRAzLgxIKEe6D7A2aWA-T7PTQTOgrJfGPJnFXawZ9DjIZr1OaR0ykfGGvC9XcKTqaGalDsAdZcD2WxAPiS9VRWfu7WF7ln7oFWiz15cWV4XFV5jk4uE6ZlTXwcxX0YtI-AQioqy_XvzE7XzKzHTro0cwc7tCh3UyMXmvsec7eDzLgcxeO0S_I2Z4k2_mGTzVVYdk4nr7tLVLEV4DXfB5VWHgqtMfoGSzTr4PN5sM5K1bHWJlWo1VoqPiX_Ld8UYMCwWfWyzmc6ujt6PlYILewAlOu1cxsiONFP0GpMv90zyDdvdWxv3ftJf_Am503bSbqKxeo5Ab7io9e2hwHFO1LJzKmhUBxRQhCTbI1jC-kVHeDpVQzbZ7JF98Q24qjCUjjG6_ipS95h2tL40A8MUjCSNPZmmrv5B_1DHfARzoqq05laqfGh62WVw_ZKLNRbTRUqX6pGbNg7CjKljVKDb4hb0uckt1LICWGJyzfIWTfdD8CC7K5pF9YPEet186w8AWJoFgwzzCm-acCKkFJ--zKhjS2b6Kh2WpN4a5d2_AXi6cIOWpRUYYAFTqrrvPFq7id4AvfG1ohR_AcSb7vq3-tg-UPLTkm4unS8ipaa1pPEWgv0EZ3SwPKQFDH4wmCp3yhJR9zOztwQ5SzHzsSJRfHHSnGUTFzg-DfKEMbK4eKpoWSk71i37iYgsu4kUPwmSQl6L9I_8Q5Cj-9hIi2FOVMKOS1JeyJYia-gVbdiGqIYC4FIA-T61WJAK7MiZ0DY8V9lUmXY_Nesd9G7pPOyXeKePz4BVh_Nwz-TrIdTwja7MpNigaY0Tu3IJdaZsCi8qRIqP__p2gyIF934u0DV3Q6V3zi37BBar1cKQmfHpUxnSo-OVlmsuL-jUKWp5PAezvq88tpH4gkE0Igsv3VhVzIORq0dqblq8bvVlI5vk6m6Z8rEPqM4o8v1vwVpgidCfL1TQZSmZOz45hls4SbOlC5dYnqlu9-E_evGBx-xbzx5cjgoxc0xPlV1Hhh8E4I_CfUIm-4H9Sq0M0oFpNwGCMJGhKB0E9RzHtMCQhRUeDd2krGbaKhGV72ZCsVbZN4j6eT_qrW44HLyaTtG13pcZekStVRVOHSZbvy7BGj0Ityh0v-2W0wtKkELDLMpE4xMj7Z9GoWadt3GP0V8rJTRLnZlxC2P44LqaJQEpmBvGgtUfDgmIDI-XcHoARtls-LrmydoH5PatCrviXlG6bzG-z_XP3QrtH6Q1If2v5XKbOXXgiyUlep8JMTSpqKsXYUS12lUdc7l5RQoyVkFwVCjashjGBELyevRxXIcsFlxzLp8J5PTaRZmqQGicB0lHFPEuCHRgSh439ju0_xs24ckmHwfMxzYNyr-kIcjJTVVh7w2K2JhFKJ_dF0gM7ez0xWDfOjQkQ0f1NbB277KYGhbWMu8YJZnPFQ1LN4dGAqLePt-7XpnND7n2LbBkWEyrAl2fd9OaTSP7Pdrgq90r13gA3XBE8Xfns2-AXYcjY2wGe3_U4W6qiYNaeRb5TEC97e0QRjsy1PudD2zk6_KqNklbtO5QqLVadzoHr8X2tlnhBcZ1MPK_s7CN_qOO59nzV7wBbKXDbTUUz47u8dJgVNvHXQXV29BqRVoV7GVZ0bgnv5PezB5MyHkIWNeNKXFfL6av1QaZUFnSKM1D-RthFIaB3kOVCbIaf-HT-eb67fitJAtMjMJ3ZpME1EGVuvaydjNadQPZHRz3lihdTqCXv17uryGP-U67f2HGSz2qRqE1smNbiMYJ4qXrKmL7CDSW4NTceHSz_d1yublx2v-W_nkzDt3dOl30X3NX0xQ_fEsPm8zi9rIiErTzadQ0j80ZjumHCDADaijRfFHyafJgHpEKZfsawS2ubQ-D2U056Je-p90NFkOj0Usx6bg74dzCS85FlDQ4cgwvSrGqI4yVbL-7r6q7hSzP8fnOIpEeuIF-gnMLyeVe49aU2jOEfrqRTP7VEoduyUE-bmJ6QHSN1JYfg3cFRpdpTMzAFTaWxrUKgceahmUaL9r7dMxcMnlgH8vwYZGHsHAvYmNDwZDbfXNvwHJv9xGQEmpJTXwHmBQ7dlV7Hh3-rChPElDxvKWiPs_OZVw1Of94XwrHWIPNGcjhnJoVGYU-f93PKSza0trXtnUbgwY9_i4kzS85OkIsSFOkpwx8I1bwRi33TFvAB0Qd8-lWwC_Fvc2vbEIU_ve7oQiiAe9ON_wVXvK2ceK2Sz5BKF3rIs028kfnqE7ojAowjKs-IrVGzeJJVGF0jhYD3X2YJhIXoLiAoNguDwNPn3lkEKRJ66ob--ZM-J2i8Li6xr_b4jwcre3fuFy7hSJizAxkye3oVipsC86ByVKoR-wgxL1f6opw1sFKRzCsmK9Vq-YJE4IpkN5lIfQosINt3ovGueoj8nkH9gwvmRP87aFrapwasTtnFye-YSACB9n-FEMw5ou-SYNYRP9if4jNh_bjMUtGBNpGmGRPsk1EjygXsOm7M3l_RfXkkwiKZbC9Uz8itibUF16hTGtnHbhcv_h6FySIOJ5VREYKUzbLw94VQpDZoZMFK5xQ6WYCnM_kGQHt77-0TmOePVODs3NU345kx5PNSUlA-xRaUmyOgHGMDt90YvSld1mOSHDYjEugJKqtxYiK-HHPhUgad4P5gb8nD7UO6Dj0BA4AIqMnz10_L8okFuFIpkRYzyzOvIAkQUwDq0QNRFdFbw9QXkDS14UdYPr0cSYX7_N6DbrZfqP9gcwrKvFf-UWUAStX0fUJcQHWObhwdrWuGjbs0U7-Vc2oljV6dgXMMRKhAnXNn3Ftr_cdWoy-aGRv6XZv_QwtPXXkuFYZhFgG_X_rhIm-uqYfVxx9PxIQi6FSGmsNFw4XNlyQlZAUI5EKSxMWTV9G4pV_jpvLdi8OCNs2H22Mo8338WR92uBykzTJ0sGPlSt2Eyd8xXtZtG6Tk72o5RgPPKBkeA8Qtv2Lj6UWP-yBk1jDUikk3e7Ow3yY5SsAppdIM8sHe0edxZ81bqvmt-_TpBe5KDADlqsHFD9NEwxmaSFY7hr92TdeT0l3frQNe-ZT_VI8Xno5DlIIDIlFw9y_NxHfLWPevKSQ-TbUgV3aZpULWqMwCTRHJqCRREGL-b0oQQ7ar64WhGeqXUWQdfj67heoKDCc7b687eh4kmpzrxAnobfaaXrYCPKBpqbCXM54MNEjKDoV1qIs_z4rjft0aUSMNNzUhfBlVrRHao6fgDr7hh5d1gkhex4YdIm8JCAtThB9JOtPpIsqiYMS37t-gJ5cj8D2PnzU14kzQOqvF6fmCBxqogxbe-0F9qWdmpoqBD4MgPomcgXEjt9x1j7sNQdAQSk0L76jM6kWhrfvDfgWPgsqcJCh8-50y5HccCX-rfWqAJBUvNeBSWL1xwQ8jDH7LOLuYshn2w8N29JAWIOIllW3og2cMFGpdjhFsAMR8F3s6S4awvM2F-HgsvC7JQ-zDPz3Y3fN3er6fekz4Z6HW6ysaW4J5piAELqg8HS6yR0IJbt34q6NPJN0l4PV4beOIgcQmPXp6kJzk3AeywSimaCaeaRjMgq6KkFtBQk18RPxYed4rz6-cri2twJyQpH9c-2XFA5aKoNlKCAVwKS7Hj8xekKhDVNsvDv-_URQZEBlg6MTLhUANB-LxqAFbE1zhb0AVqfNi9HSCYaJM-zjA51tIxKM4iSxoc8pZynCkcvxsGs3K4W6pGCtfXFvb1B__V0HdtNh_D-D6Cu21F5_kzkrOd-DdwFsHxJa5huy4DhiRpzcBW57I2UPOML1Xn2BIxxCwX1VOMJH8Q20pdbOGjhBQVyF07BKLkF-iX0ONjSGuTYSVS33WNUQHchPlRRpicNc7H5VgypIQnxAWpNIu8j9-whRCkF7g6MwfGBz4BTjEezRAVhPV3W8drr1SUDCWtKwh7R4gxj7oGLrMyS2yWlM6zirawLOuE2KbgK5erKNf0EO57VqVLNaSJ-8D8OuZlh_c3-FiQUw-D-1BnOivnOqtA-qf4tSoNjgL_UTLd22hm5BT0aAxyFq0goCwazsVSmu5EGlQaO02tZpKvO3NG5j_wEg5NMqUBDyXXdAiuqeA_fqDowu8vxGqzV2Dbr_Ou__mW0RAsHu7QJM7ZnlC19zXa2OBaYOg_rKJPIJar3vaCQs02m813YKCbpiKGUDnxUr73-YuOS0k1h2L_dktD4vqds56NW29hMZRjaFu42squQ0WW2lcJuJhByVl0F5FivZop-d_upmHlD6sTnYTdkUUAiYYFeS6eB2jPY1q18UmGYho7SzqJ6QiUQheOFbZ306-sm8hK6NWiC2bo0YjG_KMvEgpFKYuMzqPmUVLSsf0hpcgheqsd5Pot-gUj80EZMTRAvWpx7Kdmj_fXPugcYbEGXfoEZroYZsb8ZrUmIuTtwd94KGyGhBYEWlrcJ33JBdqCh3Gyt-m8EvXbGRQfRkrywcKnc2Ytm_Jxq39esKjmJKRQEEn36ouNRp68xwtrdD_IOenOFOf4Si12En3wemQax7jG9DPHw8doEf12muF3_N2X4U5qi2hubBSYRkRBaDlWInTAl8vV7SH4NIchzWURaTSuxl2Zz56-n1cV9KGbRmh7iSfwVyc4YDRUkPB30kDJJBbiN6bq8vsSTCgEWsy8ZT0rAheEXmjQ-y1wYS0JRlvEd6g7BbYLjxwfwnGJiUv9C62WJmyqs0QH_ohxbpd9IXvq-Cuw0X0CQWBu2c5uUZ5elGQvvG4pxD90gFrMO6wlYbnv3YIuzcW5_1HFisSov6UHZUiXIBz7KH2BLgiKS2QS4ues8uiHnXfpBiWwyoVweJ3oISvP4ZsRTFSh36IeWe7NR6mO302SIxcuM-Q_x2dkAZ-dmFeKyr3gp5qKYGw_7NNpKuCmBsdz7vgAWFRdyr5aghWgm8d4zx1TS5V5M_VtYCQLg9158cjPuXOJEuZrRQjVHVdXcjfpzlNmUFVvOH9_ohZKGD1ukJ80wtbbAHs3xIVX775DrgdwRYVOAS1EcNCRy3uBBBEyMaDWfvKdaiBMOyJQJ9SJv8OYewgg44VBEoelczqjY8Sgap9h9Pk8mSvCsQjIYiYGOh4vBQ0R2CZckUGmy3z81pTISNmFn0ojpy1pS1MWTKsdkWPEHiDjwIyBW56f695syyIodKK6DNrinGNA_JHKT15P4GML7zWcJoIDFebWjR71OW26RQ-dIciLH-vC6Cuh3xSOV7UFvzxHl7uGIF7ffqjz6TvjDT9Y49G7n1zbvXmgMKTOSGn4z5je6q3Pif0Nm1EdmhT6wP669eTP29XWNHA8ihBRpWjHSExqgMlOVALaPinNnqAvMiGe8RIEdYXQQ8IV3_3cvqDLdj2TXLJwkQABKciXYATAES7GWtepPOPHNV171Xrh531nyQiJSTIRwC1TyJJ8SredPPqRQ76NWSuELEEj5OHIMjrAa7xVYXRGzZ3vDFNta5FzL7j0pmya1vDynM5cXmaZ4Nm2GrhfotXc19DYamqRufWxs1kNTZqHKpX594LO5pQEgHtss_EGuzpVaBk-iDvHVPPVsp97tpIeJupd4D2ahvNaBK6kPXqd0DXk48Ka-qM3yW1rJCwJtEsChGh-A8hEO75NriGhNbHSvsTdF9BVcb6ORNCFOVbttvfqBkCzrqSJPQk9wPP0Cg2FAuw33Zze9JG2EEnwGQTXqEw4KN94kxCiXZW4BXESMtisczEDiF6oDW9TeN9YZeyGFLK3bIb8k2LBC7reeGc2tMDdviBAIU6ZhU0EFeIxy6CqDwzCJdzgd59MMYf8K4YItT4-YkrXxp0MTCb_eLgoge3yzD48gFMU2Z7kWZ6jgDtWemtyR5r0piSfmGWOlDTXDKZW4NWLiFyBj2wHmQiBQQLfop_UAMO22ZlBGF3U31hnAOF8r_jELNS3C3SQaVh1MvoksqyCcDDN4mjLPnVcunGeKILcCgxfaa42w1hmk-Cgp4JKCF3z4efkSNVJorB4Jgf01FNguIBkK3e9C72WejX46_ENFw9tPtmqFUqhpPODNIrzN66v38P6YnBhCkd-BiPQd-CNZmCb1CVgbYoe7NlzeehQ_3i3ctImD45LHb-iRUpiUw4qSV50mhDorLmMjwWscPGgsTafNa-oW8pu0Y-VTvU02AHWMWCh2jBVzbs5ZJlt1o2yHsU3lVT5wTZOXRepFbBBrqlTAYoCHvvEem5dUiNrzWhTYJyC4CwBwz8nZTcZb-SJSxWyBrcxikyTDoy50wt4O3JUmQLgQVe1ZMTcUkCLtETRNJAvgeX1JFHnS4Jq-jHeRx3UiabF4kcF-kxUs0Rm_H3xej0bqg6N5aDDFniVnuCmIAdEhrraZy6oRU8g7hjNBhGEn0HTCw55qfEqJgEORx8ZynsJkLYf-sk6Fhp19eV4NJCZDs6vrzH7EOHByX66futP5nRqQEcdjRZyyOMTyMMbzESORNT6iIQowQqX-w3hzkfu0jWBZED67yvFx-Q6-2ZmcdbiUQDtEirexpykG-FCQegHC9rN46mnFYl5ujRMdSnz8uP3N9hdmSQGnkYYZfp9NoSgwoT1WPkxErW5m1WMghwSeclI88CzrJnRtHO4fJLxCP1YyeYTkkWHT9r9ctnrU62OaPY3XRqKJgoqi8_uAEqMneWSX8b3P1u54r7pH5RdOOHMKARBNAK5jgoq-fgq8r-fQcQwH3r8eIuz1RqM9xscYq08if0qUjb-RCNTWp7-VPiKw3A1FeVvivBEmXvxvoqnrDUnzqeMOU8VRXThGsyOec60oaLk_7bBl6E5d84Lk1AsA9yrwexmtR8KvBzOQbz4sGDDHUmEv1_Ty3twfs4h63OOpvDYamUnas3FRbcBmWL3pOh7qwFzi2Q_k0dpFjiwQPhg7xtetal7mNixYVMPw6veBtOFxVjB24tfi7ijhefxx1G9DR-33_gNpIbe1p9bDgsnN1WRMhjcPjk5BSHdQcuFk-84LXiuflHDQ03uwk3rPEKIa-g3wK2-PBHhWUvOInpAOd2PtRnxFoxhDtcoNRH-9fPHv9bRvX5wG7qYy34ogItg2cUdTWazwDpiBhvQina-_qDBBxBK1gVlsztyTxf4e21QoybI9hV54kVX7k94aovzG2zWeKHd5dZNX1HensboVjrZbJ6-gzbIvmuAvdBTZD8gbr8Lj4BFK1lssG2WnYc6eM573AiM06pPKaDz4W-0CJ7LBQbkYhnfepLHtqMket9fbTPfAQtYUSqLraibif3rzYYzoma9d5wp-QWSdlmNAtImqhWF-JCRQes-xybl3fMvVUjxG_5XYCCtDmzDYZJ9cOUJOiqzBONnhVPgg-ie5OpG2CsY9PHcsSI2qj19MhYDbvqTnmgngwt_FDwG4Y79unzOjA7xh0tvqRG2g8SOu_XUkxlhsRUo7XhxY1CRl6Nk4K6bgnypDr-_Ci4TcF62iPLG8B1mPcS3o2F7vrdPuT4YsW4XkCasUi2PQWZFtcmFiWCe4dYnawZfku4NvuyYWYnEQTfVGANyaoXJkesrDY1fPdkZkEg1MQ9bjDMu_L9sAVK6-34rbbY1RFFt4C1CDlK1X9gDIUUvksK9vnnvveuhC534n7a_uZ6FoYXLTCNcT6moSYe6yI-buM-Mw2sVBXNuVFtkCAEzkA1JcfSEKv3fANPgzrurfFHmAnoWpz5nYjrFGTWM84-_0RzQmy4psS4zXdooKhNvh40n2SqN8-2X_AXc4fekK2vkEPFKMOzoaKRU_EOdQY5D0JJhaPi2pjsrWPjnbPDl_DFW7SCjk3pGxaGPo5D8s2GMYikIS8rXAaTqcRK4pOopo2mNivksc11CoXpZ9NyQnA5ghb6ixC1Y_ffuX7MNVoJB1maWN3Paxppgk4uoKS40AgN_4fmUAGZ7TZZSbWuR2EYdqc8mpipY3GZTsuyvybVbZencW4VnERAB2gMyVT2Bm7oeGwmNkkBPwS520JSa6U4AlgpJehvnM0Mp8JmemkbV8Dvl4HQAHzLaBRjiMsRtaL0kWIGKt0XgQqmLEwxkny8PrI_UKP6dtq9XbQOeuePBK2JLUNPxnFc-_43uUDvf0ipXC_ymvOhIgaQ-TsgVDxmqnRm5vZBy3dsdsIJBg-RpvChYgouD3U6QtJSDtE6Zzk3HdXS7ElJBJTdZ9pWwIvcskFgR17DI4omtOr7YZ4AXFr-ruiK-PJNEH1gIXSofHNIocaLRhPiEJuXaa9UjC9FJCHm-JTgh03VYHVOFekJkxDdH4Q-5e8wxFM2GtJEPyPlIpqTFv-AcK8pf5eszHG5uN20kvl6qIlCeSPnhy6RkL__rEczaGx_71BrA7wARvr3zfMAs6hsnxTx3AuJWFgoSVOVSaDQQ4Qb5XcZ36-VFjSYpflStfu2JFnsvXp9Zz_DsqGEXToi9M7evmZ05SoL33iTmi0oaklVJZVUZpqfc8IPa9A42Prg_p3qCvy_50qMPhS_vtUWPNLrdVbXhABCKFuYC06VuvvS7UCDESHzVeoj-rPXje4AUngBvSzGBMZXexCRT_nw2Q4WJ2Kr3jJcPAO278jYuCGaiv9s30fZD0Z7KvsWegl_kJSeMHfUFjIxZmq-kAyC1sD7Vdxb_AFYMvpRLj6TuMCN4e0acSptBWxU2Cf6ViQVG2QtDZlbLx3tjP5k4sr208nBh7zFP_R9xBNQtiFoTNBQCPhqZ6r2BWH-tZe_UgVnBzmoqjd2Dc57NmlC3-8W_gHtiJi_bnFUSl9kVmTuzDOEM_Zi8Ov1XPAGMQuZjQfkKdhHYUHzAd25IP4VifeKRDu3W-KnrlaSpKCb2PIiul1W0Pool8ESDgURUAOEJZ-pJTXyjxyn-U8OBM3TNPMhwjEmP6QhX4OjDFpsxUMKvFpcDkpXhOHzuJqXaVhOATAVyKo7zHHBmUa_FBg0Ahui0I9MrQ4OxoRMXkA7uz4KEBHjsqk09nS0vVwn1xol57nLCIHgYCxZW-gMagd-OqNWbHPMM8z2JLvGvG5JlcX61WE0-_nh7B9ubeWc5Q7vYu-DFU0fsabXDgc_ICSdXLoOblgPyXEcQXRuPeexhc3xEFKfK6-FbCewtDn20L9WxM_9O_maKyVBvZZK8g2lXsDDET_l3JDkjd4WpTZfdLojsjSgQvZUB-O6z3oGT7ydFfzuD2E6a4iipN7wJmRGeU_fiN3uGq6Zpq6sVqqFNZURLdHhd7NuTaSGU7MjVpXUoCrIF2HKQfUxjMnkPhqC06QI3rHpJlXqzET-FDCb1YK9qSKRoqmt6T40SPns2_IAl9cvwY7Kl4g_D8NzH3SVzKHaoDr_kxHnyyb0x1VMn-xmqF6f6X5aeHv5ZZGI5GuaVSL-Lqm1tGxzwwVWvi-AnAV1MyjKLlBBFJc-xkpcHXPzCqTM_xUN5cZHXb3fbdwzsCLCevR58nIMfbcMU-QlxALNapkbQnFQKQyB6AWu7l9AoStweijhQgXDGwVzGpTUZNKHIt3rn_1SZyS8CsCc22xKcLCMry1WdB6if33V5E4KV9zujVmSDMM5WLw2dVqTtk6RhLED_RmkawOMszxfoJ9MDNVNF57VTdybeULB0TtvnzlhgdZs7w9fBtxzNIPmhLXghw-q8K7BQf_8IJ9hgs-qt1Vxt3uXy21bB5FGB5-ktjvCOT78kFuLxiPeF6XCnneuUq7tA2m931w8olmb7TrDm7HH2r_IrID87smA-SXzSJtUNjvHughyCr69dEVq3PQNYPZkF87bGEf-oeNRngQXOCNpMad9a0wrZGNIaPbZB_GW2CpyXU2BbqCUBlf0gh3OZVxsfzhg_txXlU4tc7HBNyFT3w--lfIM8Gu8aqgYPAeuKvg3X18_MwqFlfjo4K5TIAYeQErsnDLO4hYW8WQKXfVjyb7RikEKyJbiXnpy_ur9kukiwTXskIyq7chXlimDCbAtLTYMWAAgyW8NxNO_dBS1mFdijMTejQk8vb_7nmqfyjTo_0NVPP2-Np5BGckFKmjBfYRgYz0oslZWTEKfIM5iROLksLQgp7DQ3Uk1Oeb5ai_rp1s9DpTXjoocoSNg1HmPbVJOsPqvyyqp0HdihPe5AX9aUnQWDbVAs6Zcn_lAT_WPLEm6I0uYR3fNIIGIokSTwNlIK7jLzYfnluyV4KP81mrLBVjSXNj5klp6QTKyV4AMypvtBgqqo9zs3eum-IQu8GNg5r7lZExWY316wUM5BhuMAi6tFywCaDt9d_KpzSunhyJ9hh86AYptndxbfKumUph_uJa-p7Tk1E7MlPwSoM2ojbZyMjjKE2Nn2Skb33Q1dghOfu0higJMsZAmSY5xGJLqS39gjR35U7MywtadIPGugTRhDvFOpsutZBFENTRs_yFMXP_A8MgHZC17Oxo5JpKScA0Lje4vqKLoFuWyw1yfL4qiHdpYe64FRir1zANhrmJaHAE2Nj9_cX1DiEFD9ceVMHN23SiYre-Tvs4HzBipX5ZpPu05oGb1BTUOOkWizQkkGwCn31PLV8a70b1nQMybW1QAgHcBhovPKrtMRHgnMo4g7lwPdqIXb3xmFiEn8TcEicnotoSnNFoaFzxwX_MHsmy7ZXPZZVn2IKApa8vll5pMt69VlAznDfjP_4AYKkKteyb1wxvk8wSAbGyUO3M0mv0FbovUQTEqzLYKlNEEPU2gKRHHgk61wmbuPJybBauxSEh_81FhkH9XRIQAPFcUls2ITVsOmJ3OQyy8-AqA-J01F-22VxLt8PUlNoXmtAWrF9Cn8wb7mkvr-18IEH9uuDbdF6wCVdtHLFblKgYY5wOA9Vd6nw2wl0t9mLWwzsaBPolEFQM0RkJC58X49cSa979QXaXKnXQDIEoKuofbxoAo2RnbL67q-eU2D6Viiw5gyb03vJCFKD0F_rt11uHmDB2aaJiu14NsZPbXFgABTxr53TXSh-041YVSv8nn6BlkwDcZ0dnKno0k0QKth_S6NqlompIClzfjpqUtTpXBcRRPZujOx4od3E2Dm05Fzk7FrjwsbbJo0UThx0jvJaPbtYTKFtZsSwDzjnMr0a-xLdXbCmYeETf0ujvyR_MtOyN8g75AT9SA4DP7TfwE6eZa04hMQ9919ufvsh7WGOXr1wQv4SzBz-ZedCrqgUtlCopzZljef4N4WAT3R1sR8_kmrlBtolKifGZ36S1s2BznUPb8Z7mZDYjbuXwY0wzAPiqb7Cs7jiovYmgteuj2LjDWKXf7r1gvYPxzgdv82LLTjrz4Jwp0EpNAlzqTrMfPtVAo_Xw9KkSna3wmqlLmg9izJymyMgH_1tTXiuv6sYh465tM-T6GyTQVvrsDsStYcKnM7HZCKqvdFYLt7ME2OlFogR_r7yJt4buQFVWfXQ1uF6MgbyC_psBZcQF62l_rxmjVl1NOqhVPS8N8vE3daZwB6GyBF4H2VFAb6OMfQ5f7K05eSZmxSL7HZ7UM-YKDEuXtrlH9j-67IkuQitUglY-zUJhzcSFyXvCRW_VhT1ij9B926hm1t1RCraFdoDQKJ-dhTFLJ_kqhq2AW3Lks9875CZ6mM6D61qTyUkYEg7eLFi4HKkyNYwPHzbEIvP1e2bkIYb3Fmwb9GaoCcaqXxKv_p1gVOI6MaMV-Hw26EeImUlA5Bpj__i3Yy4hHLNme-FOTHub3WQx0UPGouq6ulS-zknQu64zKFLwFZ2EMb8p7q7HOR9Cql6d41NpzAQRzf2MoxZo1_i8-ufR9t2Y2H3mqq29ygly_Qt7BgqjwDcWb41TvDp7zo_yesLYdy0yxR4mFKfDNlqMi2XQlUUlpG84fofLSRvE4-dEPwI658nPKXsaqC8rkyhlmZjeQSx-daNC3zbdmIM14KTIOJjPSXpGJpg-TW3iUwQsmOWjMdyzQgT2uK7l0f_n7g4yG67OShBtg-ZNRf1tMWTZzjpjrBvHCvCdM2EuzgTWadOjpPuP8wvJgBpoB8JO6jrRjy3uQVjXnjfCayF8qMavp7xhMVqjItNd53fkCf4IZqyJ4I1L2Y74P7w9iny7uUa5etizxFJypL6eh46icYoAH8lknc1ZHFeTauyKaZEzkezukmvZ-b3VLC-xODXyTtnbfxtgENvO81a-fYpE2YU0Qr62VraxZS_f7iGgu1RwT7Y9MzeDX3eDIdROgt5IETJGQDJDZdF_xbHGe93RElHm2c5Zv5Hr0PmocmACGshFtSfMouxYhZ_AKNbVMzx-QpwFZXR6MSI18aDHV4y4RIOR1QemPC6hO4Dx8G_eWpF9SCUZJSYfAphQL0f0W1lQ2O5JILrsI3LCXP4U92GjH6g7KDEh1mm6JUSaVdRYPiOstHiqBiBOf2RAL6xdoROOMQow0cCl8wNb7PqUdZmLkT8Q2cKs5Jxvy68-DyP3lahQmxi61mt7ibJRA47bQPQvWuMy5t7g70dewJM-tVK3yXfT_ZzBTF6cvtOcG-CKw6Lm_WbLcBTsphjGeSQrNM3u4e0Tlf4F4fjMRrmM3keLUHZPsAczgUNPtEt-VSsHWXA6pvZoBuApT_MxD3ZXO_BprbniMRhbShFhJOhSNJzj8RU60J0klI4Ri28GRc1o5G4lq3J_PIGBaG47m3-HhUuuEv61sYqlrwfSEd7VeWwmAfzV4aMz_h-kw1Hns-LJZ7q2giGm9mgrIpr0ZA15SPrHSH56q9kkFbvkRKUbtZ18av9eg1D0C3_pacs1ykgcXMv-IuzwfOjaCsNOBFDXv2niT6nvUTKky5mvMPPnOCqeY7jdRboxoSm-UeE5gi3RXGC3IzZmk2bX6BPqKA-otwnfli-qXLOnbXhKeEwK6TF9Ack9Hz73zzzMNxVdo_Wi2Na6ZSajutB0Vf1RSMFRPnfCriU9h4hi1jFOJpJahCb73tcYOa0E2n6WoXCT0ppXq7BblPD3l4UK8DV6fSO21a8xLf_s_EluykTV_CTxd6T7tmtr1hoPljCTG_6zlyKE5YXonCdlCjEBtrK0a_0Q025sZIjvxXvrQxoU35wBTNXxsHqJhSl-CnCHzIgTdZriSWIPxDPpaU45JUOSGf4N7gfARKUvHt6QecqXnUIrXe5x2WpDAlG6p_enCyMAG3jcw3U57pxYZYcouFg1z7WQh66837c3mDTUz50kWHruzL-bHsRHtIOXtvUUoncAeCEqBoElJGYQKlNtGC0mzdk08drmtjooBQ-vY2PmxbTyMnqnfIw1kj8ItpNTBKFTHgFswDVV27TWVKKsQc81jYijmQGFC5R4qWuXaD2TeMAiUDwsqBfTs9ozrcGIC-2sIUprrrEsRO8lAwEP5znk0PpWtJWrXfQ6uE0CaOFFh6t2DU50vIrrk0aS1CxK7atMlDw_M_kDJ-Tdj3LmXEpBdyuOWRKX_DboG4F57nFDXhBv6R5GHFsd6Li1KVN4wzhZKMcXv3fl7fMup1iIsrkqeLTeIu5NQJ0bjebNBDfygjfcsGbcIXcEWf2jFKCcYwA5rkFd6n0KU29VveKIL7c6z8HQEGbPXfdVlg6si5Wxz8MiZ2aF_wC7KCCo8qhYf-Y3KjVu0emOxwoaCdZfv9o21hC6b6i_nrOHBETwlfuLztlBGM0UdsQu1jCug1jJT4UCHlFy50Z2n4mZ_5LqeqSwNc49VQPnbslw_-mV1HTmwbkyNBLIiLhhRtGNsnUg9pa1LgERFFSNGMUrlhBzK3JU-UiXkTmr6Gsx1LpAQAwmyO5E--dmlc0xzWEq9zhHUlAsAjhsNWwInfVuW8JD3UroTiIq6Tx8HBAZWBE915uWwCfwt9z1GrDmrGbI2FTaBABi2d9Cw3u8-ANQxvkKROALIQ0GbcXwyLqOB3R5vAGr4airdg38W83F78927_3aJAIpUzuooFMPremPknrLchfvwOP7NE8BsF3wUlxsX41GTm4i_H7z3D3YsuoAuCoNIG5mZFhVvucyb0tJR_melPJXfQ-2RPBiqTucuTv9KkA01Tb7tspyK-5iplNhju6OTbHddLZWh_gwW3NXkD-NHEA7yKp8QoM5IJmQBCCsHPn1ebZQv3Qv-eHHyCgkwf4p-aa4XZ0ttN3RZWuxJaKAbnQ04dwN7ARxnFhMskiGFDLiRWK-SOcr8FMo1YmJvfQ06-ylcUn-d6Q37DlUXnKliB0H6TQndlJe7gbSdDb9cXZXyyAKv0gUDYIche-7e2o2nYN3EeoNnD-VnJO7ZF_1lsG9hCvoFmdNGXi8W_wygv7ElLkUtjlO59G5IeqeChrZMbNtA_mbm6x2F6PZHPqzTvACiXd3r4EAsj_L6Zy6DiS7BZHbAH9LIBRUT9ynZSa2XDTGIL6lrAiegMB656lZ2OnBw5_yp6kQAde5UzAnmZuL12JvzHgyAfnaXF7QyjfciLiEkSPKgF9LXRW_KMFNUvXGb5idypVBA0pid3o878VTjPIB5B3qqD31rq46UeC3xalUCtXJO-s-MlA1pc1AZfckcPDmGpC6GG74aMMTJk0ZILbmO8MganPEzLiKCJhjisdonCgSzEoLTLWXNtFT7xDn4pJ3Jvkg4bI1K3wqeyJceqvS8oPdXLb5EO3j-xJTW4QKvLzeZmkVDgZjmFw6JG1ZoX1wsJ4kosyIgVw4Xbllhwqofjmtoo0m8WkHyRsdg0UX63fwdOoy1-Ef-encOWZXTPXlliL9Oaj5gY2ooiBJD21qlQnvhhbhhfIB3bO-UDvIBgmSpCLbANQ9jK-pbnbC1gvcvmc9Ygoa3_BkgnjcMKNUs8ZZzAR0zVBoG3e27sbb7yE4W0lN8Ni9kO9rqse83On5YZiIrv-8cm0dyObI40FYjX7y7vh0XgygJymHWukBOpgizpchhJ7XbvVXJ3HtQBDsHYuF-SG90te8IEXUUPPOk5SgX4VYHh-XPq0AwAnhTMZUxF-CcKyi9w1pJkJPhx-OeNXyBisjem5dlfpmdyymeSoT0NYkWjjpVF42sKQ44C_zS2Nt-sG_pzQLjT3x0zeL-31NvT2db3dBEgXP2WS9AbobGo-Us1ZcKB1r_gwV0zLBW-2tUrOnTW7RgybD8-OBTLGyJ592MRuNLK1v5Ys2OfOov-roXTswv_QLQcNW_JSXgdeVfPEq70jVLMrngoQjoNs62kkAiZs24DdoXiHD-HNJSM4FVj6ZAStb3rOMNfgVj77FJ8iWeAowCom5sxEcZ5AtAMuXcEPPZCLst6Ovi2CYZ9NVzRPVZ-vOh63G3xafgcDYrLJkmiBvoJPf9WRNdAstjWzQO0xakBh4mt3QQLPdPJRUyxanV0zbLLWrDqIADf0PR8TdWuWDFPEmroDVvQfikGrudhiixba_T7m5I2sAZQc0AN97qZAfNiTdxjCtAQcVsyFDWnCf_cIz_g-B5hgY4kIV3U7OFpZ24-Lo2Et0h66kvvDYZbuLq8BsVVICRRm1Y_AlXV8j1fWtbW2IE8w2Ct_UmP5qD3ynaRzPmg7tLMI1rZ1jsz4hmFZ1n3n_QO9H_poQKF9jaL2EfvvTB1DeX-NYEBX87_zLtKfTk0Hnr9A1hZsHUm546QZqrSQN8qgaDi6KQi64RJI7OZHFjo-xmP7OVn8b7B41Rm4ViZcwYEnJqXo0TLvn12vOYSfNNgrYghknIcjxxRdPZhi-gHN0q9-48m4XkPbIa0WitEfHgqU8Ew5DVC1LsYL0_8V2R7y4-LvHqJ12YJfXNk6xgAD6vIRguSnvxt9Wg-a0LO33GT08goN47sbK-Ki3e5KdUFvyKjeEXP97r74mkHL1KPicWbHjAcqQ9-TpUt7mENVbhV7L4IBNGW_-ZwoCmxfK8_f8BdAm-yFomMoGIvk5RhI0v9noq6O-kdODaXK3JHOn_Ad8CXkeyhAQs5-RcBS1WQCtz2h03j7VZa0ID1CkV8ah6RWnXkB6dFInJgU1s116CEAqJYCl43igSRkzIVZ7uPbEoYJgbaDRZV4p9O-w-qczQF7VO3gyaJfLPnj_KVpFUgZwlp18ox2qWAKyMnmdTvz5oKVYUbZuZfMOvFw_7Z2D8sDmxnNHk7wtKjEn5FLwtxSQnVCWWY9rhATvRM-A7AkozOfDRaWQ6nePrZv3tGH6dz2L5VZL7_yOdsAM6d_ARyhxqYSPa78h4vaXsmBAZ9PyKRBZPsi4ZPsqGR5KDk4udHq_VpLK4zFv_oaUWL2ZCppH5_s_wvA69i-Rh9eAGO0ZjBp1aelYzOXZuyrdU8tVhugMetPo-iczUdA29-bSS9KqZ4lYNWX8wWtseHly1KVO4ArIkEP1Jck39TCuqK6VKpi6_Fh4t7F4tB6FdaJ7CQkHgLdXfZAU64Rz3q2puoy5lg3NyBXfzNI_6diBlDUs12ikiyBEXRjFP9oDeQmpaLFQJJLScDO-n1rIn7GSmmv6e5ikwnmPZcp7PjOt-vDQiIpYin_k0lwe4K9iFAwmjXth1p-yzE1hFD-kLniT25pFt-ChhF0bDnL384x0oY4D0fFzyhPbk7Ko_Fu_oWPhXME98J0ocaQFd1FKEnzkFQkHGtYBTqbakqq1YY0YSEvkAUJ1Nk2yR7T8xkRBcxmHQ8Xx1PS0t3xKWcor00B37V1XBike4KriBD_H2Bv1ggS0r0A-NI1QkuhErspUCqkKhlA1PzawbRC4sOa_eQ5nbTdH3RhyE0nYnYoULTkKiYL_dqQi4NZSqpxHOSong7l1RPR_BPML0pr5Ot0Kggxvl6R3JdE_uaOnBb5jraTDm1vlsbEBOgzozJ0Ylcx1Y5CKd_5mAIYmPPu1gcp008xSyjBoNPHQaKk-cTc0xDl2qwHwgWAwyDYkEgav2_AdGWK8-O3QZLIlV63D-2MCK3nEWYXajq-3IbUGz-MWW72e1DvlPnGwxMJCI6y-8PSRPJr9P7wFBt9YdIVUxv0xeCdyRAzPxxcBsr66jc0B1MxsfMyiBPLe-PRrwQmhhjFfMvoihNSapBbARTv_PrpKn8y_v214zrjNDBEeHC3V5U_r37yTYn4r-1vba1cZjNmBvGCqXPGnxCTYugq74Dtrs7fKFPEjIPCxezHXU4vWeUrdQpzo7Jo5N3R9wkMTUKjGtg8IUwcsEs8ppg8CYs5-iTkAL2UCvvJG1pA-Gjm-6bPLSHJBnlJb3GKF9AI_yU3nyV_CY4yqwOeRLGtPvUfSs1fHvhUysQk9yYwQywh6kymderalwieWr_aZBYx6IHyJ9JwHV2XtXNkVCB3QS5YKZ-i8co_jou6Q4SrsYoXCpDF_TqAlpwJ1modv5cn1tw7a5ETH6FjE9n7VcQJ2thenWatC7mJTMxxaqVRcki-WelTemc0mZlklk1CultE2fwx-sh7IJOSy1_rKKzbbTiObrIp5sn7SFe6uLLwl_1ZRFwwh_PofyJAaoyD-_shnQtqdvRJXQyzLYhimQ73-vpIJzXcsBrhi8icKLtkV9IjhYNHUc9cuAsmoe9p0djOVMqPzfbemor3Ev1MsOfKEpe7k7SqhlX8M0FDOc1TY8nUzj0wqT8cWjRUHbDFNH5kPtZMGYNBObqGEqrUN1ZFjohOgZb-Wuo25VVOY9YFnUcJbZeKKNZwbPqym2nsdwlQaXi2pwDiZCNVN7Ebh0XTev9-UhUup30_pianJxk9ylE-f4gln_OO5po0suakI0TAOkgBaQ1sReobqyrEVL4Bn6wDfyyANfyoodtzUTuXU9mCOBjwXlZEBF5kLJnYsvlRTfK1308KwpR6B8Jb3xus7EjCgRF1EP4atGGJzJX-SypkjYc8rAImpvn1NMj4HSHZiuh_v5dflUgRo6050_vHzl2_ERHawOQmqDv5zyBDN2kDIg35f1V3RplUiUJRirncj-iN2H13F5FkCy1_UFfqCCU-ypQlz_HnWS3Kgvs1q5BPEMFaD9_A6xaFCvynRuO8wOl1FXl0rlCB1gqLVoHQizSGnc_EpNJ7NKH5UCAxMvxUGGAggdnu4P-HBknXsJYWDjP16l0Wuqc6siaqLScAv8FWvyaBXL46rCyq_WaO36Lixwh_nfCwAnqZXasi3s1dABfC9AQdarI-RvIB-CtTjkOEBTMzGm63J0R88B3KbyUBCx3eli9xPWULLQDO7AsgbaEQmgmxifGFmh-vkZmKUeWcvuRlZfQxmGEFuvb9B98iEYryMYijrkS-8tQJAH8ZcAvDI6X0fyLIX0qSCun4hsaqXvOq5dxzww8T9KosclFZaFnKL0Qj6u5eXuv7hoAQvKswRwJlnOFtHKr1JCDurTrp0NISGG_Up2JKvUhRmh15lA8nTcHE3GMXgJ1EEvKztOtMlLHAZYwF1iL87oftS4JB3p7GZIb23RxDzsyoQqfBSg4l5faEpDt_FQ2Rjwy0EfYzwyseL_MMTYo-HFJsBu7m3nwsclVu7OtGRAE4_xlhIkH_vDZRAS2XH2LF-meqMyZk-cUdp-s1iuwaOdba0obUUbO0KEZUBUyGrrk1Tkm_8CKP1rIGIwWdPg2nDuiQ0acNVgdJQud7i0-ptIpeT8RU1Mb5WdXfnLkf6vo4HMKa941Igx2bbZJbvPnl_D6Eqqbk5aOtdYLPJICle4YwVhAvqPGqVhMPZh-a66gwPpWNXYwCwRt1BFeVLPvZjvmxd6l0-rP1E_fZLLH5_jawTFGKe0Y7mOxaQViq5PMNNkqYCJv3aUcAJfEuz0FZvG3vqC1hjOiZF2sQHxEQZUL3K4e1tbqhPlVu4-IAySCIhHCa8Y0nP9TH2_h0LKLi3IHasbecsZZuvjbT2CI3lqjmD8e8PdTtzR9AbEXxC8Tdm8s4rF2wVywIWKPBoL45FlWZXm9HwPe7iVl5D6lcceqKGZ4JYuVuQ6Y_Lbe81-O8PrX8aaK-KLN0YwgBJtxOyzwCjyl6ogHBxl5KHhTv2WDEqwVvSSPm9rUOXGPc8vh0XkZR1ritLPbkUl23l8GSsRCsVfar2-JxXQABhpL6X4tW73S93evjMhPEknehgF9OlSczuUk7lXgEI2rzvKmQL_OLIt9GNx4N3zN5TivvLY1g92Rij_MYvD2go91ioLFFHgtkgWdDdFYpBhv47cg7ZvBb1PqiGopGIUvkeq-KFzUMtwfabtR881LcJ_1Ie9YgRGvxP9M_zcDJfLGtMwqUYwURCFCmkBQskJRG5avVoiGVCdeNmU8Lkp1yJLqNhpR_Ac5UAdeeJZqkbI2PNgSymG2U0SLZQZMbYAlvDmdOlCC_nmVVCtTyFpqsFFrsBCDjgEJCZxs0VEoA1eSL5sQ1_tDhCVQz9dGJr1c_PUAapQxjkwZy0Q1AkklMjj7y7EI_7nHOWt5p5fWLG0d6mOkMpYlK1qj6W46xBVIVGXeZB9yNEmu7L6_sWpQAWFpAi6zLh8Kbtr2mkHXgcXE_1-DfAQMju0nZMiQyCUEpIuDUOcAZ9ICdDOTA5aKlOpHETSedcSQh2LYiN9Dl3dykEfDYxNjeDCo9k5XjHLRuSfCTdv_a8wCR_LafJcHa9B-nMCTrODlKvdll-XsMgkYOZdQa6HbrJHdinR-BmCM-sxP5nreG9GBG9fHWEuNneuv4e_e6xO-P8e6LnespHRuAiXIJm8ZTXOLVoq_QZYrUGDWNQdeUrVKgz-w60-S2e6_z32Dkug3xY53OcT5D65M2Cet_h5WeeNYyBmuLE4olYeqC88JUvgFij8IpSVbWLZH-Ln3-7hfJWrLKphNGO-Ji3niF_utnoHlGWl-7ErDtQQKgEQ5mdg4_V0XKCmCd0yElAAQBDM-b7SdvSMsMtvEQE2z82J8S1cA9yza4GFJOdiK8VjTOMqF2K6ygddYcmPyPFCY8PlHta5jVDW1FjmLUUtH-EqwfvKaNc66zfSjCt0YCL1OKcRGenA5Gay6zaemvIP6Xt4ABPuWJ_ar0bwxpUC7JEtJIW9zCCxhEymgVn4PWz05Ms_cVOI5xF9iAlsN3vMuU8UrYj2cVOpZ31WOrdV_VHnLG-CyFWjA0CmG-YFKf-98mBqDrubI1NyTmX16jHObgjV5c061S8G5zVfUrHbWuRaEyCHPIfOdGaZMiO7z_mi3f8R8ZG8xsZAomCnCo_gf6TFICsujiPJTcP4STBeUzZqt9hO1GfH-cNYzs6feS4DtT9GSsRxKfWt3esEu1cxOInB1DRUtJyV08Qje_KRKViCkpMRoNmn-aVx_HmaD2ornpXzGA85Y9Hsv_WRxwP_qBgX_PJ5ldVUxviFgGZaVG_xA-sf0MeTS-f1jqmrKnj-5M-z_8rPJyWuTTszkhpL4mJsQLF81BiJ7nuDuI8trrQ1hA1VIHWQ1zpVrqIV_t4v-rKpgEr5DeG0_hpyTOlJuqnIfwI963688uoDBPZ80r5jhxa1vNUoxP0QNUpa4SWPJMXEDEChGs4p_esQpTjdy-lqwVikvwx1c1p74If7JHW1Su298_eglx05A9eIDJddUI8O9zvfPVQcMb-L1thGB_htbRJEeEtJZi-AfcHjHGB0tdvPMrpoX3zRC3VEIEuV8r1nI6CCrKqX69gLXEcwatisQn6QWdFl-l5rNp4EAFNAxQy1CwsuoToL3RX8bip8wO28nV6dKkO2oM4C6qk02E5Zq7kkVX0HzlPmXBJKUXDbAS8B5yAUqp08NP5ryfGriD9znDAdcMkfjorTVGWmPfM-3TBf42ctenhdAEK4qSd6qTuodd2SbkohGHBKz9uhyPXZU0us8Fz8xw9apT1kcr47SthuqAMPWaEJ7NBZ4Ls1zrXMjNzfyaCsX1Q7qJe0_xR_R50yUAcX-gwpd81Q5Zsntt9vckMbjgVyv3Q6Kr8Ze501QcrLx2m3c-_fLt-FII3tnO3UKSVPTgXMv82nIvwVrIQ2rG3bWGxWh9bgGdpykm5CC-atMj7T1C44obCNSwMuORtBgz3a0GnT791_XsHjCIq1H3eXO4TPE2Iq48ZXQQpjtD4iGP9_S484mSptY-jPOQQk6C9rVrULmIsE_DB1PFnKaTGXvX7XHb1eX-IryfEOHD2hZxmCdQ3i3NVPDrNg9TLYtN494yllcyKRI5qVLB-04MTy3piQRAvIlSrkInIlbp6Q56JrUKh3yzifHV1Wk677rDDT-sPfxXTIstH6JC2lPpQ2n0CbjZk-5g7x00dhe6QNwdTRloIXK5Bn7NM_zBAmLqD8UY_pBOvONJbkkKC8aD8I_T3AD2iFm9bilt2iiimPBWM_OKdPcHmLDHUxdvtTCjqUkpP12jLdgDJCZdP2HhYkByJnNMSOb4YzHRFPUVbCv7LkbYnlI3xaZL6mNp8-_1p49KFFW8_99YL6JezAC2K7QuWgQz0IhCIvnooEI5gP7R8JrXHlFuCAus8LAUIcjcv1KFRp3y7hDRieh-uW4YCq9xUmad72t6wXjYyh0yTJYxz1isdiUB1X10dxPUYzX8k6NNM6Tj5ltwumGN8UJuBBh793hgf3xBnmKazmlpgLLWK36su7Wj_VHElBlHqpdWpQWJtusoqu6qYolbLMthRdxyMm5igdbMeG3SujkItG2-SVucuJPsQLLKK0DR7nHKABOM7QwTB2X6xU3WkkH_3Pu-01nvOLfVyyxHW6PhI1vngYBBAu6t_dV44tLZZOT_aFkyxFjPBSgbovNN6Z4FGwZX4TlbyPcxPZDioO9EnkS2wJTCi2brDx4sS7wKv_5snLGNI3CwFHk9rCVGCCz4h8MPOqJitBLwhK9dvD5BatH5kzZEWwZkyGT3wqQM_MfDzP0BpuZOqBoj3CpWSuy9D74O8fTq-DaBG0pLLMRlRrKV6jFupudgL3ZshI-b9BihH4VfyfuKbzeK4yfP5S7J3jt_Jz5buoo-P7TMfPbKz9njQSoyW7m1ivTjuz9i-N8XYbf0Lau3vwsNcWaDlq9pbrgkxweIRsFauP2RSGN1qx4dm2bDnklPwXtvqrUDN9IForQquXk0Fr04u3dFxcrCvY8NE9d8ln_5q41kMc_AhPeyILijUMSbFKPWHC-rn13fTFi931lAWK7f3ukNixRaO3U1wuDXe4VAqHLHHp1vdR1m5PXaK2m0ZdAhTaKM8X1tz87M7py0abuEwJfJtvoOGRp8FZNwJaxm9NRAK1WfVy35OP4rLIUgOJscFGrqcI6A-4olDU2TGh_T8l_wVxFK0mNrN9n_fEj5-HKoCuNqKbg0MDT35g90IdfiQBs9iLDYVCX_0dw1XauYLZG21Ii6h4UQM1PqQA4eR06Wx55zbCxwGrhfaIIhnVqCxHpb_SiRMMMmZDm74MneLTTMjkkRzZo5UI1qmR_4OiSwoLt4chM0xnhNfMwNMeY8SHODM05rQ7_ErZ3hwyqNiCq_NWeuL1INR-z4giH27ukUuAy6rB59hNzdtbjmJotVhZiSSSygadwHcLUSGnFdV1WBhI8B81ToJiRGl-WzkAzWtcIqjHGVyXNAxVbOqssU3YNosEbpDGaSCR0zy3oDNhMr3syVBccsgwNZ8AfNHciRZ91AAn5sj73qW074E5qpjngOaSGj41-zsJXqIAc_51hNs-kvEct4VeYeB3n5BOU2wswL79hK51KqX3X5qDv1BODsOB740x5GA7his-TUFBDv1j4vpRDdPcoZOxwbqeZ3814_WmliU06MDLckOaQbOPldIQG4Kxz7SkpUzN13gQXLfbj5j8qKtoZZlhugNy4ONBx56ehVqkJGbQdXW5QQHHAUkSK3djCeW1Ds_fvjsi_2MTmZFodJLWjdvFSluVYFQQI813PhiPeHUcjRFYR9qVjQqGfwbFJKTQ2UMOMynWSRmDuVIbw98_mexZeu5hLoaq8-8jcjOiBth3Y3Jxay4bQ37TtGjq79wS3pKySFZ8ID8bcY7znqWDR8ucJlfHzWfCiFTZZaQWr2ZU5lF0wIryc-wdTpMbQNkRJVw0daxwneImx1byJI7Bq7yiJKO0hn3ILhAzh3EDYslAA_voQ6I7o-cYg25WD8M3z2dTE-nB369GrNdPt8nXH4hJpzHqnIj29e0ZFsM8xEpIcrqbtm3Ayn1fERINjU6IDIj-pirpZ6S_iBT7difkiB5sZtfwjMgAKqolTYYVA1yrdPvODLeKO-_FFVu73jM8skd_I9_J6z5FBdZzyeMnf9QOdcRSobApCm-30SBa-KW_wLyFlDZiBrqLgknPipz5QqqSF92cTk_qfrMejSN7-e-itLX7PSidsrv--zcifVv6VXubvyvWbkNuNuKTUFOdIOT4JK2NaLBqX0AfdcmYBZ41ArlSLcCDbEwkLiGSrV_Kuw6j7F0MpVhjX4HXiWg7QaPECPFWc-kYGgpQT7GFS0P0SJuTWwRgpX_NNgb-Jn214vtAZfh5pxy8acXU7FpHzmYzMjs8_qVcGJr4me30DGuDJTlw6jIAvmKiyLtPN7G9xLMYs8uHNwRSYeSBGn2lP-bRodFgQCFhHTOyf1qj-d8j1HQr1264Q82wr-QN3zYIdMv-lTjdVdj25z6rr5N0Jv5zDMOFt3T-U281f8K-sSV4KOZAMc6erAW1o0Z4SpCZ_N_OSFq88hec1q9Z6a89-mGVOqSognuEZJyvANgTlBaowYz1XuY_mqgPy2_nqf3bpJYkg6y5xiJggGurY4EoCg9xhZPXI2oDkY9znZFdTJ6U_dVkDlhFhCIg229zidMYurkhrC9E3t0go7dg2FMGPIfDlPItoaD0Sh_qXzh09R727w6RYrr4YuBN421JySSIlcd1vFNPXYKjDHgZGjw8iX4_gawdHCaBJEJCseHZHRwlgh4Z4cbJkRUzO1vKG8EC4mrqPYAE0KHUyy_GkeZfD0oin_NLrOY2Q9R7DQjt0AClSvHZUfI-GiceLqi3w2ABF36LtmZvbPEwEzTDPEKXCecdAfvaXmHHcw7oC-5lE0GMntqi8nIOZg2ovFpJSSbMrBnDKqervFlAE1bLyr7h8v7uynAk8j5xtbVxIOxmBjEKAqNKv_hNQ4P6_8NXyR0PvdrhUvYNOwes_-nfTixsRu0ZCIdFYC1tBhcEng-dxQW37Di6Erf5m8Be0yedz-ogMD3yLjsxM4BbN8EJNNMJToJXAbSiywmnrKWLBdPnP6LS_8BiZvCg0KopqtZAuXDuZLvfqlkP8ZvLt3c8i9OsYu9mP15jRMh7bICi_1tslxU8-hRVuS5b_fEHCNVY7NI0a3i7Hjfe0utpav0Be9vQIBO0j27SJi3CxAvt0HFnMJjeq_ITl0mo8ow2drUJzuuiDAcn_lj3R3Ind1k7UQy6qn6DcgNhTIjNSAcEcawSu6aMz9GThDvUGq0_FWbOHSSrbHJ5UDr_6t16O1ucwtE7tR3Z5NtXBVW-bSZP7I6zgjT3A81N5e9y5KCuaxTudBB64PbwOH_moKOL0_jgvbtf1tdZLXyz57LhPMIneV6Xdo60UQtPr33xBQok-C0B4c_v7wmTzq6BXL8mRoGRdJ2DMW0a67LiEIsXBwbrdD6QLY4CQ4NLDtodM1MV0dsOGCn612ua4KwdC8b-qNzT3HPR0tcBZhzNLQeuYmise-fQYAYLysTc9vqeCT08xJ4OAJ4gE07UKJ_m36XRiZ5iZj43NaAZ7Y_01_tDZn_mFsIoF0_V08OMaCcAmQQKDeK9574nBjLqZG60vxW6hhvMW8u8JTF1_K5tmePOKp_8t_nj1v9Ox2ITUELBwsbeCqmQzmAWPD9j4__i76kKw1e5iUagkJrNDJzEkRQDwjqcZhhe2YFESJw30NHTw-b7ViV5-fXSWpDmyaQimt08r4ZGrukxTrV4w8QGcX-R6od3nQ0JxU41lVtUyVaJDIYDab5BbyimdwJmRn7tJhuWeMK8uQXeM68z-N9bQnc1S20e3dOEjeQPhFPsATHbq-BegxpXuAoSjNbLgRYU9R_5g2L6p063TYH8i1-hHTgBvH4tzJGkBkD1_BG0q3l2b7hEtzMHWlZkAmq4dSO5HfQ4CTe4SGn-3mcuPvWudTFhbOP3XP5884l-BQdMmgzdIx5mzx1f3Hr87f0cW4gc0u_fKmmSxOEpIxdRfDU6MfqAcuo6zJkJ133vIHNOk90dYmUq8Uy3vE9gra7QFeoV52hZZNmM-rDQSZzn1RdpHPyARcE5W9DRe6q2qrwtEKsiP9C_-tmfX_pIhS2nBhgP2Uy1UJlpbie0E2DTgB1h9vt---9lC55WWKpmx0UdrXa5iJKLfOR0_7V0-tiHg27S9EhUVNZCfuz9ISSEvaZCfqiN12X2dbo38avWyTk8mpE_Ek0bBMGt6d5M0LZif9JNYLax8iRjmP-wtBu7v2fu9Zu97y9H5LDYayv-8TnoO_UFLsYsH2NEyVIhXzYQ2PKNGf-FYs_DFF1tl7sMVg9iTsMgvDkOZ1XhouQ1SXKNSmtptdaqsldQ39sRKpBd5IJszaYonz8onWwkEFTJ235n6NXcxAB1QmdtsA60bdfmyUTiK3wRpDAaTsxcXTLqAqc-QAZ6_D-jATg09XEmF_cm8nxGI202NDX1hXnuHMSUiJ-MgnBbZgQEhtmMbUkAxLgVcFGERyzFFFxxJeSa1-ZkWKDOaKeXQL_lDVwlRyvLNdC1fYyT5xdF8FmcaaN8n8fd74nea0UHDPrG2krNaGK_oM_6YSJ2EfqtYXSIsFRYxzBRa8cFm5XVF7beMj2eYoDrKET5bOnkk8g0BNdWQp66lApCU2lqeOH7x3_tiP93ONXRyq7YYG2M-TxwbWVHoZs_yfRxlCq7FqVoI2bBvZVahmPTP3bE9q0BsvcGxQWHxGS4n22UZL3CVhnHu94abtMqSPDo-QjFAlug-OzxsAJ4NM6mccVuHm-wuVguHN9RdpGZn72Q6foERLiCiCcJgw4Ez0s9zNKgWLvqC3V8X8adrniCITvYuqGIsYX35E5vTnGC6Y9FH5Pewgzpx4EcJXkuYxs4ILyhKfT1E152oNtk8ZC5WIdwzlC5H6W8tz34gYII2cdjH_n3GQYMqJg5a4BCwWZlgWIRZn9kdEI_BwOumpmvjNVIQwBJ7_YFIdJAE14JQ3TBecBhBz047lV-c2Sn2-XPI4Rf8n9IraK2OqYrxFFJVkBK59dvKIFo5IIFFqTjvFZFC0Up8C8doEr4zP1YL3IHnfao-I9iZbz_ul9xLUsvDS25Y-pdoUmlj02fPOaL9SBRBRBkN0-IoxQxLpzXOMe0hhiUlNcvF-QqPt2HGjjw4MA_rXKLIxpft__DmDQ9INEUat74m7g_vS9HqW4ulS4gclTwG5Au8vxVbowmqzUHG18XuRCMt1qRTwkhLVcE5q9q5GsnkXT5-fsmyGsvEW5bTU0NsYgM_nrW5CksubBHRvIzPTuqDKOVclGBw8HwJNcdiRcp6PyQFezQHHPzddjQ95PL-JnzMnvrF46IBvlh2LyQWUvm9YpmS_0urcdbNpa0YL8QpuXfx7Wj_sMKBoyT-RdJLdIHempjmQNt5Dz16teN8_uvbf7gGMLaIOJCNLoaTQCoxqHA4YJT7GNGuqYWbqAo6zngroL868yMC-oH4JkGWu3Jy2AQjGhCBr2QyCHUipjomiaGc_13ZzZ9d-T4lL5jUWWa7VmcyWMUUqAd-4BE88G8hRS2bd3DTCQCc5AeTFJzj_xmR3PiimZPd2vxdH6TeRxUDVvHeaCB0srHEV7WD8L2Ymh_ZD3i1qHtjjlcKL5aPnl--Dff3J_YirdXdPqlYTNVfVNvhRVlBEh6X3ivtH7ETSHhsKcfLwAyDmbOeMwx_X6_45KXW9sgLyVLOw_0z_g0TPOBk26HMbEDsB \ No newline at end of file diff --git a/backups/backup_v2docker_20250622_172034_encrypted.sql.gz.enc b/backups/backup_v2docker_20250622_172034_encrypted.sql.gz.enc deleted file mode 100644 index 790eb50..0000000 --- a/backups/backup_v2docker_20250622_172034_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoWB9C7xkw6QYP55LY7AVo_c--5ZviHtNKGJGQ-PG0fQAKOaMfBnNWIHJ-n13M6jOYHIWUf2sXVu_aKUIYvI8dKIu626gQ03cIv5CILLgzKLPeCDNu2DHdgJV4nTKZuaasJn3d-5jjD0sRf6_5cxnqp4eT-_3eLu8rZYntPP2LZcOcxRvXcKRVHsgJbi21Om_j78h1j_7r9IKUyKNj-BzfVFVhaF9WADrafjF8vPxqH3pGblagihG52O6ZAGFTMFO6mm8YI0tu8KV2ptQD9U3Waipk5ZfIn-_7WdlqJDYgpRYvTqkUQYvfsBzZfgkDeXKt0m8Qek7bkISj8wUR2klFuVBQcweS-p4JISAHyeKHq9n6QPgD0l5hC2Mx5tXyHi0O1EQKU9Fy8TAvMoilb3DldJV-OQX9d4m4Unmw_2lk2-fTHWYR7kw_JoLvceOcyuIw3yuRGnR7oN8txNFO-z-CbrRm3nUCCvPxPfbLldGb1Vouj_kCpREyR0hXX2bQqaMy58IrTsJhdCHDIWOhYfyeVpc7A9H0D5qK6EyT-4uWDAs9IQdlfGE2qxtStsjlyGPylVi1fLHyjaNuYzww63jpS6BRsytPxHN2uDJPd4yt0z5uV8O_UG9pr-MyOgJOjlQiBm5Kck9E1sPdam1jLWXxljvzcVCu2uK_O54ADkmghCPEEKzqPsVImR6FgYKJ2NbvgRt9a5LHlkQDPjVE_n1Mbgto-AgRfoGilW1UPREKVY5tT9f7r8UWjfgaYj9Dj8QZJpiTGCdzGRz9RCoqW-euyB1Z86oWziaTkVpC3cdZxrxdXxxpBbpVcSAoKFmjCA-9Ic7nCncvcfsHR5GG4su-uGjkAux3kREkMg0Rq0g77I5u5UuxscsV9teH0LCVo3UY7mAyoZmsAQTwHfmtgbkyYLplaEAbyk9YZWOfJ1b-s_uPtv27ii0ywq4Di3_1L3814wrecLu2jSnNVoCQLBjR3tfWf5r4z1Ma1mu85P8C4o4Ys_-vfgXrlZLHwmlo16kVlWs6Vb3GmxJ5uBPg5oCGWJs5DuBh-ZLfG2FMpQJA3nZfSutp9YkqLOFHgK0HhdovfReaKpn9CCo96zbjUpiDOSbLsh0woV1DuTGZ12R8ZAMQ7DTT-XvDNwMwc23yqBy4KINGMVCWXnm5AzjPNXEq5hk5TqpsMNt01Q6dImYWbfoUAauXHr3cLpZOANZw0jp0m1gThAeFKjO6BeRGF9wwbwBPh1glvzYy5fO64MIbfbVXQRIPv0XYW7y-ltm9u8EQmxlYIFxdqpi9_mLBY3PNiBCLJcHUo2EIreG48zHA0riNkOcQjZv16V0wHrUx2tWue0w7vOUGKa2MF8S2EiHF8EZN2CINMjIbMJzIPoah1h_g3LX2KmCqqxl4_zuKIOhrXUDUDMLLkpWNon3j7rpMYaNQVf2CrXJMmbqsblHwbP9G6N_fRRtX4XWLN_kteWQwubPKu2STTVxF0B53JN3888aLM6LJEuyg1YzE3agwE36S6V8Xz7ricVNH23cn0-93fasnnyq75g_BP6_N4qOpeokH5kfp198Z1lLIwnyuAj5fD9dA1CYwqJeYGzHGeN0AEyT48ARYGEhISpjrqcRMcnx0sG9TmtIy5ub6uj0wuOedvdqpQV-MfdRdGmBJ9XOltLMarubLogrNtcc7CMjb65VhuR2hYTHezdI6ExzjOHPZjR4YgWD7S5LeAOxhyWrUCKCUP6ljD0RhJSvB1Lb9traFjqOvCpVlDNMVylm7frJGDZLhM1M8jNZ9GROZNjFA4osT3qKZuAA7eA_1K4wMhaRSgAnbWGICfAtFpys6kjeEG1_Tzy0MsqkAeOBRrDygyYA5G6qzu8oEW0-7T4MjVH4wrn9EibIvkLkywlCk3eB-aTg6U8dX1Hz_kHQ0Bf6m4gHKiqbgesTSBQLSgQ1SveQMO-NUGrQDGhmeywRiEna5LeQT7CxtzNrAf83uU5M7dyQEIMrKrllWbyO2y5IwWK31TzRPwaScWxKULNS3K1zeZPz_lJOKf9q2QRz5Qu2hypAZlsVzBpJXeNF-DO9KMycfB2CLFrR04cRclKa91xNo2Dvd4red6qemlFg6qk4ccdL0Yg1YI_C8qsFU9Th8zFX6JAKhPeEjl4DVcGNAKJwDrDsfBQzW9JuybJydVt-IKNuKovZK4ffQutHigZfY1JRUVw-f1VzJWdl61oJYHKDacftNOY4-Rb4-0IOw1-EMHYWd_x1nuIHziqVGAc-3HPoXFjJ4_FmLGNfdB31eSz5RjB3ewuGGWkU5-SBxLz2yUN7eBtQZKoU58C6Guxylcvr82YEsBosnWu1nO51xmFGXT9DS3ebk-LnpZ-0KzVkn0hA20CAEjI0z3i_rOWc4RqlSTr7o54N_3gb9AzXj6dGHizRjgduu7sm2k-10OygogOLhnPV03o3815m9rj4X2zLeM2F03PTqsQbiXg4eMKLQZdosGaUW_s-kdvCdpXuIq89VdBe6anukGuo7lx3GLRZQz5_CHSv3Llc8G-UuWORQjpSW_W4K1gmTK3X-5DtA-wdpPg9XDjN-NL_rPAp8Uzd2HEDwOBVIxVEleLV5_UX87tNbdL0j8Qj5JJcdgk-f5Vy12HQOMMW0LhJD4MTBD7BQKTeq6D8wNAoEX_z5nsTrQ5rU6F0yJ8tUXWxrgLbQo5VLyXkGmz_4llJ0oV09xy6UsBU2jHY9DF-3u8os8hfyt0v_9zr6kv48n8F74AViXfUomKomXP6blDf91yPfa55eh0lZtUQYo0w-3rVyVZFCj93C1N-61-HCHvb8oJZCDin-OCx1kXn136S82c9Q_KRGhz7Z2OAzC-AHUcoQMYke_iSya1SlOLhzhgrxH717Wf9STWm1EYp2JqmyXY1MlA2rnJJxEq301zTl29ZFKzjGkkuGBFJnf3FSljIniICyN4t5tfkJVfFd4vfLrzV94FJVRlw853I8DmTIp8Gy7HZZyBY4f7bQWuYgdTtrJk21eTZQsu6IkdXaLGb6vbfjCJHZhFnFMgKLe3uLWOYmr0OF-boXOzf1Dla8HYGmd5f3UtXEtGVeTol34xgWdw4B8sOGgsSSwYFx6SJo-zcBvHDY9v0zUvbkHgnXXghHrHoxi5ixJQOL1x6eiDqLGMSItykzuweRon8HaI5-GR-4gK0Je050T3Lz1-PhqwaPMt6CjMlGEV7PGSP9yrhVsUktc9Xhca2H8WoubMkKwu6azvkjMxvBQ2edXC3Bjsp4xmx0dRF-L3cADmw01W9b4VdbWAo2usRjAkfKCvOiui-3Va_xJC6xni2layBzi4B6BDeh8ne4bX7zBwgUULh5cwDZ5nLmIwjS34leKfIG72Jxg9Z0F8uSGyaIKRYOI4TIRJ2ZYx5TNtTMxSNMvvV-aojwp7EZRhJW28hJ5WdhGWGYCpi3r-yW1uyH-9HzBDZh4BrYBqgz2NCVWQGXkGOLWR4A8xVAzx23PyYQQhCiXzaeAdWrUNwYi5_9U8ablEwBGhGTD1FX3ZoJ3qfLRrI7H8eWU93JShIuPj26XdcjnKg9M5_wYVlU3tWB4jkqP7-Sk9Ypfi61M7bUNTJYo6sUaRskLXb89TtlnOhs1QF2hd0aFKtNDNo5W2yR5E5ur8GY4KOdvEOxuSrei_qlOqfWiW2bMpKekAWE8FWX_ZqIswFFgc2xnuLEBrr4YHyNQ9sRbb1DMjQyFKVvQMmFk7SVwprgr9V_PrOXxs-C4KAxeR3Li0ktc-vISRQ4sDJz1il4u6DJBaBklitIPsfVZi_Jf5Md4ro7-FyyotpGMDWhNiNB-mLmyQ7FwS8bjUDu3DNuz3tszWVqdP0QSWx9ZvV4lVi41uzvasvzdiDY5-HGT4h5_MnVwnVo9Ju-n_Emyd2vLuRMAZsH_nDSAeLcxR6bTkuTFKbHhJ--FrsoxViUStAD2jJ35Q4AYQ8LaQn95wrgs1-PCAZRxk7u7UVGb5T5Nmjz20HJ9lsgnTUSRhDk5y-RLCHqR4sSNnRaigt-3P9fVZbVOQkOtda1BnCthHWKI2EUk4H60mJ6-Lc7wC4K4AcFktvBULH-BOcthl44-SaaDsS-71XdayQ11gHzY9tNE_88EK37IB1bPQEnEkfjp0iyoPIdrDYKwjPuLZZNpagZht2tBlFU9OpIkQ8obGtY9V843sCw9pnyZw6mr18aiw5bDRMwaziQuIz3qJwEcfF0_t00dMEDea6FhLtk4EMAOaKtsVpC9okbI7XCoH6jYmsoGJ7oGk9oXHhxA5wMYGT_XtNlas7YyU4F8MFr0L5S_jdtSGVBfCBySqaDtb0-NVkQbvoY2lGaY4xSvivkOZn1l7FXzKZuWDhI-ZHr9OsnaSxK1-HA2BN9F8tKJHDCterj0NcwCD1Je7EDKjAKx-2E8NvfdWH2a49v1oByN8GGJAGEbrDAoRRyZGvh6frSaxcs15NdDQTFRYRUsKeZ3Bpl60scJ1B_lB_KV6ADmavJd_19XFRL4AIWzFomD5JniWdYstpyAnO9TfG-g-dvhQeQgHQ3RmOw0vzXl-XoIQfT8S4g0SVO4_jE8-QithSRMjWsfpWnVyR96ARCNQDtmHuaKWLW6_dZyA32tgImbROluCo4QSFHrHq9CBunXslt3nzOHUY4u4L7JE7AjW56jImjIZsg3x-S1MAha7R5ZHgI7uyAIcS9jPLIncRUrddOgr4mDqdP0j2-ap6obVD_Scru34iOAoRs6HOk_MkaHtp_CaPvTeU05KCzzWE2xebR1QPeMJbVoa9bzxirdGDqR8Vpb2ZaKLHSZ935beP15T68PdQ5nV0jD2m246Z718P0u13TIItcLcgwvEJJLKIisGV4EgvcF_WLeV8CyTkAU8CXNO5CSaNgcU4mVD9rWf3gxjClyvojqoMk9s-h_6GUad-_iBngvSYpzh8yqPxnRJTzvGC68s2mcBCjzPd9mgIWVaxlZve9oI3FC6H2cl9QY0m0V5DYhsBQKJ8tC4W4SYkGAMWs6Yy55gD223OL9zWmWwPkqqMOHEU_LXbxZkb5SdN7W26ACDxKVQFhHhWW7zZCpB44rhjrzEuLmXdRxI2g4tzoNoUzmbtRxhaPJYUgSIA6PKSSXapdY_34ZfPK2EIhYgAQYyq2Pxm_jH3sSVhHdn-k4qNw5Je9hZpKA0pUmPl4yoapfNRIkfwfQQpUgVRs4EDFiW5ItMrBuvSFcEmDdAcwKAyQvN7z4sy1oUVhPSLncHLbYdAvcwzwkwsrVOTu6q7H_spZqm5VBW0x3WfH3a8s7QrOE9GWrkgOT6S0YbmQUQyfBOfdRJNQuoT2CbbPJ316Fmtp7ve0d4HikxNMbRVNC2w_O_DG-e-RmwrCOUXyrlLRcOXp93C8K7ws-MbylWBpD34u6Aq2OXp34WrMvE_156PRth-ejhqS2q_H9yvpxIGJgbjHC0rJKATdW6U97UxgeXywqeP0J04y7MLXt00v8NkLnx7-zD0upaWBj5tte-TPOO8J1EqI6H-f3G0av1Vjwd2ldkl6GEjKIkQ3PTNqCMVpIoPsnxxHfL3XrSCtl_nRQNAdh1PXVNPCChHL2omL8CmcCv5M7p4M2lCnUEjTdPPZ1lPzM2fKxbpamd1K3w-90t6XrHrbPseZVWHBBfFP7wf8E4VE26kVTXhne6fOWePfZj4Ob4JuASM85Kgsgl1V8AXlW_rN-o3AJ7UdFUxLt051JqbjNb900njGqsSivutKat5ysEKf3sXzgHBghZhrutH-jHsDrWqFEIKbWQ5UUNkw3e4y5OZJIZmB6fWyNVuXhoMvFi-kezb7xvDsF_b8810HXPcUKI_F8KxKm4NfDfApuIb2CGWzTNwDs7HmQz0oXWI1GeS_g8NHNxigRh1jg4pbFwroj5a1eaCjdWLES9kdErRubKZs882MlTCkh1k8GJgKOHcZxpVLqSQHoa2nzaV7Jt5BpNyLoFf4UE0qkBr5IzXIuAY2rM6E9GdWMbf9eH51nXkF3Up7IX7bSIGy1YZh6fYpirKdAEYierhYYk7JKw0rbSVSSn1SZcRlBXXqB483nUtO7l8iEtV8wpIxHmudvN-B99bYywWeJYMcDv8CCw4oDVwwA7tFEFcEtnnm5wo6ToYtmiJ38kQ_VUwOh7-2IxNAEHdWXrM7i64pFkcNEYodjZ6fHR4WK4BtyzdHI_-K7K28orGqmDV6iDNWgUdOU1pGiYYf0hYuK14F4ihQV_6VcvRDDhEiG6wbR11RdQmj9qfGHFRnjbXNsuglv83B2yTv7vnQ2OkJj_nUew7dp76kLwcVtDTz8ObjnDZKkVNseebEOtjJwHDH8VDK-Hggwu7X3DcPU6es8kOxL64RNw-J5H0xo5D2ko9dlsUn3pGXckLaF04paKDQrWdkr7IExJzD0v1pBwirDW7em4KGNlQ3z1LH-IXL76JMYyp9PECQ_fpKxbu0a5BOJMBv35tTsapk7q3bhny-LLy7q0IU6-ao-Afxv-E_s1CAc-hlDdbJvMY84UvlEu3U-DS3QH19Lb7VjDu-yjT6U495ILOYWL4Xuc00KbWNJmp_aI4_XVxIQGuQcCxILValxxlUZYIGI4AWwjyU9bIfTIimh3LPRrHz5eb7bMnyWAIl7eHLg3_XpI0XZZKAFJgeZvSd1O792t768q2mRAS01TUWR_IRk57ng5v4zgvvtr6VQ-Xwf7D-V7JFtmUHoIdQngDQ4HIt-lNZQMU_oZ0GOFRLO7QnH1S3cHDCdR48-OFuW_EV_FvHPnPeoSH15_FFjGOY9fp5aRMmpGcHymiKwEItCyjGzcA1l0a_2_528SM7XHVwBIuo4tkUAjo_IrUvb7IrCHvmzElGLIxGXGV-nj2le4P24L1YvQ7_UDkeEhtoBifZaBiuPqczW5C5JR85CmVnp4FXP9CuvCUUOoIhZMmMuSRqWjpKKmTL8gN4pzRj5KpOgErBrXlgmjluW5kUmwdS9ASJcIZf6HBhB_D4q-lYnkR1XxI-NxJTovs-sDslA37kuxKqKyYeiQLtnpEmyi5KQ6ItHAGnypz1FaJWts4R7HUcGdcYUvHk3J2NKbg7Wd4KYvkiyQ7ZL3zI8KTdj6qzC-NUKXeXF6jw5c0Aau-CG6K9PjNQxqx7d8xCvxgWXRFa5cvhrJIwqmsdbBehDufxu6acS74fgyMx4QHivBW8R7sR1lMbyoGBrdYsjSP7E-YI9TJkWQbjHhkAcJ_9bnhXpP-2vDkK0lrm8bGTQMQqiXpv_7KPvTE2kAQmmQnxgu6A0czoXzV5MK2ZuAk3h9eqYUQ10DQeGK8jGyb0tVkYDPxk4vr9ok8ME3pl65pvUYippp1fOhPJ--hVuSkaxrci-jd90ZxqsxR1HTxDU2dGN3d-oDHjiuKQNf87pMfEeiIUTTBXrjnkbt-AEMQwDJo1eAnFv3QFblixaB0p7z9rWw5L3C50dl2OBIFbDH6HosoYzSW_RJkdun6A9bCTqEHtVbY2GRUs7QPS9vbC1WCDJ_aIKh8OHFUmeNCvIqaT9BlZglerK1BsMPigSUECF_YwtSs2NpbLXC5oj7r8Z-I8n3z-iS2N8cb93_KQEme8K1EQzWnr9sNSZOHk2N2JnEos2xZ974z6W3PE8PmJrbtEfdc42zTQzbquxJk2_M492uPv8h3gYV3FNiAhIwYAHBHwyWHkw_8eBRxnz7q9tvuEZPTBfsh0kTDE7QS82Nf3-K94Ec-Tj4bssG8c5txOu15m3aBpjC6Ii_iJ35Iwc5WvgO8BOubn7a9vzJx_2ateytQ8EsUJ9VknVmovNOcGrv1Rxc0zv1Wo8hF3Ami05FMHHeopiMHspYNM2t_1P-ENL2OJrLmodk7GoSepx8CeAG1y5999J6KXK1RutZYng6OLLcu5YsBuAti9ZOdgogm3lca3Lwq4TfbaeBlfP7ZHrguK8Q1Xd8o7x7nm5KidP_c-e-DggMpnKRN1NBlqdzke-2kl41v9Q2_MWdGrUbqZm30xE1bw3EyxWTg0u_pkSFuuccDNYmWQIkxq_8ImPN2RbfPX-HHgPSIDPUkp2DwyvbwQzRFivLjQBlc7SEcIs3g7cU54p3fwA4Z9AAnmMrho4qqdwRuFlHPJ9tKeGnZ-xpJJgtj9gTB4Zcxj-VYLGNT11uFevACT_1eJAbpqs_aX-EID6Eopsq9V7Zgu6RoeV9E-PzybHhjN91R1HVF7A207kS4Cxh-Xmu3Re4KQXwyQLXPgpIOTQK2kL0imzmy4Vhw0PnuTPQ4Wadq58laUa6iXkyOV386zNNy8iJD_L2R4FRlSviUKY8G9RxCMrwx8r39P76qglmx0gVO1QABkwvymOLGeRRFH0bvTri0ywQlZBu4HCtwqGQKi2i9Cp8VGpPxpOoPqOwx7ZBXYH17UrAAq-m1PZzPe1a7LdNEftjEaHKTVMsfhyBehMY5LKPxB36L3I1Hu8wHmMtaSSamAoEaS42tNxsvPctsSjdFtBuWIdTge3dkFHJpf_mFXUxIWCFbtaGKG-TmFVH2MVoZLqB2v0pPV55n7ejw5gjOshIfoXRZ9WlzuiiJlMGAsLbfo-LInW_4lZswWLbwG5YkEV_A70LgQHf0HoEOvAJ_cGmST-OZnl6DRCOfo2L6KOhIU1_vSxPw3PMu8tKt_8ZSv3ZlP2lFD1G6rPzTKv7q6tPp5aKaXZVV_C7iNilsithaXJM9D6Bm6jGv-kfSZEozN1M8Vn5JKU1Wk-WZMW4z6kZPlsdosPxiC1b1FkHmkfCkq0zbYSwctcKQktj989nDTVhHeMRI9ASoVZKIbyNNRYelrt7tlHTfyUrz8zP8isZ_UkCLUJcVlX9NNDBYBgse824tUaQS7rfJUfs1Pg5Izz8OcWOwpcRshn1DgCw0ofGWo8VnXA9dqV2kGNIu8UIbymZc01lwyrhvGCK-GDDWdTeIsu_lITrVfK-Xy-yknNU-UNQfYomCvRCfapJMXrzhEuzYsU4VovQHWCOR1Xm1TsnTs65rs9umP9uApN1Ix7G9x4T-0aIVfraHMFDaBaLBBSpcYnfaQrwzdrEqvhouQdlgOZ0pJmuakjcXbcPjzgYGYNCaDkcbT1GY8OfwG9l-GpSmYqrL7BbLCXc2uxV1JcEAL1iCmH_xVdyDCKO43q2Q21GbiIukQxvHmf-v4yFBrUXEtOxldok44xr0-gvSSYHADd-kTLNjI7smDujrPGleKzHETLiToom-vXwYZw_3LHWUQZScLFEhJvTHdC4zgC8yIwCAOGnhYqV4j2Aa1w6Kocts4OYMAmCpVelbJuv35oXjX8mJEli1pN6n9dfrN3fIVA5-xOmAQsUSVdP17frMIqz9s1T00SS1s9IE_37HcDIq9NEIJnk6VZlc4QD0CZ47gd9WWS-X0u5_aGCxmlmZZrWEctgsQdGVL4ZXa-bROwnVOtljEynWtbApttINli_GzjW45WLlMGlwwhgAsuQEMONaI7sjY9k_HxF6gT_a-jTQlEjF_zLvLi1fucBXLUKBAXOINqE3i0H6P1Gq-rtrcTTX98QV_K987dBkhW5DlBwehAOx-ZuzwcHwXcjMbjTBtXqpgHFJ3Nn7zZwRIsPny-Ezpc-QyWci3JLGrbnHKp078gkNWMqM18LM5TGNnQI63h3ZHXP7LWH-FIZRCE-STaTujwwBR7NIeaxK8D5chCIQE7NEEgEIxH2TxgSmZbQq5dzIRFwax0Q8I4qk930bRjlbp54k4My1fXnmaZr3s_LRYF9MxJX_tVS8wMaMG7q8gVFCO-4Umt7I3TYpRGkKUmRGg2Ax5M7TRzzHBBk5mGsL9jSIeIGiI2iSeFS-rlwtOUusipqK8SWOIpCy6V231P0JeU1t97qs2A2Bjb5So1NGGjFX2vRY_vI0uMw7GqIKtSnJChFkeFQ-gvEcsBdiH5x6fzdRHv6Rh7lFqXmYE5liGfzrFyS1klOxBshQFXY1o68zwfb7cWF9ya2AJy-0YDwriyQAVU7rZn9ibPsEpDx1oSX2VBtmub52kQ9BvXvuW5NY1xugRZTksZYuMGCGP4-5VNl-jSEfiCkasif-p9g3kbLAy-LhOnSJScRjFiv4FfimXF0ClWk7k2G6zXtYUHdaODegTZrveo-u2W5ZiR-lwDA6VhMraE9CD2KftYx-Tl-vcH2RBGqdE1nq5JBesWJXF46KhVYTxyL9HkVojeOmzBRfKox1zHxymwGqYR8VNoYELaeEbXWrhMy80wpq6zUk7vnrCarKPkk949ure0-RvGY6Zb6hfJQ_w5Y6SHGx9KtZQEjgx-lKtfa6M3MjQfwVGyz6hqxJ_PbVc3ejZjbLYax8GtqSnc8y00L5Qs5bnH1QdcL1I8mmbQDAZTaGtcYf4u0PoBAhzs6EswD5SkDck5rpEJK7RL5Vr5QLmHHUb01xAc369Zm6Y-22Jyi1XMV7_cbBJ_imCV4jIzQHqQm_K5_1MNVc5TKITwu465ryC64wE3c4o8VE9NOAJJej2xcZnzuiBtSUjCvcWOVFuLyDc6seD2Nb6qBPZrSrzICMGJKg6I207Zli-0uIedpFwnJgzHjhCbzNrP9qpmE3uBS0J4sYvr3Tw8aJzti3__6uQgurPhGkP2JpGxbhyAv96ZpZw5utv8RqnTuD4StD7mdMCnFwy7FHHI-KbsDcXc3mHzyVOE-Es4s87KleeqqK3lVZ65_5LOyugr2Gpyqs5MRknwfHr-_lFdFxGcTDbuvMVZRgzwZHAIpXEcnn1qHYAfI4EnU_uDXC8CtM8glaTJQxN4QgMgG2QbeKRcJ9u48o4aRV3T1BipS2BgLvbuxT8zJTib5FsfLeed9CtyKIrY6DKxq0hjO0vmkiutLwKHRO69mHZh_X2HIQpOyTnoG-mWPjCi825Ao8lc5FPQqXc9AfGNhaOGqiYpQ2qHav2Qe0Xh1_OieTqS6BP8N2HI483P6ZDsG8Ocj_FEXS_inH8lMU2RnVzfidNJ4EinF8EoT4ep0JKqWu5IMFf1wxyQI4lM-q12R1RY9SUeyed-SpBH8VZEIK246NC8n9S8AsGul0_XojtYlU6gYCu_s27_ThS7TNjyeNREQpr0e6C6ydSkPiiuFAIN7QpgsMCDugF0s5cpPXsBbGaGBw1XdYNqkEHWtf8-Qt6uqBQQ0dihSTyCWxmTh8ZOsgSzeg0F3f_1Gp6_mgRxrKshDdCqM3gg-mLBZberDVpCUdKKlnpIg8ib9f6niTRd7J1LKr53SXI80JpD0fbtfVNRSZ9N5s3zQNP3m-LTVcXhbdp78yqS6wHxZ62jUp9QN0szAPOG7Z1fcqQPNfMcuHpYskG5xjSg9k5mirJJjztf_8WH37jEtcDBcbS9YmEKI04-3OFzafyLnI0Arncq4pzKefLn4gA9yMqKw4SMTiddGZ6-FibXsgGW0YjcTnTzbjWxn1p1YUzjareM0HUx8amYdgRbi4X7X6KMWGRE4TqPW5fgt1LhgG3EPga2z3og3wdU1I8Sa8-HaYqb-TmoDgRa-QdjtT4jJx9FkfLxUBZzM1keFFMYldVBqc1WTFWZXWPaZCENaNbqAfIBvJOjK0E1hitdwhq93bIZnIlWA5JVGRs8esskBpD9m379EryCeF6_4_i8JkVp5iKnCe0i-6FRaYpCbIQP_YwNIOvtXq34shu-owUjvL6w2_0a2sc6tjxKVFfjFcJEEYgZJDEHbvotLQSjsNKZ0BumW9YwWYefqsb7ZQqcpxiqKKkEx7QcFctoKCiYDEPvdhv66I2rJbJgYefPRCPHOYB0OePX302NKVPq2DhCjPszHhauYDf-JSXZ1e4rZrH6ilHmm_UrIeHqlbUzRya91TpIxDjMX7cLJFxZUPHlam1TSZUSCe4kCO_TlbhXylow42M6ryrnbqK3E57CveN2Bf46VFiSnxkVg-YXX-5UqadwyM2bU0O9xr41wo70eA8Bnlk2oDxl2uRONvuJkM4iq9HCz5fOKCPolecZ_3a-iUrOhNr2qKSDXw_TfDpqh_J9RtgGmr4-KxyvZzZWm01J_JGMZS9HW6jOdv5dmpV8Kn7f0350YSwvT-D070Qp15v5Z2Fny14Ew7ho1BYzQPwOIxGF-Ak2sQEYhK5D24yVJKroQCMrtgDKG5SrgX75IKT5bRCxsZoks0AsUJYMtnQPl1GWJVjWij2inzFwbloPw3GGzW7t2_xgG47srwNmSXnx9nKrEJXYKw85vEl5Ea9Yt-G3W80ua1WSzBPMN5YM6B7Hv5JXfKPfzVUuDwBtHYkk1hxmZiRpdJGglcaXTUUYB_UKvvWfadX89k7YcsG3DSjpkLMRr-cRzSPDwlwxn0tAcj0H-9J3sx3Dia-K2DpZjpToCbm29enYqbTl3_a8cmF3zuRQa7drLexNb9lCAXEhuIvcy-fcOw8CpVr0-OiZ8VziqepnnlWuxh8haJJaDJjSMqpudSXN6stI6gbQju6HnFJPoDk21ZjP1enkEB2fBnio0RsLOeMYZX4tOw7792IQE_GAYGjnE5aR9UR1Pforf-AENsIH0HR2ImjozDyVH7YNirBBeNKFyQUNB6oa9UBZs94ICGiP36Ug-6sh0kfQGuUj0C46IpQFINNH_jri8XLfWl-WsfkwSfrQQGaltLyGAGGdYw-aujzMy9Il0W5plixDhhnHGVQZzy9M_tA6U_22sbuFcvipM8yIBCZ_d-ivzJ0m_Bws9bdzMELNOba6ON7N6YtB4qyI4pAK4OwpjwhctcZtysYGh1VbOvbybzbCTnN886bXTzel0j8hoxE_RCXraf62mkTR7qZaVNO3BDsd5TPdFSxeZkVI3tafIti9ChItc4MsLSePCJbf9vfRePnOnuGLyo7pu_hxe5pKB4B5QnNFA9jcQsOtdrHFNkiojGr3w_ZjNAPr7vUXTYDRU1AFhZ4d8aWppZbQJYTdwh1WB6tPU5mujTRm4gKgYO2Wdm-Z6ofgqix1uzTnHfhdIIAjsvXkAgRWLaB6XVFKLquCEwFsLIXy1qNX4xuvTPBOk4UYLqRXpF46pCNt40i2WyM_dNAh8N0OMVaFlepXIVtmYsFaOdU6h35TsryvTZXA_NCS_J-kDzCw8eszmcVOvLpMobc2mbmkmHDM-cJ6af5acRBY-FaZuTDUnfMaOQ3sEulHGO2y5sqbguZnztL51dCfH8q2VlE0nqWj3PCM0G2qy6ug3A_wrOpFLgPu-f8b5MdqXn0mU_J3vOt7sE64vltLzpPb4c4XSQp9_SAMMYYooVv9Ly2qB_Lcul7lsTcyjdT-p4Bg6sEd_A3XejnEC6QTsyXrhAHqkPDbff0-7wasnSrhZBNCVtX2drn87OnhF5WGb8dVH8XZfHA-LggOEnDmCHoSDP9JSlp9-dhJd0Fg4GEPz7e8bdOsEviIcEi_LuuNnNGuNOgSMGp40hCuAbnV9sAtjvVyiEa2eNF8HCTqSZyrCNQlnwrHzSmvPL6IhgmKaE0YATW-P6cjzH_Lpd2-_9wP6Cw2sumSpNGjq5mvQI75zQRarAt9-RGK0rK86w9gUZ5gbIHjtLmfkooepcPMlOsM5AwkdryRdbMhvELGUshPY-QTmcpfMAUH2E4vhZXxfNE2nwv00uHEjH2S-fCTBEf6CqY6wDwUKknBy5guFwgiO3bnM7MyXziyiyhBELaciFuzAAmGELedwOEj558iXAig4rzAAeo9HvCDOTnytFif1YOpUFKoxQY0buTYjJsE50TuLe-jTRJqC1OTVkxrlPj-WsniYdvljQW0Q2nfl6MWogKgQN2OhrUjmn6wYQ0E_A8UZ-s0v5NwXFad-UsQkq1KJwlW1tK8b1K-T2yAXqUK04W73fnRvz9nNBlWEu4XZiUGKSRBREpWfU_qQ_S9aEMYkS1eaptj0XyXEkZG63BnPcb90Vxz7mFaQ_cGj_2r3xglhTDemeHSzqedW82aHLwuy4Dn5Le8CtzfecSd1wPRgtMcCe8dgUMYg7-IWCJMPH46v2XfkD4yRfbSnXD2DnojoWUsy27dziwzMF4jJQ_69oPQV_RNPBdHD3Jt-5ReatAjKDoBsJKzW3x30ctE_unW9SPJjX57r8GnPUMB8rIct39F5LFWuCNmay1UEjfUALTbeT6zlIzuv1VESulZI4MWka9Nc3R4-_mpy28BddDVAORZBmqhMaCho9aPrEWpf74CNCWEZtPDbzVnQb_3yrET2qyc4DPvhtMQeyndUeWGrcPhGH7tq3ReCrAMKGjeICGfVWy_F-cn2fzHDCFfspVWGEjvSWSCPNIgzir57du829YBnVCbS9KPj_-l_Ov6jef5hNIPEgcUBZRsTwIDqvDJKe2Dwya8QAFk_WS6lZVE3FG4LtwmsStw4RzM5inB_n5V2awAUHSpYLwSbA5UTv4p2tolC1eS4J0YYmuTq9jHKoyeMbo_C5ogBIqMPh4YjsTxKsd14TIc2KqAV3L91M_tNZwgp2lL5MfMIT_2dDpdDkV78BSqgeYKYIjFhTWMvvf-0q-MOB2oVjZobkE3nTGC2Afy-o6bs65yJXyi7pj5RakIGA1csClHwC9XHBvfCV8zVr-jAnGZ-7oHTMLNxD-BmJy8TFHfg8w3R9CPyM9wZA_e6Sx5-3JsgIMlGHCemkkSiAmkw7iaG5dkR2U26Wg387u6MmCrrPy0hzEN7f2wOBZ0AMkBcjoQLKM5SDSZE5WUIkONx0jEPDeEdyBWP71LU3V2sX29we-BbBqSArfEfZHSCyj076Oyi-QDSqvK0yjp0gJInocWFoayljMuFkG1k_TLQeR4Lpt62dJ9h7LKWwl6hSsUVRPtidfBQcHux9Y6vkZmc0J0A-1rxV8Z1rlElbPgyKi-WOIEBScUkK1x7bwiHqdehwG4EKGknwPu1fKM6n_9XZPmhi0nWxT28kacDxaUBaOpIt1Ez608m6MlQq2SmyPAk3s26Yq3ENm3-oXvkLmwy8q6AWSbcmBHpAsAze2gxaqBt_G1I_Q1DoHbCS7sBnGcFdZScucX4SHU-jSUcM6bw0Lp3E8ZkjBKp3gkLR0i0BPMgLyeC-9fZ_CkxR8wuWuuoqdMW9o25KNWQZCoKJw8-JFbP4sbhnUZxs9KP8VRJDvPddyKS8zUgxrceTU_iCi98L0_oF_hlvz_NBdRfpihUeln9Febz6ogFKjyZ5IsYlOPnfoGZThRxGI3_2vVp7q0P7-ogjwn2rw5IDkxnhtcKw0i4vgyDz7SDQ2g7dwn1Vul3CjMgGflmm1a3dixjU91pWSMjDIUVJ_ohuRkxV4DvtI30U2ndZ8izY9Qcbi8pxrc8z4vWOO0NUI0-617-FGAv7EIq3Ar0b9JGvsKt2bPcnHqvHi1AVSSpeR6YrwYDYygWPvXSmS9gyG-neEbDR0qGXgLrmcdv6IAxAAT0lReO2Abxsz5QKNyX7Y0rxOdO8fgfuu-LQtgAD8HnKnDWO-XLP2aIIitKjYQZ1zAvtlrZ_TExA0owarCFtYNsuO17Ie6DGbryUibvOjmy4NUQIRhSYKOa8JOLtWsK5MYqTqI-1mY3OzmzNi6NRx2BBG5LTu_VSaPde89BWJUzacgQwgBTe60reJhIV83xzd2BHUh5fO1apiABXHpLIdopDuNsjwa6rDKiJwFShzSDqldEeGpOnGVKgBfqEqOmhYDXTpfnQFl_r6_LxGFlWaAZKu0GUR7UCTtXHYKFnIO6QAWFVIL3nZw8siSUmxsJPEXXv2xOQy-liGDoICRpNU34HGXGsiBTy9iRNS4mH1wLgPro9U7mFPOMU6O5dU9mezzjY1VDHBgut_TExrmdF1UfFALoOOgJlJ-ck0Q4zdQHEKAUpTZvVKjaPCIEyv-IdDIAV7IFkygbCfX4S5GGDELaPn-D16rT7kCXoMnLTLheh3MF0usCEG6StzTbfE3StPna4xJHO1Jby7ajT6PuhzbHZ6rzv57iCwXlhxInm3XFk2qBw8GtfbUoAwPFhUAKNJGI32eQ3ko8SxabWOsTNieUrY7Ofs78Q6MDteVuy4s096gt8sWktwki_Dy0aP7Vz5uA5b6E213QEEbJrmkO5WDe9keqeZXSpCgMVU60ka8F9G2bX5fpCFg4l506NghprFZ9oKdP4A0TqK-IsdyDvkZSgGQHgUDyyMjsB19o0Ht97wN_3NtF35cYDWUG3DZ98ercsUHpKO8GQCrugEqQ2vBCZte22feKjJuNQKVBpzdhv8GEVi82etoTqNNGnUw-iE7M3CMQGUDmhMugzfzl1MkSS3D8CB4PKnTNnjdiFxJdisosZqMezj7gjGdfooDQc2XTxbVEqopuS341AqHXTcNi8lIS4b6gsUeglVTmEPpq4DlxinNuM16XwpoL3FRhsWizJdVrT1WEy3W0DST9Ew-H3KwNXelkcXoDVgQjoeeFLW00ZUbWY2v3BtfZO7aXMx3U-IxxVS1HEJkB9E8FabOyr3-F6V7CCy5HWDMCx0D6JbKie9FHopmbOCXwxpiwqqXotaKLBEc_vuh97-81wjhQmoBZq_WywsRJBGTDjVjqjLsX2R6ZawO7zS9Lpmod3wjOczdXGKab8H-RD8jGOPEhEF_HKwYsYy4Ta7VefAfXMkdl1MkyUSqh88VtPz3xwEmUnGXvEHGLoVzI5Yzi5qUvalrjHrA4I1wBgh1V8Ozu3LUAjGjntv1HYEFFhV3QiQGVJN5YU2T6GgjkCn6AOijpLH1Vu-aBGofhXDngC1JJKpMzoKxnvwHe2fBkDPRb1YDLwsqkQg6f1-yYSMQhlPE4-zwkeUFctXWWNDdNjZ0E_VNICPdwzFqCNbk9PBipXoa_HQbIazDk_25B2epgU7i1RGix4ZxdYb_ASjPUauslN1z3jllLU07sBrQpOBvrNag51v73gAreH-Je44lI5OB9J78IQgWlduVIQm7zBNoCYpzW-DbrXNMSJvI87BCL1nPqSej6mEWXxDjuJuDqjW0bXvraUh0ZwE2sAcBZI10NbqzOW8J8AxwfzzSRvCjXGrfCsb83VbgXRveaY5JobUiY6MDrlhahFd1x_3wm_d0_oBI4lIUgkMHagV8ZsnYVq_BxcaX1JRy3AtE5NHpyrwsKdE71FahVXgpi66XmzqGjcx3KqLsULxqMZiQ3wcRX-Q064cpjJyyCqhEQ36qcxutqamE6IaFLAr9tY3Pfa2V5bdkz4zpLDctv6qX-e3LucaGiM1e0fHXm3KzPj_k3TAiLgFZiW9uHGUGNBCTCfAJED6Ng4u9_C0ExwkR3brQjgKOY8LBVxPsLigvUYVVi2wY3JH1qk9H3dTk5TZWRVM60Xfla1Hqz0ddP--KvHec2p5XiQgU7bVtozBFvnCSVs3NIcS-FokhHwzzAU1Bd1Sw55C9JxOwbaJurdvHJNKlC8WPSzstHs2lYhTSeQOaM2X-6LOtAj9xTVUk-NU06KezNuThaZ1T3iyntWDFiGGsnmipso-xeK7E2RQUdEZmyfTniPcoLiqrWMmWFxpozo3-5qMTQ9qy5GaxvaTa3lGaZyhoQjx4R1Grs8b9Y2L6kXpc5CSX0GOS55VSwBUkqXIJOb-TFn_-E_ptjk3FEGpfGMpxP45LBytg5O-L6bom0jaRji3Kz8rbgaawgxw21FeFocdyqeSTFLz0W1uJUtGJuPjE_ooc0NjkqqYi4DsZtd22-0cR54G4MZJkzioTdNysW9EwU68ZlV2JER464laLXDWOBZN6nwLNf5fRt0kKHzhabLTRKRed7XGwUjx4eLz4NbUdXvI3iNEWTtGy-cIGQxT0UdgTbUG1C-AZXD-j4mPllkyqLWHHx3xR--Ib0zR3iIYBid6UfQ38C8GRs8O_dOn2FRR62zpmk0q3QRC2Lrh9tJYOoefJok-989Zhl4DJ88gz5MEpS4lF85WUaLCr0Splirg_LF5vhuoxXrZh8nSBV3Q3IjKJsY9yg-2ydQLE9Kz0SOm89sKGNuRDCSThxOvLk3JzFt-5hMwj-1grN6hejDONLDayKhpG2CSDEuZNElrounftzFDLxYmgZFXVmoLklYdiXna8yA6wUTM2iuH7ah5moZOB5CdWtTCHA-Uuci5VD3o3TP_O1I6187im4QYcxsomKktvtj-Js50ODxKWJ0OpJAJmEeLpHFvEyoYbjknODXonTTe3N2cy5JKoush3wvX1de-VI2i1yAI2yr7zTFSlq_O1NE31vmxc5DDgyLl3kMWL40wlcIPuUsQUIeFbek79v4K5UDtFtDQ7zvFVOZ_FTwe_4Nam3wwlnTcYtyzjj1eYWaiA5hjTu-rcbtEC5aG_ysxPNrJ7U1JjLbLm4oEofHnKayFi9Ce2E5-i2lf9RNgcK2pa2HCidALd3LTnIDuiQIIcxCUFAJ1Ud-WZJKT9al8HKJF2ytlrY33rwjBaxUxXvAg4AK2Wv2tncwAqhYNIFUX6DckbfKJ1VmMn0HS5U_gwJGa-DmCyCAKUM2b6OJfoO4hbnT_O3aI8dHdbArFQqXn3N7VCS41Rra9wwMeOTmIL44_88ZJPg9CZroaYCBJmzvByb9qkFmWU0LwwNPimyXxbitrv9YAKsSEgr5jiFLGrIoj4HrUaWOWKxnx50DCMfQ6IyAtBaQx1OnTbtHRyLYsv3fDWEjp_grs9vI8PaVqMmJbnuDVNQSBvjCWFFSr8wNyjc3PMAe2Pz9SSH9E3vRw9sAQUVtSo5Qum-jQqFKwJsBXofbGqs0CmFJWrgORuGOUSdxD8Wj_BCQiMv4-NcZlTTLAFRe33QyO1kGrzLwvuQTMOG0AxX2VLfxBqZHpgaTo0zN17vAqTOlrHqJGa_1Xi1gSrLscfDjqPeramZ8GnXu2uJ3dVCiKECe0GONR25g-emV7Xjs7DBivbknuSGlW3fOheM4S76hiuACNrIhYBRmsun2f_ERrJKK8BzwQzhMAnVGhgsQnX6yZcoDc87grqvgGPHSc0toZYJKXMIfiushXKPz5QHr8-DmIf46OC-ro_EfpLijC9yrqfF-e8QXV2OyzqgvWzCQE4FJhBsQ8xIFLfoeDJYQXAmCerPpjaZa1fic7ilnUksQc4P9eDivNO5FR9cX3vis2kdPYgOxjEkCuC3YGRgPCcNsdI-9nzCmyzV6uM3tOtqq_KANJarA78DQsZt5Ln-GaRpSqalSpzYA98Eob8aGIAfijXuqkE0qVSssGewKA7nLeDECKt_oy-SM5KaVUHiQKrHh_BDY_aimovZ4UJQ8OUyqwXXMvhSxkQgGMGmyZhW8hvHMs8TIGJYK2TIhcKosslhpYT4UWTyf6H4PMbzsBLZtgN-cLznqTGB0_CLOw5YDHziSZtnh1Oo_GvGgkZlKLryiqU2LQqMgeNoVsJRHwTCP-F2ZtwCIWpozdISrVWniN2dTRWxGFyylKmS3fvCPlUHjEDsWd8uUYSTTWS3U3vL7Y-cOch0mg9z8Emjj-rd37UHgMzWXdRe6rUwx6aieCKp_fM9xv1eLOmFFxU7zZpb6f-_6ZWS9aJ-obH3nsQ9OeIORHs-Zn5fHA4uZZ7txEiQh4t32SNNvXsgiNlS6EZ06g-2ZoyCjVtrjjRVX4AZaip9zSRAVAXHEfvGvTY5AvMDzG93EFbohbElA8zrC1SSYegdtFMECa1dD-ypITa1HfSvFvvvkeX7Mhp6kat7aSi3YrbotjKRVHH3HQhED9R-w68pOyyedmJ-c-8PszHFnk1KuF3podgV1EWyfUGKRajSC1V02xvYzoQNXaz92Cjw7dqMOtHDUZ1ueMLWn-26b-YeNJTN1lt2NO3_4wI8vvD0aoZtevGFsMebMwfmFhJeZYPwuHVlrLI3YYjUiEqqd8yrxPXsNgQLYevFuOjlT-6fdagsVOrPNYr9tn9tk0tWaY8Ke_axi3zaKco6WK8N00_kkO5054qgHOPiErNfEIWgvbW6xejBE0w2GjQRPx4fbM5u0LZ-P-3XGDcSgOnwXKLV6_dgdTGnwGtwkdmiJ-aYgPteaw41vIRoa9YuznyTNnt0cZtBCTdaQdFlxQ5Xr5itFHl8GD0eTwC8yKASNiYrY-M3gYjD99smkC61hjEvbw01kw8Y14ay6vtxxMVvUbZeHAeCAlaRYc9lWOH50FnoEwhMO2uBUgQEf1NskpCXwH-OQ7wOSVQGoXx4WzLG8c9YP56WDOtZVNFUfDvRT4MhsunmeJNG_MElrv0Ww2lovS4OR279w_jTLvLEX29PzTOLTDbyOIPAaWfDcHbeKQhM0enR0jlgT6tcKSScPLt1TPqLSq3QXDAj8tYRz8jW33zbCeFdZCuLHP25pZOpjuCuKbSXBXP1RRmkgvmVf1EL8GlO75EH64_J83wBU1pWrZgtRp_awz0jO18Qqp9HWpzHeg5BsWN-CqGH0AvKe71pmR-6VV9DmX0-0NY3lYu3hnZW2iBypf6j7DRucq-5C9-Sn3ZCNyVo1OLj1KAIG7Nl6Z7z5VeST5TlXV1ZI7FsOVCGKG_1lyFt1T0Mox5yPbsuA2E_dQ_tBLdqfCkyJ1M5JXUNur5ahd8tyAvTfoWgLVl4FJmgbtVnDArLZBTUDI6zO8CIHEtOZBASOvLIZMXCeFIODkQc0NFYFm7SBPUR8wupjki7l0quzIjriN0YUTRPiR7YFhqhnFd7EAwjclw3RAHHcCGH8pXvAv5hGilN8UCc9nQKhq5N6DiFs2DeTjamtprk8E84WNdoW72wsWwN9LVt-PxDGfgZx5cpPLTYUXG8HAeANf-sSOA9gqCzAzv68PMnb0rYgFPh3KijU6IpSD4l88IVhRVvwNLOulAOaJDnEGmI8X6TpduI7Km_6hGnpv4IQzrTvQakiAbIXm5Etw1Q8Z22XluQaodVcIic9lHyf0aQo1hxIc7KPRrnzfb9x4RGjdR3HxzCaCXicJ7TEX9aPOelfv93k0Mms4Aim4R5RMKz5Y5kdGw_dVd6Zwvn6AMHYInOErAPnfCiafxOX1s15J0ymC33FjPtnKmdBd9BxAfvBQXiG0FIS9IevyCWqs8NqJOVgv7w040Aa4oX1u5Jd5stN2EhZ-sA_wKxCLyR5s_ADEzvgkyynDxPVoBzmtLDHaJG_CL1BatwXt_0loQzpA1hM44BSmB6ruNDPVDqP3yCv2y3bKdbS5Ow9pBLz7Gt8ddhENS6gZeTaPVntULlLtL_MgEfE3b_12EZFTf20Yh5kUsIRhfxwGOpUwX7zEu71MIUarx0lAODpzQdfHPRLRfaVwPox_2zn-XHQeVkL93tFzFJGpIXnFDuEEYTH1REWVL3HVgld1bc8OQk937d__afMl8MkoDx97nN5vjMi4EmvOj6PDJTOB2HADCO7aPzI9N1bGBsBZ3Jut9vAF7adnZ_rK3cZBIXptJhwcsFH3EP-AQm2ZFcpZO5kOEnqctqZmN9j2ZoW0jMctHr6lsWf3j2jHLXXHQWEhGKMpzriyXBvxU5wN26Y7NmYv-H3TeLphgkINDlZE_5Y4_aNsyTiTO-936sg41h_2fdtuQnuuwNWnwGQHFlsxhLXbYz_M1nEcIx3p8PZQUOQoNS4PrK0boHXICniNr07jQNyf97FFUiOlWI6hXM_BXhojUKOu9xM1U56zqgetcKlVxv0plW459Xg6fDhB7WjmMWC7nCdWydablbANiIRAooOQmV6kOPpggL5iYNc8e5CC6-SRLWgOxI55rrpZHvykhXFfSTk9EjSnVybTu9K3bpFCMGJ6vBXqUmfI5rP72dc8BxRvpxg-I_2BcjJkpSpfglagStqYsjUvOXWontzAo2sgIxlZxIKDdJ21wPfgBfGckrJK-hI_fCZ96CxolRcq1d1hiw9URz2puE5Ip4HNAbS2HIfGLXhXAtteHGAEQkqdwnWNtKtb5DLskt3ObvL-S6c_WMKoV7MQQjERz1BPtF9FCsBl59gskxo787XF6LqWsBT-1QV978ybRh1S3O_INtOGOea7wM0KfeglyRb8iA2CdcGiOK9pQiisKS2mo43W-LkBFkU1rCUw-tsud-vrnGwJ1MqMp2I2fDM1zio8WTQ4xtsJKaEQqEi_8S_x2v8DXXccGaTwXcmV66ruVFxXDrQmOa9pEA3N_6v5YU9HDzP7hF7rZ8_zxSdi0VEqg-7buNVhy459mxQ-eUYU1M5NF86OwVdnwMcSELlD-6dz_GE2HJM12G0T5mPfLaVRIBXDxbJMIcgPy56RWT0UopWoGleGm8NwKtxPR2_LaHP_xPL4Hg3JvAEitSOfYYIw6JmC5Pj7xyTyY5xGk6VSkV3kBjibmktGwQ1-qp0gq1bxJiKuEoy6r-Wnd3iiVKw9AFBRFF0DW8qIR_qCRIvR68X5CCavhX7giAJrXMYV1rAdodjNYZ5kSrQwORZqEln1uxKsvc_3AnuyuuMzWnDK299SVVOEELlpRia8cLTkdKhmwsNk6GDxa92M37S5qu1VNZTNctNF5Nwa0kwDuQuYrz5MBe7niZgiUpWHFth_TANf0QxArTC4Vkqg4PQ3S34NQ5zlKpB8khDgdlvGdIETIpvYEFd5YXvK81TypHYbkJ8XvItXVtJZkpceZB8714YUrd928eU_aeia0BRDBGT4ycyqu6eUCMuV3FrEbhQm33f-y5DEshh_GBTyd-DPp6QwF7DRWBUTl1UEXXLU7JFFgb1G6fLEYjTDbnPfuTYROfDgCV0VHydIGJ-EQdKKm_lgThNuiQxZCa_5Obf4dnyk3SUirNjtAXidSqgUbFFbCoqbU8NdKHHJJlXVDd74GyqX3OuCzcrpAlh1y93PUQJ4xk7ZBrThyDPyZ0yQm8HcHwHAfSD-nQzAutiAmgmBPsm-8xZYAjWk-g85mGjrWZTnaSXrpBuXKrRW9FORejkKfMeelfOlmrHgfz0vr_xTrrHxtE8CVqwf_VZs1R9KJLtxuf_Dk_eDLQsdpkSIkxFgyhH_HC88LORk02PUTOID3VZoM1nmG70L8MsXtg4KFHOyv0KRCfkid39vXXHorp5xjOCuB1uOyXi-_J_VK0DxRvF--qSJ6aYWLLVArsqvM6-H7_uVAiFlhAxQdQu1Zbi9x0x8rA0vpnjOxDqN6dARH4TPdkAUGxflDnRW0nljXS4Jc7xeIo5N9IgD43wSwDnpBvzLbRlR2_Clo_uzCek6dCj1_bbpHTohqynLxTDuzualmFFGOv0h5aWbvYph8dqTqssgKK3ODwXRO8c81Z0A4ZFshfS15Lrm-RuEo0dEDzqwyw5wc8HVhMHM2NfBAiDwY0yBC0rxlrj68wQm_NMg8Wdbv06q-4N98SjQP7-46OPj3-5nD6d5zznpp5w0oQI9yQONhCtXAoZQhiW45YTZ-0RYNLvh6Q-PIaiHDmhDUsYtDBNJ2LN37E4bpv44p58UpCfVaerZN-yDpRusMRTQ6bzWClhMCi6PFZY71ZWd_LCeVWu2eQGDQmsxgJLqnewZhzq82lzMPgw85xeyhYbTxzIOSwPrU49dmt7zsDXIKi8S1wTIpJZndbja_BTmvUFNZdaWaLKi_P8yEWapgzKNNOD9R5WKvG19x1PlkOytl0WBlL7xo0Uw5QzZ3nZYfDwXiJ6A_VjS2Fj9NdA3mh8jeR-MyBJR17cDYCA9VSkdaeVwYYHH4ShLYdCAItkvAthqh9s-7V04gotgn58AQg6Uj_JFxdv4TcBzxIdMxD-Okm4VRpu2N8eQRd-LT2VCJ-6GWvsWsetGGT8S1BbPhw-QHwqqtExT8wH0JKwYqje4mIA_C48T9Q6ujhedVcM9HIX1UuouBvaVSQa6Z5mjZyWId5i5FFc8OqOMPskeeA3lstZwgI1l6FrBwdDT4ILbtLdOJETygE4y4oUh_8d-aWNUpYToVlW5mpQyirz3lpMTmQDpG6jifue-OmDSXw16-OtrpuQEIT_UeB3SO3rbnW9JjNtyJR0_fvYNe5AKfMzhW0P7UBY4nAqkBa1GtkZYVqePWDTxA-5dgJPS4KC_fx4us10IJ9GL4cpsE2wLyQz-2k7vscRgVN3Z7tTC47fz6C5-HPoYKvebej1I-uAvxVJv9YqUReQjz6BP3-WZ0PSW_Y88c9h3PbVaMQWc5YyiM8zybF_HNxoZ8aT4lPCmXutaSv3J9mLI-hh-bHhbqyuvjk1jKVb1UJDNXJjWjwsiTvRD_FyKqJiSt-a9BbBl5uugb_jW4on-iQf_1fzPYta-i_zhHw4CiAdGu5ie7hMpnBgtHZBYlkSPMYkzhA1ZLQeRKudq2tl5SY8kcYMxH2dGyZiK-5H6RavxR8mzz81UIWYTZAJUYIfKbXh8nRJyricA9R4kgXF0p0-phvUl5G67ifHCuXShXSLB0oW2Yl5cyhvOT9bRtzbsWq4uIuyd7BXZ3M9NdstHJz9dHvfK1oahcVh5xHAVkz1MMgALYiRzDgTxBM0UP4oysVxXE4OzlePELzlSLZaur2HRe55YgYQvrLrtK0XmT7S4QdnQzUeJnpdJlpWcgVp6KSMB6wtZxvLCfFZthgXkI_tksAQMMUUymU72ZSOogfhxac2DIfMDPau3BrCEVRrrwhyZUQHc0xdrkJ24rWtyKO6vTM0n5dmtb0RY9RVqsb4jsQhlbiWYx9MNCbSpHpPxChuM3atVPs7ubsVDxPw7Iu1wD-BGszQpv7bObjtEKW6WIiplTxD1cHX5Gviv9Cmr5of4Aw7K5hV-GwZpgngpoJRy8SLqBpbnjnQ5Ek2pBdoUELZUxYNWuGReh6j8caMgEsYDflaGA1ZWZh_eAExijm2mDtP_PPOnY0tLeE2teQgcmXLbmb-ZlQKYMGGhO-MkQ1Mb8JzcGy8nYAHFeNT1KAXo08YPdn5gUnSV3Ruku3Wxy_n2-lmxSQv5wg-Rqj3m3rWUK6rsi4G0msw2GLJCCbg8zgxa6H1mfatcRDkooM0ROW-udZOVs-X8uJczWCkhsrmmysKPxxFQLw09UxPa6l7fHc38eSojR1v6HToEiZwet93ALuEzSLieB9k2z9f27LuoSRh_b6uVI_zjXbuq-WeJ44Yfn1cDwPaoG0tzBnLZ1QXPVWsXqnlyk7dLcUP6qHrY9BPVfppA84Ex1sTz_4IIs6QJ600mBH98XvnT8GITFV44a5DUf0BCMmdY-Ddhtv3Kzuyt0YnWsaPwVtgNi4HBkSRfV9ZELicdj3dVtQ113k1U87v2NGEaC4-n-hJcDNORFGpOKvLuIWxruipOEfSSCuFi8sIuOp7aAz3W5oa1wgnY56YPgnNFujFrX1Qe3IymNOZkwAaJSkr4o5qcFgP5E62xjOdPwp-8mEqY_BX2UbOile-QZcwZfUer08yxPQUXVmC8rS1Dv-oMc7JZspYLD8iQQRr3wpCpb2qpMThHVOGLRfrASsc0TKIWXmLokE29HJSBDaoz5fmfAB5bkoo5PbUbqVdZeC3svA63oFt8FDmrZqpq-83oF1xTIBLHCf7xlJYPOGmE-hMCcpNAifYhYuRkt_kO2wWefQ1BerA-Hy_XLsyjre0FUu4JRkBnqxrW0zKL4I4wkkK48iun9dxx8eJg0y_WeSl_geMEjzGTFMb4iLpE_LZYRCFZIq-d3BbwlL4KO0iiB9GAxkWTKwfI3BXcuSqSbAYB4DA0Qbr55tD-2tpT4s2Ok8w9HFpOF2Ho2nyH_5kZclyF_6k-NALxFXu7RMUt5E77In9aefuhFriroOCd-URJBIgHwyRe-urBhPgKjjgf4KjC8LBTuZDo-zubz_CsFlYifWuQOPYrerxxi6UjHWLx5npCKgwvXTyQknBj4T8ZXI6LlinFvjAagNDk1hYZSjgh5JPtTHQylZ6Xgw7itUD2S-WMdnkrfJhZJDWR2pXaqMok84QcbUV9w0hZHAd9YR_iySwF3sllpXmDdXk-cT3nOQLZuIDRFBwyzze_1Os6BMCu78cPxCk9y4v8KIsWLZ-_L8ZIE1_yJIisCJNb5UaCqPAhI3S0Ady_O0YD_HzdszAQd5NOkyMUvPomhk4LoeZuL2BWb3HPomW34lArQmClHaCkh029WlxEIHqYaFKXopi74ZWEC3HlYnfYgrhJM34jkRMsH5DcIUqTDNmBBneUWINb_YIuT1nVx0noP7j3aDw1Hbchjs-oLmo_VQZzWl7gBIs0842ceoNQ3I6-6T-SdPF27i570bicvGGP4LRETTDB6A-sUUPl1JvN1ixeGW6kTWLjLo6g6Yt-7zyW_2q66_G62_qwKLaT2lUkwEiXK6skD40tAU9-BfT_uG8RYc-FbP1BA-vhC2ghUlcJTSOzgNkss5nLsxv9qVvF0p58BazCWLfbphwF3jZYWaGSVju3IqS-1fq7gM4WBff7CgSFlpu8s4nQS8KXyD3VfE9ExhXykFPkE0cfPaauoKw4aHHlwYxIWzg1pECdtPDPxX-7jhqWLPKAmY1sC1jHZNpE-7JL-Mm6OJV2QuUfE_PbW1LdMkgKdTX6H20rvaxQWd4LR2bFcZjvj_-M0g2D8hwO-kAXPgbZDSu3MBbrVTqLqk8QHaSZtAo1WgewvK8Q5X3fyqGa_EJs_BTaXjx3XClfi7C_F7DcusCSoGKQJ7K4iNE_8_ELL_T-lm11zrRMPhDZ-CsnvsJq0AjzzNPbeyS4dejJJ-FvbvB8J6Ptkrz4NpMza6T784Aue5s8aEMw53kJnyykAzjmT_GTxvbBGfc-iwzv5qrA1FD7muCNco5OiLSHhXnC-zXJCAwl8134r-O5OsvWkNPa7hBw4bFgzu8Nv0KWbh5mtubOfOfmiN-nZkgchSMp-4MxtkXwe-nE5WwnA40GuY6yxZA9JBJWYIReSZuSNf8jtNsgzQcDoKH0MGzLVAcE2JFHFv3CWcwKM8YYkzBNl6oM4jMSWFy1_dg-v3uGXsoQTWWRNyUTJBl1VxTXHNIcomrYyTi8qDkuAEBSFWY98pKtF0uouh9tI3iCW110_cV3NZC55JFu3Ybxl6QCt8V8GHj2bg6FvUC4KuqWFzc-7almMYnGSU9ZxC1pfvdmlpbBBDlhAL4o-4PJTLTT12Xjsp4WprFWNhWg1sejvCKF_QLTvbeD1zfeYxWlReR6CodSuDZUngsjEyIdMtNh6zy5RiCavOiQsn2Zjivx6_o26fwN8NC01_vgA41s9iYnEL84yq4upbqSCnubksSSRcSpFa_kM9qgsV9-yfPZIXGvI8h19XhXQE0jlAoTz0kqEB46oVpwzKhu6Rg_LqBp7oL0vbtCAfDyVdPVFxHxzYNgWyVitQn1RI4ccxxCkVezq5y0f_nj8KkX3LosnLscIKeKMtVSoogpI8zHvZbSFcgLoXPr2YQIJKcuUQsK3XpFrrL8aHksvo-RSL46IaY89ZPbdNUjVe79AH8ZzHCvb_FUKoWj7QveT-by26kPDyMRJNVYNiQD2mq-OSyaO-hYKcvwW7yg1kDsxEDAg1767rFr_YRBx2JtBO4P6AicHF7XddopRu1wh2H0_9lVMTaHhFY5c5alHSo7zijc8Nl5IlJlMv2B5S8XS6ryXjzlVNjUxoNyorT90WMq-rA21MqLCso4-Uy5UVPffjJXKl-aCTB_AafjXB4_Z9q7Rv5O89LKiziWQhS2N0nuWWWstLu5Nfn1On5SvpVVPBB-Op5MZpNWf4rGd-vvUQKRLy5FmN9S5m_NRXiuEa0pCuNDDP_va5LcP5-9S47XT2yY6rAXucodMeveyujz9mPJK5_f815s8cRSZLuCnyZPEhEPHqGtcl7whFVwq9m4zTb3wPhK4tqRWoZ6U6rEzJ0LJja8lD5JZuRsFcTq1MLuwVb5MLCMrC6CdM368Y8VSiLOqbghmZ5DwRvSqrsY-Axn4CCAySirMua8rC2X3YrvDUK1n2vbhO7jC71BGV4bp3eFR8XXlEv4rYZR0q5JVKtOBbzf3h8KGELY7nf4Y-ZO0fFlHRhGk0cKyM_God0XT07-KbMfXzG4h1ouAkMXFD1oCYmfZnsiyYoYKElzDlHzhadOrbnOowp9Nn00JCM25iu479yOV8RM6-DwZUCDWrhbrEXAwz_nvTbH_p4hX_7Rlzxf7n35Ygzo-qeRQcZM9bRls_KYVJwJ9ppJ2ZG6s2GE_0vnfR1eO5JPgZPS9dQFDeaDRCA1pRieTEl2yj9-zvppH12zItNTYpY_AZjzm3ErUHI4bnnCeXSZJ8kwTr9Fo2cAJM8MIXg1y84VCdZG2naVMCYFl15ndz2NdqqQqeTlxjA6uqi8XAZCCm_JnCkLM23jpSRkziaURG6Pa0l38lck8deFR1jU9kB35nFAAF40Vm2fqqNWDJ71jdy0zQu0KXitfgLDgwfCG3RVsBDx5-ZSgAuo6I2VISyifrBQRPqoglCbg6xHPE4v7OWKSSHzs8Fp6xqgrIWnmiXI_TNtDAzHQkPxqG8BcK8PZRhtnmyOzzdTEEhvdRFnBZ7bzgA-DtsVWkKy0ozP2N7xDMmgRH5HpAsa1zN7JqhZynjht77_dnLoHDVHr7iqN14gjOWhMZZihaUEYL6npxCqCMy9dMDP_VsfFySuFlXRdYXw_1J0hTYrTaU5AhoV0nuxIkKDhQjMlptLLE0S3rzRJ4kdek2UTycPuOhCRNsuS1_sMOSeZ_G7JxXTW2LTHt84ou62cOKwXaaiChi0EE4ny55g9WSHJwN6mmBedoUZZ9voXH_KGBsxMngFehdKuAPNTU4LksTXNuf7mqZCX8rH_UjdKh3GALV9bdU86yB_JXy3qDpDeev2e79aTl3Zn5wli_X3VCuFaWI07VBDFl99K3agGfyqVPvpeif3d_vckpjyGtAHls1C5yZA2h96HVonRSfw_QBkwT42f4nYTDIMRbeXuNmdYHbrPSmyOF26W4AYCWME39MgiKV_RCAEk38GI2SYcVcKFfIw13JrnHLhYzfCSlnRVZlNaTa6fe6pFGd0S0pY-nplPENdgaVewYkMcE2SgFwtBdwsk6rjHHOLpcd9jXjfPqaDtXP0VtrBwinUGt6irZ30OF7MKFExUCuxM7e9v-udQqrKDAToVBP-0McD0Th7OT-W4FWcdP-Re1yUU9VHasas0cbmJs_88Ut23OYpxbL_2WapexDZov8V8gaS3mbamzbV9yAcaVwsf1IA-VX2iuOQlu2DD09nZWxaLUYj6-_mejeVQ72FzUPJgVYj8tL3u_PmHHt0LrYh0xFsmqyVoMbT8d4o28CVsv_qB4vyA2VD0mBQdL7klrKmeslnrgUvEavN4DF3iXsy7_u887YyidIqUAlFV6QaJKiDMVbe2IZwp90vY9Ww_Ca6zVD-kl4g1dj78MC9dmlM7ytGNAsIc-QaV2QjdWiWbhxCZX32Dh8id-cPK2T8kZSY57MkbklI_WtijddaJFkUSgu6k1QxpIjBJczgykd-MARLXc6JLnj1EIcIsKQHUiZksN4yP7zQJ8TkUixtpls2zYeum3Yd674U86KumB624Rh5c9yu9wt7rQP0ucwjK6TA_y_szSZPR6nLLlMpUtvhNaxsxNV--72uoWqQLUNT6h5aY95wbLViJDIiMMtLjQbacJI6eauW9ROEXdaafoU5YWxga-B06V2AOr9s3nZTBemq2jxL84vv7VvU4x9-NI1tEYw78cEYsZ1OZ7hy80d2R0jPygl6Qtd3oa3luz-_sYb2aNDwYQkJD5GwNK1r-CzFM23ASldnse0VIY_ikUlRj7ULir_ts4eS4xhgTRdh6u-ZfiT92MrIb9QlQP3bx7wFfV28VeZFDxrSEL2rsFC_h0SfwRnomNRhapfZ8o9TcSH6ERssApLRiz1fD6SJ8n4WcSUMADzODIqc2eso2LC5tGee1sGjrQe_BDecIsYGUrBKyqimPLrh6csUtY9Xx0isjFfd2vnMtCZdKEGyS_nL1U3zaCNRZ3rX15h6rRR0ZF_879OkTelWcIq-P5HWDaYQxK4aT0rukyhzzrCPPM6AW7gV4lf__KD61LVoWwPO52lZU-RK3fUDNa-R2gv9v0q21OPz6tlFtrNt3MkL_rNLn4LmM8BnQeoyLGH1z7pfHO1-Sfx3n9CtpQPcpYjilLB7Y2bF1dBAYbapKSv7n16y4_XefIHr1fbHMsIfeZsqx6Vv52YcQBtu_IpmObeHioTPVdsbV857GrB_hgXBfCgRmsO8dQaVeZShChcCNal_ukc9__2uaghqSGK_wAU9dLdBSB7OyCMbIczg6ZPVQMJstn2YMeUyn5cLxxTBVOfFQS7MnOj6cKoQD0k2-MNFmmLHsOPX41peskB5-7KyQe-lV7aVWoe2f83qN7_LN7Un0TeMuKAp_i8JwY60dDtkUmEAvAcCy_4bMjiW1k_E96Ow-Y1BBW1bW6c0K1KE_8nGc4OXW74LLSECyA00thgy0O0YNphB_nq7Gyrrg-zWt3cACW_4mCdwKFtn_3M6c4AYqS-4u_Sogp-VDvCVNOp0cMnurgnf0oHS_FV2xs7XpT4K8ddEKDAeHmCsm1FafIQxBgUysMBcm0Lc4xKFrbEPuwP-kP4Xq2U91fDBecRRUSgE5JbiSbhsdRbveRn1jCek-G2M9DnmdCCeifBy3GniJ3wqAMmu_GWB4zlhdeen4HvdFji7Uc4e_AKoQFgY1D_c9U1A2Ot26CKVCMrqTm9nKecYASt0hYBhN-49V9LHx9YsYmkVBr2hMvHMu11KC8ZjczL0qyRVH1OFX4owvwiCQDp3y4WddpD7uhQ_Dv2md5cI-2iKzxvIvTqBrPwmN5QXI-ordBrQgq71IYVD_nF82EfBAR4Y1HXyo8hETxQ0FpArrH3aQMe7TvZ-BZ0J7lYDZm_Rn6dyUBLdJDOF4fHLZ9_30uQL-hBsNQ5TR7m2Bf_l34ftj662GuW2MN4H-SfVnCcxijQmaxRIcwBviXCn19lc682EK9FcnOL_NHF1dRCqwUfgzj5GqH0CBvIuFvnqZFRouj0rXlmlnH4iFOPs_DCMzggayqIpheEyEY5eykdTTswAYLkgPRgQfhD0hrOaRd3tc5W-eZR0aC_9Jk3JzfKMxoh8f7iVRNhNSxGZtXQvnYRU8PYEuAA_UFV4YqXrSw3UWW0hKUwdoKZ-9vuENzjxFLqopzd07QTMmbstaXalfWPRKBTVCZnEGJ55eq0St7VTRjXUwdEwQjOBc433G0BypiuuQCVDnqsRJ8cHeSyIzfG2ELxOghW_34R7BrgcmaR-UVfE0_xTLu6F9EC7av1YKTs2ewJT3rKpz1XxTHvLlA6LfEOBzwr4GdOmLkEfoY4uuvzfTVUFX3xfgIPqJfQ9KCxe0d_aox-Bnosq5pfpVvrsx9b_YgvobgtDcQqoyVkdMLy2LedUD_WTSEgMTAyiYbu7DmwI8BUSYOcHBY9ihtsbDH0qbaXPOFlMcTPZjUkyL9yoRK1qc1bSYbSMLkNLfh22eapQy-C57Dp41A46hFUxVy_lvDPhBtRU8aMAQmc6l5hog8jQ8__9zaJNfYjZelskWNRo-LaS2yyQcTUM0lvVJmA56B82rb2E3CQFeZqqGZwd_LplRZJjuHck94Y-CS9qCGW1u72tWZbzEyJT94stilfbSb9XCZ0qlZ64tvrXqsIMzJt3XRIkdTmTqG65-lmvihSISfiAAwHs4f9kl3fqxyUMiQ9WjHft0MCHuqLIZvSER_yv31hhP9wYborVQ8lm_zFJDzSs3ePYC1nsappWqGQjDcRCP4Vy18oGlib3NIXSzmQyDoM5UJe-QbZgd50npxZFxyF2mblhi6kKa005DHSlZHuJW9toSsrptIZuVFgxEIBXGLlDV5Vnc9uoBONx7LhpG5c9rMeVpGSgdoxZpUQ7Z1oAIqcRiLhNcooBEH2umdGCVaVcLj-AB543XVjAxaDn9_iWBU6zMlHde07UsxraFy38DB9ANBPb_FDv94K8Rq5V9gd9yKU5sT-D2ix-_cU7GfI8AZNioOC0khgrgL3NoycRmV5Uy8iDMOsevOIe-oc22dFCV5qzYU2YNVE27yDFPXIP-z2mInHt13SGXVdDYrAdfXCH5Wwunf7OfzBAS6hWdyeN40-cxpVG9Mu4nfGhKXsAQ4967qTBeThXhT7ycrXLgbDTszM7owTH5ATaAFKkb3TFOLLUF2KGg-CEcIiOXKn05zhA_w7lcmOsPTFIt1SOFctUdeaKj4h8Z5LyC87S0KyaXboVnhu_PIuISimd7y441L7titFXUw5XChkyZ26NabHrn9VtNsMuiGe9WxDsPNOuf-S0AkE-WYwETnyd1KaSB4K8tdiCz28pbuZN9sw_Ou8XQeWwq2LjgUD47mQ7rp0Kk2njwKqh6_iELIHpRMtg6hBpaeSLciMbI7sugwQT0eJUBF1dQOMrGGMmftxt6-CZiIJ4WEOD38G1HxNHCV7UI_v5mTx2xeFYLM3imWtxZiGhpXRsTf72UL3UO9Z38JvA4b45FgXBjXnESJpOMb78i8zDgiaEgXP5Wk89V0ibjlbTEToJE6Zc4C-8EdqXcVHn-wLmjj7N6KopY8eY_8X2ud8Nsv2ot3mHe-II6a7X9UpaDMOqWjf0_c3aZJan5nspzVm-DJqFuSvIeddQ-4YP-QIhVAmaIsFBKvGLeTF7kWOUO7unGYf1VnlMffeM3JABMsSaiGj3Q5FvTI9EZrIoxwSdOdyJfokMrgNBjvYDOSdtjChlJCO2TB6EV7u6PUQDV817ZuDnPv37Cp5k41iYu4p_PhV_FmFNsIuHCHaSVnuMavy6_uAT7AUZBrEGpS90WLr4J1rSOpVuPDbYRyCux4aEhAcS5-IRKPpznE8UQw-LR4p_Y4dSNJUQsh1CZHjOpyOl0KW9FHNaUxfyVd8i8V46SOlWpfPWBeySmkwjZhezka-0RCZzEAikUNcjnyMJxINMzhESDgYQ0V2GUB2Qiyfdbk4AoTGu5ogWvXxxJ50GHY_wsGy0sVfl3RjhV8A9HY0-MVGR-ZjSXizc1tnCuUio2AiIBL4SpNiXCarda_GtjNPgQY4a8ky7YxK8qG-C9LroA-SMOIEQvEIh-3QYGeWhH6MiQzipG3-NQsfqKGSyQXa1w4epvDSukSSZmy_RGF9u-VP5WpGsgCKmEnZksp_27iyeCanE-yfsZR_oyhLpdQNk2maeQQCSHevZhzB5qnI6_-ann5o7ckI418nxXzDR7OPeml7jlPVOYZBoKV8u8KUBTw9533Hejlmf6zauSH6LWL8h4ip3tpFQl3LC-exJ2vaLNmMEVYH1AvIVm5vfDPh3eFmjmpwIJkPUyFhBk1pwtZsIh9jIVg2IxscUp0rgPToO8OMee7eyg5Oc_L7sIrpG9OMfVbQfuMrde8rJHMYHgpzA2rS4--F_a9ycw_EeD8hxGOzMtWEQ_gPVg8ks61FjQCLlB1A0QA0rAhz0EtKY0q8LFCSOgFtl_GW0HfiWa1wa8JNSC_qrRJJm1OyHuV2VAf0S5GxLldkvyrRnKvcNpz0T-6giU3oe6G5AwByaBhj6cblHGAEQApKIeFMASsrj1_2yVotIUFPQWQkHxgfDcBmi9vAXgTL5vU8VpJ2j_xYJA3WxBbRMliThWYDCmf7U-J_L33qRID2bf0fuR-TDXrxE4AP97oS06-4eqTQO_hLKbLoRBtBsbZ4SUTTRaO8DSGBA3t5iiHxVaKPE6xTKKKDLQVNSnKI9LkNAkV9srPtdznhMAh_OnTPEv9VQtpPxFmbCovkP_XrQVrTc7CvObVfCanvf8t5TZ_-YoseGdvzTfgDxHwQL2WbDmowCCKw8YRRGmPxkVkw0kSLfp87AKctGmw8Y5oL2BGtP8gCo9nZ-9QfBCKqied5Oi2yX4RhulGAs5718diLCi2_7kaKcMKyQsryDkntQloYNtqYnTqPFxYjP8OzHlBMHSgO2sWtlaNR6Y8HyOhVN73akx56eYbetdPUyK4eniBBqv3JsdVZiEYPwex5357Ur5a-fc5lWT5YqCFWYWQCJ8rVjmWPVsed3D3ZR1tk1mhYP8DAdVg71Zat8zQaXNbs5M2UA5ffWX-25fcFy49vcKrSYY81WbLFtaUeDiXcV_EXx2bGY4kfOUia-akZOGu67hb_4o91jvkhLhz9QrQdI8Zl7lFNtQsnx99rzcxLZ2CkCwvyBLhXd6E8URPFX8N-Pl1hA37bNdF_8RzHbq9F9-IIen5riHhmqJncpaWv09DZx29funLZ2zEsGDLqhhaq96FNChkBmWEiR0svD1PUO1C1JG7TUoX4m4DYjNBpsGMW6VxvFAwpd01svqr1YHWBwHv37Iy7A3rUmqQ3eU3u9WC081RaA00uDTqFpm6Dp_Q8L857Evnky4HXUVzZjhnOj588xgUZh_G7Pp3m5rDSiedEhv5Hy_IMsmXbWccGFdd6p-0sRcH2s0juYNFVZXRNE_DFCWccpHHwE-n5qI-8qYHAMWSDqgwuYWHMX_IStJWVekVU9eIB8JnrzGb8dXxPMB0u4F1DiJtGgmLImjnHuuPC-y0omOa3h5ht3D5zf91oVty5SL9p2wQBGle9BbmMCNF-qR4GOus14nO5RP5Se_PrD2lscWMg6PwME3-0xIX0IzEiqIwlJSyhsCaFjp1FGDUJUBTgKMWGo-jfijeVfJ-3kqJnd22jBLWUha9skSBt01PQnLSPFMkw4qsCDqz6IsmUHICWgN2v1wf7LaLbZPclRg5xy3LpgDgAZifvLWZmzAm1VCfivxjhxOldtoIUJiPtHTtiys37M6aT0ISaT_J3omWlk4_JZEXIjwVnFjlSfb32yCMGMgyKxCpUa-GRPjCCFK9sqFAWHXhPt-9XbbfvlB2j5EV4lDUQHlkcCQbqguQePnxA0qZtiRTHkI06RyfuCmqf_xlaW94JA65wR_6JR-3e_Xa9P-do_7b7yIYisShip-n4562NgzoQLhvbAof0Ki4NfY3yrKZRH4FSERT2DtHkUomgV8emqxPBowA9B_OBjuVDSJeQshOf7_8ZMyWagTOFImkU3sxbHhYaC5m7CvL4ADLjYZhV2hLQueiLT3UB5BlZVGDd1WIdZj0C-tgI74H57QjoFy_BVdoRO69OgXZ7B8m_Yz2PQM05K2RuAp8vQmvWyRgWvz-X-drVFAyvcwQ0_Kz5aSDXGOpMcnSS0t3wJb-A8C3IIYRgZpKXIHzRFHzQmiqaM6skGW9FcanPq5o8T62Sn23ZgH6-xaOe9mSYTJ33zTJFIVEGI_iBeZxY5d-1NPGawrIvM-BOLo_kNJ1UCLVCLtfzXh6YvouU8E8bEGFO3vQympodXbAzKcFCHcfCd0cj3ImEIq-NTLpuW8nMR2tgnfTtcy8nJKIoi-atJZJGgiAuOD2pe-x5-L85LBCT-G4A-429wn0HJ2mBP7RwkmV1Abm6TDkhdbT4SW2SZ3hQfn4XiNyPoacS0eY2gmcNOiovVx0T7pS2NZhwP6_FtiLxOXN2Rr8HD7dMb6iEfa39g8u-9A07VYOsUYJ1uqsHIratYxapDUDyJ-2oP_SL9xeVV1o7AX_TzWZ2cCub8SOqdOcH9S_ZuIfZuHU7554YcnfR4G9s48N3Dlcg1A7iP9TJjli8nxUmKOY4AHDbQN8nRdHW04pMg7wND95u1QhcF_Kzetvn72btfNoV4UPbJhhdKCjN377VMs6pGFh3UKs-WP8FxnX5Lrs-w0kU4vTDrMiB3ZkkOK1yL7v3y_BNEOP7UevEhG7DK1ylIFIuXUypn_3rzDKuAFGnha-V8Rv8N7Jwl6v_oJ60jM5R_ySMx-1o0KSQ7kLTIs53XpRtR5Y2QmiQkL-098N2W04SguuBHWzuGsERkoLjLkGUfVyqSdMqMgu1VJiKreoF_HB-azUaMSMLVhXh3gMRO-5yjEQCnnpG75BaUCY_JCMlSYzdsXQInFY0EYmHSm2HFLfD_b-sLTUoyuHrHKU3AOQ3le9wkgcHamYycdqOVMIGeIHoVFSJgELPC9rVntsi3cGvhp7ccHdxJi-dwWhS-AMeoSE8lwHd8LlAJRrUL4Vw_ILGpAJX_CzLojiurrASBe9ANOwD9WsQG2xHQmQm2mltvxlA9e4NOSNDa4CnaaP5JnwYwgauPEsYHt7hh1FDGWKTxJSsimNdgI1susl7DyjD1A6-KRLeG69TWszk2YcBirflcU4MXXs-45ZYVdbYXQAbIJFvXg9D--mJYrd76aBBciSBAjBCuzAwb1bFl5EDMvEd214zRehqhr0vVSF7K_nUivtr4k_6lliTnJ84b-0NBYICQHRY807s44sNqzV-didRkTCQezqg0d6fm_GvP_0yM-9WMbnNiJmsfcudv_PfuQ4s87XdYDEFYKDdGzC6kC5wns8_UOEjgjgWKE0Qg5GNXy941W5-6hw870OD6GKArlQ58xwBpzGzHM_qDwzdwZSoqsL6v2cpJSk-sL4tlbe-1OikTnajnEPcByNKhBQCDooh5gnjCYNWFh6dGJpZoQtjBIXWncIMN7qIIPV_0V1LotLRRJFmHYiT4aKLe1ggov70OtXfamrJcwyOC0QEA26i2EdJw4Car-Q6riBVdJECAxnziC-xUjc2S1g2SczXC7MhksHjXUA23zzZ-ivfwPOEuMgpt6mJyZbhdd9b74iaIwQSDK3oPhExc6VNRv1pZvRf5Y_mNYFPKqtiWUECz1a8n3h-t3WDw_Z-z0ND2U9XmqDLJPeJ5wVgg02jsp2l18t7OwQQJhffVWbnzEbF_yb5IFTOAPWKyfF3J86vQYnKwr8OJ39M4JqPFy-H23q50VuV153tuTE9K2W7SLa7a_ZB-l4OfI_fb2NYvKJbXHdKsPId4Xu7bv4u9ywMDtX-ilOp0LRWsXud-VkhEQ5mfA25qKwBru1i3ft4QU81Zc5GcIg9odBPjHC1H82erJT5uM3H7pvfXkgmFNIRP3y64R0dOi_UVFn-m5KYM8MQU8L--cftM5lLignkwhkvMe0HCZikDNbnouuVEZK8s21ev0wyw_S3y_CePD0cpgRT6pi_u-iO37rZTOp1H3EcbEgUU_4Ftdpp0jJuRDvVNbaU0VVM-vbB5xjaYSs5mH5eLE0vyROfcPKjrVMkCYVhxyEw0VROm3lf9IErwuvHvTSwPHds9hYXVpxQBLrTxFmC5cWZP7pJXTjf4nKKtce6iH-MtDINsBPHEg9yl33A9oCJcDeWZBBcEYuNvLqk2PYnOleQHF9d-dFlfp4Pe_fj0iO2dnN3MLNUttpE1DcPUINvbj16b4gPdv4nv8TOXDGIbCXC5Ik2NkgoWDiPRANaMkyAS_vKL_GRnlEVnlfHwHtk4TDYFPwGa3JKJmxVNjbUxOVJlvAdxdNznZoFPQaTFF24J9lSMxeG2nCQglN8b0qonqcBMw0NsYKVuhGIdn3qeQmCNsmR7QK90WGRMWjMdJz6K9JYuZcpYydGB456mSruuMEOpKmpvXLJXFYfzKQdAhRkmSU1KEX8Q-FJpImb40YktUHfyggTedIVP5kv7NDKh--nWR1kEVH8eYEiPc-cH2gPjNfkQe_3HKArBlWWCwK-738N5R26-UuQQHlcSaYorxib4CPYXoPJpr1W_4j7soZtii0suv2rnDLEl-5pwMZoPcDQCQepF9gHcfGrQeT2s0s_kJdIhyX_APAOP8X2_4Xq441mhUwLTVqHOHv-ECigoZg_IqX5wG8__Zc8JcZ_q-wZZi5IBCgt7wRMgRTVQycWL5E42ge8RB4t1vJp_k3PNQGUToDU9OY1PSji7dPF834KcHuPTg6YWs1yD_DtZYErNN1j0_0yNUVWol5DUdeaB2l5QM0Ie26mbGnoxRxAHeQWuFuuRkFEzyxKzEdVWSyyNXtT6xDUnQgWk7908qCgqYiW9KZkyEs7MXgv-JYetym7pQ7VHRuYm7IduiLKnIEScGRCqXMeTeKHDcBKnkCPehN0mTxA0m_UB8oR1G8AH1hZ0D8hi9XDdxeyZNKcFQBzsA71oTZKfcgFmXpoGCPVHO2W9OkdUj4Di53qkB0eCDX7SQVPBCNmWhOMod-MYH0mSruroygmsQKVSbz55lUzv13ctKD4daeaki8MbB16JLln4ZJEUIPc3NAPNgrpeAGUdMlXzI5NUXHDzyUWb2Skx6HGlI3iw5mFkZ5BpVwaTThyIRs8pb2dXxsa2MJ0CkEmw0XZF26T5u8nv8paah4idiO4LhxkKvEKeMTZUwo1RSPZYliO58DRj6rhm_tR4N_kNXwf_-bIoJUJg51sEWZjAxZBXNxu5-Z-AGGNvMcbM0TNg5dxSr_7AfrNIHdFwggfGkaLf0Z5ILQsGNuRj4QFOmxlCQKVkrbawc2fqF5YiLSpvC2rVgPkzYZ2COlkAPF3c_6jfsNuBd9RC6sHPioxjEsY5ZJhxdHyQCTTwJ5C-vJkBHA6BjQSzbMD2KeUBtd3szenchVbPB6Gy8TNoS46tVnluqkzxGoNVmlX3oYNUelmeDI4txEyxXblOMtkUCIGhs8KiEqd9AP5tR6qwFCj8F7q-4JemiKBNbgm2rHFaRUAg1X6CC6ebNcVjp7-zMkbIZbL2r2637dlGR5uewI1jXYFIGbgGt6wGS7x6-stjcPPdo1ILjOkANdzhpNxBPXb6cUsi4-pb4ju4ULj2iWGh7UcQX7KYRwjMEivHFZLTxFCXLTOt_KCLgmj5tknko4e-qbieGurG4k9Mly9yC9e5ySESUkgX6kyTjPvQH4kYuAEMapJOIyIL9RS555UNWDw-k63G5ee5LfX-sbgMFyl5yvWp4nVjm2P97LXOZNdNkNssZbxj1ziNnXVyr7SoLmmkkelU_IB9Wr9R3s_zEIMTpMRuGO1vpGjQygJvDkXVd-LwWY2hL_KX4_rZA5kfKLDQXVbqeYS4OTIxhRfwuXfE9CRoFL51FqK4JkxZ2it5eQVMHGGoP0kDoDryq-IVArdCez2UHxVonGLwv5XNz8e1qC7iFDryWaBULEmwOxss69VARPqFFwaaNuAsViMATg5ffblvvtS_43jKnKOGhzNTc4wQKRCr02hk1jwfCwTeWhshPcz8BH3z8UTm_SvQGw6vIS6HCZXa2U5-mVrgSI6O5-k6h2dBMre3QxdEcpOeGkelcWUvYBNVaaGdAQX4GnLcGaQ_4BLCz175vSICYwzR4W_0bzNWy1nRQEhC_Nq2ITWVXfk8G1E5M7_MsqFRx1bgJHxIIwgVDf76-II_ReessvGg4fx8_fN0fgUPq8pq7fbq1nkOSoXbNl9LtCS2kLf7jk-nstH9p85eO9lAnVJUwpzuCCebSs_CM_XF5eNagaVaxIQ7NUdpvN1jSB8qiiF3f_Amfd-kW3O_fkTuAoAZ9Ov-NdMn_XCq7POi_QlyJnHviFxuIsOLuMEp6Qp7XHEvIlKPI0DLTkVJxc-wgfGs_obAUOSNrc2mlMi3eFZ_iaLp9Qrx7DQnrorGxvPgrUPQyL_pbZiqjeHyUrhmDzM-ewbtHtPdGEv2w5vd8NxZBhDz4_QvDxZkCOicxTsc3tsx48SR9wrjQduyBvY1RQRX73ILBDF7AIT_VxjF3RbOGrWu99mGp1PIJYOdeJ_f8jG_DNJpeNKfh5b09kupfHLi33x3fU3tcjXNbXbNfkk4GRfN9idh767qI_VCQz-1q-pS4SNdtESSG9WUzHPiQpEE2r9JCNuGGuZOekrhSl9Gk2zNutE57h8p-nKes9qKCxe5Toyajq9YkzVmeutxPZY1flHlwmCVB611YrvQxLXPGCTNPofYTyXyxfiI7YlA34Xe_Fl8OMXJSme1E5j6_IsabfUiyaHTodpeBfUZFICfdcKI0by3G1jK1NkCG-zvTQAPVC2269zWsAzPrAHqmM4jlTgUnWabXLjIQoeEfJg6nvHdrxKrA6ZvuRpZjbD9QtHcwWe0gToJNPod4If2zOB5Ti7t-VHAuxj9y95J3tn0yIs6jKHY4hFAV2tdTcpBdvDt7VILeYtvE1HAnw2w3uEApLfSde8XLISC2EQScGMYUDbnbnKLqaMMcTA5NRLFoaYu3PMGFScaEr_8FhIAWdMGBvIDvZHapotEHDM5bGHVA4m-UhyFwt1EnBz4dlBwyXuiCgemC2oEb3qyfPVPOlrrSxa1z-MKbwiuj3aR-6RK3jYrafFRVRDiOhgiKJMz47XayZWGPDrpID3DTm_Zcmpp0FyQITk1wj8-574d0yKc62bzZBZeJ-uXFvropmTgdjEQL9gco7JNY0rdAzb0_M5BVyXZMM4eE2f20_e6SzU9NuOW_KPF3xyeLpg-X0nhdijhWbzzaFwcHbotMh1w30uX-qNLH7SOlXQz3lxDLhmrx5SvCBC-uWnon6oVptdKAOuNrQ5t-P3XddJuflT3IWgmGruVxS5bcJOKZQXmUU2hUAkblIjKeHDuCUvZfEXTMRpnUN0qnwSWhct0FPw3gsEaiYzpLK-F-O-Okb9SVIfU8-iLCpsDIHi_2qYfoe-mPn44y9FD_-imVD6lOnoErAgQiRY58_RkRaa7_FanD9sqqMGhQZs8hIymwnTnjZcD5KNBetC17NGqNRMdXi2Bn8wjZbWofMykaeCG9kHX5ToS4N_N-QtNciEvvb3kpf6cUavdpiLmMwqyYmzOECf-6lLUr_sb7LUNp5znDSWp-BHFFuw-AI1An4gBHA4HOxaJ7NlWciv1SjPPVFoa-m3Oq_MjChM0mTOeue3sUPWvZFyhK8H3xn9nXtvz84bhWPfNRlwKR0gKtDWGH7ggG9v3bDvwI8vzWqjW6jycQuzp9yIpSjIM54WMtGcC-EXLz4JSzX7-eOj9ZPXjtxmeoFjIh163txvSujyt29nIlb60YzW3AU2eEn-qeA36IYgMk2uF5A4_IOo9WDzPG0KMWYa3tmX-3vn_phSqtkAv0kc17UpLFFM2yCLGK5T4U8f3EwSzAUcCADSfDdvUSOQUJRiCieQbh2Svd9ZMBUpIeEwDMuBTxB3g8L-BzeFwdZBwC2FkmtLBso5c5g7Tf_zenjnYCSnVkdyRNe8c2E4wiSl3Du_0eMZeZn8MQuyYPchnZWr0OmX0birgjidfrtCpeCfqanVLBVNuyGjoRV5yHMPyt3gG5cpQ0j7oTohnEGsWMLYZBxoHem1Ld8iDUWolonqMys4BaK7oElxI9lHQns_Xxhhf70SPv1HjOPKbhRNsdoYHKsXQd_KU68VNznZ59yJixqoPk5tENRLq1YnPNE8WUKwi6XAPUIuv170TKh8uwf9EkQH2olHX2yduVp8isbzZme3XU6t6uqdS6_yQ6qwxz4y-VUfhvkTacgHZslF5cUJDRyYfBTphvDhbmra6WQqnhpLwizWLzb6JYwrJkmFlyPMoLXanjMCNPE1nb-xkPpc98gIVbam9JkrG54v1MiNLXiEy1MIRto_dLhFxsJ6TfPyYrLM4Su-zxuNba-5yY3kc-MDYYcdui13fF9BLGtKexcV-mNVNcZrAwWN2mJvhOXl06X1YmVIbmI6llPkETI4fvvk3RBlS7ApK4_GkR6KloYgGOb58x5eCsKJqoC1pyMr1-IhDiJH6DkRZsvkUAbNOLiVStYYURGjW75WOnXsO-ipD0EuulgZJ1cAMAd5HkIAF-iN6yciyI9KSKsWDVIJyz0Y3uoOtsQU5TiCWGlFU7yutLHLQ9duzSfZKYehKSoNNurNhEvk3nsqF_BOMhV_0n8cpu2eMJgz3JHe19P-ciFbW6E59h7Gx3KLTn5KCzEuJTxY-_00cTDSdfI0990mawvlLrydWwop07Wc5qhUhXefyv-vCxz_2-Vj2Qfw5zPrvdqmsL-gf5-ZITCf2nRVGB_0wzq-YODD1ajnuaUSIOXBXKN1321zyCJZL2D9o6_HRylOIXtUgutBrQhVyFgUqcD7OUKMtgYrQTzrZnxaZTsBNT8SoVzl4TjWOJY0Dx9eFbCukyy_G5v6YsjIBmjCRwJY2yF80YfRSLrHZrM_fV-k_t7QA8cpBjKt3-KjYdxbzU8lEWPGHpoJL6fub1tGWj3jIEfHuE09Fv2LwSKtTUlzA7sbcJzfPoyrBvfbe41vg0vN1urYhDYePQs9HyiyRsn9q6D5y43whcVndTnhU_s0dTIMNKNieCZ2tTjJgmcI9-L1WA-lEySzxtTl1iQDTkAXPpY99F9ehn496ssh8hopYDwSDvhYnh5646hFG9VFknrhkpAgavosCBTQQJaRNJ7iMxTgB5zgL1e7wDfFmmjXROJUIcyOqcdrf4QdlyJU3ysBivvugtaSvouX8K5xbVoh5z56Hp1JWrW8qFEuspVyIhccAC2pi6AoLzZrfnMab2Hk6pXWUGTt6Vko7OT9QibxQjpeBE_IiaB8-PMr44FfpCm94ERrIWRYdhKBI65s9ppFEeceqgIQvPHnVK_TzOIaGU43vWVJRXlgjYGq2VBqfCl85IeiiMg9FajhzJF8W_gL4mnKAnu0gdiZrcxSYQXRuYDCKV3eQvRFR5whJ2n0Kowcb05MIpFUm3ExhGqliG0IYogtFlt01p0FMurken5DI5HQw9kNILqfnZp9Bxm8B9y_hD4dHzNqZJEwuHUxNrkOXW5kX0cxqh3VBGZ-f9Hro-z4ZOtl6lmyq2Qg4myLnLAgNl31xtdoIwGSx9oVYqWFf0HhcSm9VMDT9UoP8l9hQWsqlnGfqUZFXxDxx6pJieANSs16KZl4TIhs2a5hurEiXab_FreHqkAUm_2QLIUo784JrYV3_XVUkfm2mFQoVHF3YsyeC1wMAXUXqzHjeiiYor6OrAVYZqiBXI7ZICCsyWB18Hueqn2NvexPxm1YdjUXoJyQ73sI79O8QXlzizFRTRooRrlV2MtNssslKX8j9Y85Ap7HNPUOqXQTfV2KbOsb6Y0M78VZ5hiZfQJIHnOvW31ffpIYrnGF-5r_LHE1NcuwPqusOSDu9zc5n9PDuJH5-wdFGU8BWaTDotswuF7hVcquEJv9yytvRxVDYuMt5CtanQIF2pH4XpN8rsV1k3T-k--Vzy8xihrFHggg86xVzvIFlkhXJLlTF3k8cMUHLNvjjhXG78CLdPWrINMe2go3RV5e59eDQNq8FPXMAwTS9fgU24JXe08BmZYSeXphY-tVyVmdvwZIM_yWx0tn2SJjR7sDZlBX4mr5c24WcDZiJYfBotUStTSM56qkiS9Z7pFDgtd2kr2MzPEH-BsZUDZ6Xfj2RsRcgsgYv0VmpVpcLnA5waqMyuyDppCBZUyQZ9pT7LHXczAjWytAUrNakPWsdKpFgPOg4APTc7utMfi9ErA1nfuzWor2eGzc0faTA7FT0oNSMOCEQbJYjfNvYCEuwvbTbSRc_jDEHk7RbXB00njOYXJLpvIPImRXqcAVscUCwg74EQwe69hu_s0zxsI-FOF0ksRsNRp18VjLJwuGQSpj7Kpvyf8lCKIOkcsxZk3upARW42Jl8cHFD7SMT-qu0RkQVKab4k2-6-n01pSjIswPHjMMo7wpvs1kq23HODYcFCtLjWZDUV9AovROwamGHKF1xjD91Kmeo9pRt1KtVN1UZDJRXjuaPT8UWclV9g_cymzrnd1I5JWt0DqimXcCFYYiDJbn_jpltuov5_g7RDnh8t676QND1UGjMRfjylcnYRMBz4AV6pWBPkcS8pAJynVoEywK43hMKqCc11d_PevV0fHFPS4qneLcFszINR2X9EJKMQEageJmyZ97Ja_HeuCebzX0miz5yKIJWr19Lmd54cmyG1R2n_HNHvZ5ECbRvToBAnEOKMOzOzS2Dzygh7lCHVMzL0LERyoFX-HbRLQO0brA4UoNJ3YV-680XrAXRkwzui4rLtB7tpPfcCxWddYuGmTeVaMUxpR_fu5pstfzSrEwyOcy-I7NGAPJw5GrvEJB-tjg6IlsWQJSL0zKkgce9vT0Z4e7I_MF_C3Be6a0YWyilbvr9B-pQ3_S6lwJ5-XNIDXDJZzpjvA07vsIE7ADDjlvFNluxIYxz2E8pD576UdPmKg6_nf8ZIOQ1WRJfDjVKCxmnsn7DDiID0TODla69V9gwPICFJVfIzgwNwHocf0B5ATPXFbgPHgIcK0JwlqUfU4w-dQ4-tE81K6ScG9GWuukW8a1kReGboVxbPXiCwqVV0maHbSrqz5-fZ5yYD97aA6uHQ7tWfYbpo7_C02kHuG8rdVxHh9X84aGyC9_95QHc77qwKZh2xgKzZ8xp2QM1cUoY6PmeNZQUcJk016CH_2WHIEf0HHSYrht_0BOYQXWTAcwpgIejGeN4RexdlCjTPsKV9-VpRt-kkBqFmNBkmLHsNqQCSAInu7vhjmEmP_dHXKH47QADWBTbk0_1sDpXRBQE2h3zJx9ElYGfyvqXCB-o58aWsourX-03E8nFT3bx9i0937ViM-XkACE0KyO2x3l3ruS8rEsyL4JVcYDd42IIsoHKK2Pz0E628DnmPn_cNGtIXZ8j3BQ3WK_WJOgX_uyY0pTl1AYlIX_WOVZFt2afytXhD5zAbU1PSKAy5JH5_Brft9tp3rf0LmyVLilPI-7ZJ_d2llQdgaKPjwYWocE7IIuE5O1cOUbY3Ba2K3oVnDHTvcMZwX1Eq-fU2VJ0Dmg-V6sGx6cLh3oZunQSb0VrZnOJE6IhgRlwWQ45zLlrRG5-0DNMufh9X5iSWQZtrUrPk5i-lqzukclQSVoFjad68Amdt3CPTX-gfZyR2zG_f07mMd_wIJLVlp4OsSHd8fPbVpgCBOGIaBDgHmEQ0wO-hjReWgWlLSgFWluETPIgycY40d2cBAMRD9Kt8IYlR4phqMvFHPa7i9dtXA9qFhxc9jGLD_NNPN5kmG3UP2gc2WX5IOIImoWc0N4mTQPQIourT6DMgRlyeKBW2HF-ahFsSpyLf-bdOLLBlNjU4RPfiugIyr8gZdYLmsHSO0FMDOuIThA3tdf1GzqfN_ouYy5tjxZUxWV2ylof8rCL0oLEITNmlXAKszLjKpacu0-5n65S5a7dr6uc07h-vzxj4bq4N3l6ZsCsdSlMRGHXCovITUN0hgVlBmQzc0HWcvFuT8B15-Pof9OWNgrnctjnOWwqiuV1ZGsyEao36PPPWo16-JL_RmW9t87rLrxYS0g4zAsMugtmfY_GDEJcxY6fdXbGYAEsQpA0k_kNJZ8SmiahdK4yWfwOj_2AmMc3h_FfPJM61ljfh6FajLKmS_GibUZ13SdqPnVJERX1ll2Br1DXL6HKIFM2Fn-5ZpmrprkQRICelzd8PsqGfJMcT3flgcylqvZgYHzPnHN_yyfK9bprkgBDFm8Z2IYpSz3dD9acfZJzkCnPVZtcvWbNuKQniYeixeAHayNd_DLHHibWZM9ssZx5pmbYt4c0Lf4CmMNTo1FtJR2otQFfy5xkTm1ASbODwvGhFQ8c76j-F6IjB2XkWe0_5sex9sI5HNkx9yuQ_UCjTar3l75Pt40HAAA8M7z-FNNnPWH1pWf_8QaDdArp7g2Z0Hv_LASz7_MWQ0VOLY7AkdCLh5sIUm1wmt2m24H5syzXZRCeMZ8HOhb5TKrTzQ_oqOgZ8HqlcrjPBv_XXVwSzS7K9PBIE-6OZW61wvg4hoyYFBV1QRe2cY-aZZAelIrzCZNmIxfDPcsyp_nDTo1wC5ssjVUwpJQrUPQ-GdE5oCE1ZP8eU3cdyEt-bRKvB1WIKej7xX8WM5ql747ndHy6rdkIhdIHlUoDjQ1ZWiTo7fDYbmP5fwpZou2UPSufIyGTYwCQIWTTi2GqiDGP4iq3MA-tjW69k7Z9Dl8NXfURnp4B7sGHYcSdm2lly98Jp3GnTz5zFuRxFS3dAFc5XfAwsKh58Co6PbeuDJadz_IW99SCTGegB3_HnKcUBavBDrsFfl1FM7HGtMMoPSEMijhVy91fkIxTaJQ_KvUjXjyuz_XyVoOWSqiPSXImUbF3KeRLOTinYazx4_E2_xgNYIphjtld-hA76lxjkGCVW2ISVXG4HF3acQUsB7IyqoKdRdEWGNZKOEhNJdVG5_J43rySlMfk6M6aESweRehIwhPbSVoFY7SxzHydUo4vgZvkCXGIj-CthWpgtp7whnijNxqGzZdoRZgOmoGIP7HJDCrsWuqdHwCCubs2CwpeDZfYDNL_WKWeIGYSzRS_9HxWt6y4Z3oiEZ2fhUjYWrYT_Xni7mOyIAwLMyY0rQSdcXVv-5638Hi992SsJoczYqqJKCNyG2KHLekz8cni8G1ciVjRRW3vtsqi3qcJJWl3S2HJJ0O66CWd7Ka682PQWNTJRX-cPJ0edrjG5ya7WoCwajNkde-198BNqwICRZy6T3oqJkJtIfOynf_i2kmy-iPdTbdJfPDBX-sR4MZbqsJ-bRfibofKj7NxFuj05D-O-voonduxd1F0LnEhOKmaswNinb8ybfdqXGGh7GdaIa24gJxBknT7oK6qKsUYnBFpIJ3i-58Iz7eLx849MuXsHGpA7f3xuIeqvDITtApuUbJGy26cDStfpulINWUKBL2ouhwnjD_jOJ-Arz13ZOQxwe-dvzlSTF7vsOQ4SJMnJfnp8qpIcQhCotdOscquMPvF_-xTnrsFnOuOkKFqqWaX11qVsm7uaqyS9Srv3XWAqvPDZugsyOaorpr8OQboCKmWTenK1Zye3RbZ7WbmVPpfturthiesvC9KQjqx7zGI8S_y6knGmZJY2YXyE6wROjVUeA0xx6fugUj7fBxiOyZoLx9UhNkpJdw9fOuAlxzvXX7sj13BgMOWtlpr20BIAjHizBjWgfPmyGV2SkwV3OnJvj6KmO-yS1hzT9DWv_BmJ7qfV_Bjm0kUAJuTvqKU5jb6PmhGmZuM3Zake9uxQfR7QKFNRwLwh7veWRjkMCFZanNjSZgFPZMo-iTniz0ngZInV3kBXyX_Pq5OG36ooxDgUv0syQeRs_I_HDgMcK0Q9f4_WYeKfs1fa-_fyHVO9XgwLMulxr2s2lX09uv4BHiePKmydtahMtB3XCV90q4q-fIdozULBuE0EbATcVhYJsjDGySGtEBVsBQsHvuqgbRTptYwNxvfRSMzpDKOXmLe_UqaXC1LmdpkFtBqgmteK-JoCSKmw8RxDyolUgYBsGAZIYYa5zOjizDP0m9NvHq9hgqPYwzDRJGkJ_2dNetzwr0SxkkEHCnjyEenn-byDEVRBc5-jDBU0VQEjDZhJs-z4Olf0wPFk3VHjHFEm-YCiXPrkD1Ic2d37coUKZYH9k_WKHJ60_ffL2Zb-TI1urHxcQ80TF-1-g15K8kEIpMd26GZ-wiKcqMU-b7tYhY3iq_icS8-11fMiQ3pe2XeXJV0H58uV1gGRrWtbq2yn3-QFpY8Cj28lrMFQJX1vNxFMeeD5-rngi3jeYNyJdbn8PYh8ENy8Xujh5pI6noexfTuzm7K8OXFpHuBDFl3ltMzcaqhPneYAc32aSHdgghkN7ZhhJpdQbLMj1AvAuzLeAt_95AXC_0aQCzRnYyv8Q54hJ-lcX36zK8vNOEVB-OXwOzfhVyByjG931JXiv3oexzEaQXKkipAZvTuigg7cQ5qs5hEgHNNum1BaT1itmLcpHw3WEFhiFYMV0Dok5Vn064uigcSWf2PFDMn9ELZs0kAXuS8DlaK0EAMEgjqfQubl5hLdzF-Z_Y4PRDAbDcjNhS4wR7KUfEr2mJGP6H0tT5mCW4rddZeTTjcUUH5Qto_VDzX5LIU0MfGC8cHxYOmUFQjAuvv-vPdAethZWgIu0rzuS_SPjqA8vemcvz2nBr5IYaOSe9qlRygR_J0BRXdJ6YgaWIXtUEfepmlGH0ZHXyzNmpGTUN085_SV2H_WeTJ2qdymE935iqZM77NrjMFnWBq2-uDq3h0DcBa0sJutLrx_6GD49OBndBpmZZmV0GyLXrsn2VJO3Fd9O-GKAn4iH1SAay5TGbCqqkPvnHOke7BMtD16a8As2moj040qYefRyNlfi-67GYVzLSA8UUv30LgsHFbJ2c25u9RT2IoPic8exRH1HSJYrjwIBz7ImOvjnDhTh4df2IrY303XOA_34P5c4Z5fbXjKTagrTn7rz6hL49T4t-1AKZxMlfpmIzDGqwlD49LGtxSPXlBB5jKbX7kZtvN \ No newline at end of file diff --git a/backups/backup_v2docker_20250623_030000_encrypted.sql.gz.enc b/backups/backup_v2docker_20250623_030000_encrypted.sql.gz.enc deleted file mode 100644 index 1a277f3..0000000 --- a/backups/backup_v2docker_20250623_030000_encrypted.sql.gz.enc +++ /dev/null @@ -1 +0,0 @@ -gAAAAABoWKcQ82d-qjTk_MY0WHwXmauC7exaAA5d7AiWFzZ8P1C_o12Tm0d-yLRa9mcuNucuzzyarL41sFaBxRbKWdYfVkY9jqo3Dl1Jn4qA3Y1ftp61xnSWmYlSMVFKWS3N5K9AO0-yD_XBQNHG8s1DEOtKfNpEncRndJ24fUdBEBLvQ65ip1EqlGIvfKLEktSRspv6THImTUZJvhuvLY7k9b-y4aQVLxgqmgZ-8o4JVefzEDbjZ-37hvl6yzdYD3Kc0RfOAo-fQuPq4EZ0EaZuCQW_PA3v8m3dHrg2ehur-qDKyaiGhIFIvBkKqAogSQUbQAJH-fEFgUeMfnRHe4zcAFUUTnvCaZy_VgrddHOjqjFnBQIIS-g7BjkOhy_P-SE2y-pvJht9TWX921uXWOiw2Lp8f4U8k-NFggPIijmLZc79tTaH0IJE_Eacj4qxwNtBmi-SDApuYjKkUrfAwjiN3BukZ0Aa15cHNiUab8eq1jq0ecBN0X5xZaH6K7gNDVS1nmtLYwjGbBTEi8SUdw1KXmGOAX10VPtzZu0DuT--rTLX40wKAWFlMfaov_BI24lV5kYdlf4xwiLNs6MKLIqUN9o4cBYfv2zMM76_sy3v44F2oWQPNj1-YqGg5X8BxqGvt8si0q8Odg3x0M8ZJDx12Nxywhher4hC-Zc1t13e3ORs63IdscMMHFRGpeXMJ5weEFr9ftQKO6j5xnk5Af3P7ZyHLitNMhZYLGvP_px_bnvn2CmDWYU4-WgMgyqhG1gL8IWUbQO0u6eBe0RbT_jtncJuJMmisstaCGBsvWpzSYCkShcMPtibI-xFge0mb8fWplaxwOtQDtKDbAttgJQHh3rXT-Nu6yBg2oCKLap-xnlKWphlUDyFs5VUV67pP8CDLjtpQeLzDhYbDTq6edcEWAqgX4OFuslCs7QtPRJo_Ya_TK0Ar4-Z66I47fLks1IVSEL82zlb2rIYkCZ3AiGdXv3wBtISCej5wu8tcayTLpHrx3VPLu1XP_Rqc3I4CP9-TJhIR7tE0ZBFxKhVMWrinrb6QvOiXEU3x3XTq6fE0nfI6TADAMTiEUNWva18ohtsMl7Yz41O3e-wf-07at7zaZqYYqPx7fS-3Pc1QojW_HVDV0wYfffEjzQlHxpjYdCTyLLOfE5wP9mxRX4c7EDYpJzE8gBEzv76xtM8o2c5HYo4Q_Q5Jdxo7DpF-c8MSDE1w4ZM_IaRSkito1KqWa-8m47nVcx5yk6jVQx3hvEk9G7Dvzp9IMO6P9PuzocAuO842Xd6cx6F7DVDxv2TR-0Os9xo3uRXpBklRrJQjTnDZ_6aw2iPyYwOZQI15KzWGKPSYWodEbWyOYfww30Iz1RfCRgJ7RRx5IL13E_Db47ce8ntjxjtaHF4O7vZPqedP6wIXFP7rWNqvTMwpyJFxQIPb33dfGa4oByW6uPBmI7KmANNaODXhsM0rjPQIBg34pfb7v5Z4FVVDuobZZmPYGeVl7QNU08ndrgDxIBme4D9DAd3IpT1nKdf96UZQo4HaPcNXNxVdaCU29DmJRz7OsiPA0FQpxRtLELNwuxPEpII7AKlzcROI-G3hVYkfvhxZ9sqscYAMgWIeWEIjXDJQcbfrXxz_iYmLRFYy5GSgE7L6E6PyAm6WeJSkRmprp8ySM44nlSBpzFUizXlpFlr_XL_VQAG24TTCdEtfm1D4ktOZvnOAuwqUXT2yl53BXSi2oNvfzFrohGzd1BW5QmGneTTya3Sh85W14MLw3JaxvwVbZ2HD2Z1g11naOJZjeVqT4GbjFX3I1kQwzSlJ9Gc3oC4U_L3Yy6p5cELdcwA3jPgXoBBHZsBx0BtBK0dPvajuVZ5blLvEmx6b8L6fqz3J7rQ-b8JNgcPncMJAE-TNcEo54Hpj-fb_gzszvag3Ti7u0-9pO0EbAHT1Dlp9MS0lctw3qvP96Y77FM-6oNgnOPY_AvT0ecxiUPCrjLBh0xx41uX_NgTAyQ3Qlpv8VqnYFqrX_yWNGLv1KxGQ1RALobpQKrq0HET26VrqvWd1HKoMSPw3wG-HFtPgzIVsbYRemaACgOVBFfcEpwiI6sNUPS4fNeSLIqwNQ-tzc-fr0w8N9OsNSiEFZZfrJwIvzrUftii-pto_311lriQXJo16qFqFbXbBKroFqWX2y61_sNT8qs0_hU3iWfDcwywkXsBh1q5jOkqECphCUZEXFP1Qqy-ed8LGtB0Vjnjtb6Dj5B6ieP3_2nzw9RZm9ug1XEyk0x14AbNIc6Xnjfda5s4zJIeFeX8gljFlxr5PzFvLlzu7lFkpyqCcMMUPe_4SOQ6Cqh-d_ERutViJsxsr3yCvrLh_LNR6x3C2eF8t9D3mv69xAWruKDMOgk2JrZOTDenATIPD8YO-Bh7tpqi2a5BXm0u5jOEFlMRQ3D_yLfTwwjtLNvOZidlkE0ryygS9CcglXtUrnP8tGD2viUl4006NuoSWzsFHjttyB2sJO6wcvMNov7ON6o-s60Dzk0VC2smaeB0Mi_cYNpENJBSDLBcUSCYh4RhBGuzZ96cgBwU3w_Ab7aBhiHw2e5eZkcBG1hvJJhxmyEbGQ5qROk-4VmWAwn52oedKBEv3IgFGcba-uyT75muI_0KUWuDniz8ktR9WNhUWbxCzlpS_hO235-7m81grW8U8FpZb3ZAM1WLwjqvu0jRGj-iN6AT2MXman_8WQXvs9T3Z4qVzseqPf5x864p8H3hYaPGbQLbhRTjSJOMOALSojNCawo_ClygOK_HhSH5WJVpHA3CECs4j645qknpEXvd1AxQAIaPROa4CNWVogqLmQ8CsyfLEmhyYg7aWjFO0Yn54wlO7Zcv-7EeS6C6JFB6djh3_dOc-cXuRS-ktIrY2zQUfPCSjIl86pecbHc0v752wfmx5T0VvYNdmVEuwpnjWYZfDiB1Rs7XtwvqS3PooUn0MTzHfrSlSk7cauzxqSAiNDRdexftmlbFUcmRf4m6BLfsI-eGxtJgtS7Es6Of9UIv1EhXFH1BgJVyiUIRU8QkgWFQOEdakSDeJa1nR9BUl4xF1eyO2xstNqrnAi94Rt-fT8RkiRJ2Bb439jQ9pA4QgrScSGxayYFjTW7LDmKhBFZVtBNT7tq5hXaMnVNIXHQXVhsK82kJfI392Di-wmPslgQVf8ttuYOGYWoh4KxSnVdOX6nNoLUVIHMWthHGT3cSucaLtHh0O8VZMNtEwrn13dX29KuzCfzlPxLX5bdP1Tmanozp9Y4w4bDRS9JpAL95n4RmtMzzHt63JJNIlaYDY4wThsfqHmPPvzuw8pKiC3WxigCzIWr2yqa9ZgvGWjxTaDFsSTBBXDUidN6hJuEVRda7jSZNCOgCjakKq9uS9IZFbIeAWymyODLSN0WIgyuqzE2tO4kIBbJ1s0mMJt687JNp-OIilz5PSOPbph03Sqd4FGXre3qYntepGT1zQlo7FiADmpqWeULj7HVYnK_HDG6X6vHqYn5ZmeJMql1pceXRvviFXuJlMUJSr8o1N48rdg3OX9WOku_X8vuFqpoElQg7EdWRwnuWrJNW5j-y0aZ_NbuZu7NNkG9PpwnwQM1Gz2jmixSwZlVIWwYuIm7jo18oZY20ONROSO5xzRH-OhMUDBbawFb70NUM98v9Oip0LBoKvs9Uugc4o91SRAcdf6Cxs6Ig4uZAQx8z7r9h4Wsukz-GF9YNVGM3V3Hisj73CyL2ahk7eije9PjjBzFxUM0WOA4dnXgdfKXa64PNqHzcdf8GXEX4142-FKJ7TLNk3mM7JP4wbkvF0tXqLch2DOuIkrcx25bu-bRuHiVOhynxdKi52H7RewNthO9euDDgrZf5Pmn-YOwT5BXNs7azz7bOVmKJ0qtOrelCagijK2v2VQIXNbkAXUnW6lewNQLEAt4y0p5xXXnn3IhkWHOK7QbdDdJqHcKC3cdrqa3pxyguODNc6Lfg0FB1MgrX-BNCZVzSZDlTfWe8fRcNUfh5lUZb3VZuf_lzVVetXVhd3EyyMGgVEoVQo4OFzyIJf6RPXZjIRPkgg0yB2_Ud6YoBXJglhHExNbIgtD5teccqOs8ay_I-WZ6PWQ4YlUCxZTuNIIWNTQzCVOqj9Rnyq2rsz3Hfm0chLMFrxiruvComt6PCU6_yit1y2XtkCj_9VNn4FaitdNYPHl50hsgZu6hE6wLXqns7L9U0tfNrg-PqswSSdjmxi14KHZFiG-CbaBaej-hbDeThcHmC7o82Br8SGbznJv88w44AI7Mx1e9p8QAFPShzSbVkacA8XH-VZzfzXex5oXlDtW7ZyUOYWTgpZ4bq9mhSlU30vmpITv9GBNBOUc7BVNu3D1O1usYHJtR_X2a5XH0kXUwgkyRawiwmtcxFwiTVI2YDIoXX7fJPjoF8RwzJG2BVk_CsE2DyhqUc--XhQRRoby-rQu90L8LiQuiFkbIb4cGapSzEeQ-c63xWuLghK7e6SpzntxD_GVYs1E0JUT0oduRMY8rC2n92tfCuSj48PB7EtDF7X2rsdO3V-RHru7famlZMgMPeTbscHWAa0kBHQ8relqQDzp4XT8PVDE8KR9P5fgqo36b27_1PNLL2Ped3Eb6CLApKmNV4tbEp1ZRciNVHzCkQbxZcO9wiRnx9orV32X31TqWnz7O98OWNIaZLT5Oq8x21Nw1wqlLotgNfywGzIRiHmW7zaP1XyKeIsb4MS3usQ1YWXKOWHaGV_WVzE2gpdk6wOD1gUqnBuooTy1u-us1u49hv7iaDmWEqjRsNePLIlVf2bqA7wm3mDAKWBWuejip-hb8iQEGV3nkqyUTs8nSZvGGzQtIFoyJHQ9wQRzgTAjl-RZl3_qc9RvYjVwDG1nSzcpB6MnaOzMef_dupwsOCjHW1NuoPkHHa1NqRbfFkBGgKN33EhrMfb6RJP5QOsrHHQcG7yePgNoYiOm4XSAvIISiDwyRPm62LejhC3Lns1EEaIfru9F7QEd-RXEXguWQiMu0mHSdc5L1MTIcUjRbw4ZnrrzQhkEGSMRc3lxPMftCfwYdZSB33mo9krhcDyCV5ORK9EUDxBzvmv-PY0pM9Pe3DY06JSPhqJ-2UpzLOymAQjyaLyGl725Ij3308TA-QBmyBxtVEkpHEL_3iek7PcFAps3QlCNPgQBjnOwYIy2BXeJe27rdlXVVDx7DnobNz2SyZq0wRHKNKPqzjw2bKjsWsHO_UQG-0vVgd7TnFmA9VUZz4rKF6x-iJsmjLbZb_cPz_oImaTomK3Eq38UF0GruEutUQ6LixZJIj0Nl8KKDQegNAXsCPfjwYVbsbHfaoSSSBQVkXjdbgBDOWLAplnHy3QxC1QXY33zJ54Bs4YFZGFVMj9Dm25Z1PCvBHO8ql8dimO6ndwDiDaSIQdOD68JRyNMLWLN_qGg2gXK7bS9xYY0OfbvN12lCIMXUrNSEMLEiYPxrVBWZn9Q3Hcfn2A4d9BevWClnjYF2eb1dDRmcc5itPH0QuAbx8aUS2QusK4Kd-x962WaLqxSa4qsouFFFN_anNe-rSbjtwZj9tieO_esURDXLgf00u87Zkfc-BvgHlOc1548qyWHuafhiLcoeGjiQrk46GrVYFovp1nZZPE9N4mdmwxNWHtzTggXKSxQDO3JvGQm_e5EO_VF0cBMMsEWa_2y5XwKKogISnS3RWf7eapvqS-dvXB6ZikZOTR092RU1Ic5BjBnrfqQZ3-Qy2jJHJbSnTkM0feM1Kq3CBt1DKMt7Kp1IWXJvqMRqhyCAbRenDTbVi4suNJHWQQ-vnnKqAsth23AsESPIdytbtRsSN6UnaNOJfwhTXW3anVO2oCzkw5K1X8hSRd5PdPwuJDaZWvpkvnJ9xqjzyJQJXxhU-kESA_-MrGmk9fhZCbmewwTl1GtAD4s_r5Oy1aeNPlWcr-uRSgeq9-XnOxUcXTgmqlj5CJ-IDt2yh1EYqvswX65x-z0lKYgP6Nf62w-CS1bTQuP1Q6i9AMPf-Ta3EgcoIAlsRmjIDbJb5RTkEtsBu8SNtKedZO0kjiFOkDeTKWKsUGxcLSNWkkELUCK-tEJPOpBnQ-cIN8ilYHWl5NYgdiwwOl1J84VsfIUq074RvMsS7AxFJ76ymUOZSwpOJueXT2VsGfTYdKJ9xUE0cGs4VHLJLG7VXSs1iluehivRJgwfl9gjkOCC3lrJZODzPt_EMbYy66FlqiLuY9Dia1Nph51KTsW2gZ04AjASjhO9gkx6nop9Uie3GPH7Zr5QC3n2LORsMtIXfJCArckzQjTAqa-GPzlwHOn5B2nH06l4WuJM8X3Vb8G0kcAKqeBeH34mfloAtZvhi4VgjkC-_GaC2P5aJPAzYRXLM4RtU6NF2T_sb0evBybsp4DpavNKEc5k4wHCfP_HoGCk5Bt-PsSlWaj1fntIBek0m6T6gweoZwzrC1_UN3xhom0dLgTgx5x53PeKt5ESdX8Vbx0cgNZJPeOayqQw1PpH1Wg6VanCypAQyKFqPy515tHgRmV1CzU9VOa30SdkBwEyfoIDxuZA-285A9zF8U90NPhPHjeOG2EZr1Cbs1cjZME3ES3i3VQl2k4vqLF-9AZe2sveo5bpsuXFUq9v_L_dM3PMNVQLrda1m7mX8Elp9Rlh3fFWZqiqH1nc-9FJk6Hwtl8uZKg_2_p0cVis5pVUGUKtXt_5_Jn1KiLZMtqKJDVd_n_kw91vYb_oVVMqARAWF2zT1YG1u7yOMds_Fhz9AQQ4cr1zliY6PrcBzTcncVUskmWFpv6LPVHNRj8yCj7JTM_7umPJaO26nXq_GWeSH2qZd5HgI4U5kUX0XhKmgXp_D61js_ZSKMCGlj8wTkB3b7E0tF1IVjYP8l-pttoVPbteMZAx4owFiut-GefJ_oymmrx7J57q7m2hXMidDxxBWxqcrY42aUq5E2hJ7OwKExvFriTyjKrQbRO0FKiJaN1s6PVu7hIYH_rPI2z_ERBxTycWMw1mZ-l_rnWROeuBV7b27J8h5txiZ8q_qhtxAvUmPfSyxZ_1xyDZCJYNikWb_j_AJaYUJXNG0te68vMujhHXnFpVKHq2fL4nt8kc5567abCmqElEJNFdm8Aoi6mHW0kqsnlDFEQEfI1wHwwsoaDABUQTe8rqqzIDXua0xfB2CVYIC7l4N5CHOtusPgkYsrtSA4p2jywJ2LdzQqSuNeEyKIyIRpkvRz2g29GtBs5Sg6mJBuG7NID6jAK7jbkJtkT5rzdnryNQgkft0EhlIv_tKKf3wVfnIyDfnUpI_xJ4WiHfbOLWyoa4cOHBFn61c6nKSjSkZFZjYg0B0ajf7bvPh6qX2G21OSQFnS8BHplY5-L9ohy6UfV7Efefcu_6iDj634dpAaiZjg_HC8ndPIP7sFv-UBEdqNyXt-4kKSUMv6bGD7QDpcpuU1FkVy7DXzlKCD3yIVMmtcdrfLMiEsqiwbSLXiGp3dCgP_dNNUzkFP3QWg6ng2J37yI-Axs38KUmSi2Rsp6aT0RzAToFuAs-memKrdTXrxklWn51k0ds3MGUQbg7lHwu5SiLWupZjE7E02eiU8u3XKhVQdrSfShOZOGavhyvdKXUxEBtQja4X9YLtgWYv2B4NJOkVDshfXKw4I99QluY0vg_mgOEMl4rOAIFowJvpaEqegBUKlwyD8659wZFIe-l_gxYpVb3KMZU1tNveA9W_zSuaMih8S102BAzUiAZKtulEt6fhtwTxr0NnF2sH2ucQgXhrpJjBWWQeOnOD9ehy4QakHi66eYsUeBvY7E48798FQ0rQDQ_2cc2NEQlhiDTU9w7mbkmI8PBM4Qf8gSTxk1CSKIElJqsw9XxPdNnxHwVDakVy7aQZc-pop_II0nhgUEGUWp06N5t_qzeR26V4sAeO5znkMSo8ot0CNpDjSykQuLPOALRcIRWBdvq5TtBL30phoTsI526kVCicBAyhMvj0W6cMYqSNcdyCv7aEBKoL_eMLloImIll3YCHZHdyB4Xb0025BEt7SYu1ypvhEri6fz79mhM_2mmAk0UhLm-iBvuoFxikwAY7Q986vws0QkDawO2XUt7ZKTa1pkLrVVQEvCqIdWxn2FKDDUaMK5i-kEfHBy9CsTzYOpKgGFZtzZTouZ508KO5H9jOryb8FWjxV0Z2lGvDUmmzFW4armLTuwnDegMUP_G_igRfp-j2sL21D4yJvaVM_JkHbp8ei3La_Bi2VwLEOIUvM2Knp_wc_c81K43g4EXA8UDSJ92guVFoCYNpVYcip8X2BAqd78yhtLJNpY-SXl0L4hKv7LxGbANLZFPFE6OcDlw8bWXWFqk3nJY0DUvv1lDPKNKunVLO0KHtNIuBaS07gXNxHDG836kB8nS3s4bHUL3sEOkDICvXILdK1Khmvyg8UYp5tlry72O0aq1IOe1_2kxfK4ocgdRDM0UhE_MXyJUWKhr2ZOkfxzR4300_5ltP-yPiLVG2dsnMRsUBrGMkr-gNS5s5x4OiqLLfRm8nGOge70wmIWvFc96c1igpP5ImOT6AOhdscBM60Dv5evZXRipIKJfafWxUyUN96B2bWWm5lUWo2QZeraqeFPA1hZTT8CKk8u5iST6hljTXB2GdT12g32OJpsQqPmS4A4RNh6ABYVNlEd3L4AHf3UKYQ2AJTiNbB1Owr2nEJIm1RR3KJxYFU2Y8iiSXRwJO1aQETZJmSM4qDHl-AgXvEDuvSzg0-U7yiP4S50DlZPvOlaVUC0scKe8y74Oe5_iOddLjAFHhUIaouCmzwEDUm49iVEDqRjGfkcPpv83RSaUY40Y79bULyOUPVQXT_2EbmRMlDsyY4HwiKzDcc0FhR-aV1MuX6KH5hdVIX_k38lQLMRe7w_YSQOIsNEu8c0er3Rqk7HVx3Z9ThNcILH8JiRK8kOrvRQVe-GXNv1Raq0zQmUJeuBLl8rOs4ilDI1Fa_P8iuzIuyXCQ9RLRLbHmBPMC2WOnBJ7FL2yX_Gw4mx6clAd8s7y6U4AOPBjMN509R4FBSfJ-Ar6dEWDz754ndt7kv5cnXs5CyjCr0g2x9rII75Q-BLBwpgHxSpL5VV3Hqpl4pqVUzJEXp5kaYp8Nfy-GaVomDp8V2QhItoOokx9OVIzQJ4jsjKBu2tYPOg7CX_9o9AZSRAWiwK_TTmpeduTHJZoJDx7rqQkl_BDMmiMrDjRn-QsweTq0XJgUEVngyxEAsiW-yif1pVyFGMjTwhlhJ0iVBR-qSZTKUJFrQJFyekRJjrHh9J9MJRQh9bh6suWSh91CuMB9_lZuAvHv1cWfB5_lFOxZEm9zgB3xtOD-Z9OfmyHfIwUBnAt6Xg45CJHfsMW4GQn6keom5xhddNmIy_Kbwi_zIxx_8ZnRkJ7mOr1QSPL8F93zVIdPtuh6V-mhS1hQk3MFgOnNhjONOqbCcEgXZLckgpJdAcOnXkYuhmAWsULrEcRkcdQiHeVgF7YMrYRylZvsRusvx79kenFiSPqVZvbOtJLaXxfPC7aWGLAYY_q7fUiqPm6ybIcFWiLPrCGGmh9bywl2e8R0rzoNx9KkEPmJsloVfowZn7H4vFrcqu1qWq676E1nZnwn7pt1GVMr953mLCm7xz6w_Ad6Gfva_WBgtQw5BkM40I8Gt4nffC12McDpBZwF0Uu9SEN8FtA6oNOkwN5eh3XCmUlzOumvS4L2_KuLtMu588BqqlYvZr1DjCzwIQwGMaTbnbfrnGx7Wj9Iv4iFXadOGX_NPTo_FWH7kowQ0x77bEOKtPcohz5OU7XSsrNhfVdF3Q1Fz1Ob0leki6kOmldfaluwMewfu7pbMBj1YIIvna-JSHHEtbdb_Z2_CXN1cepsFxVn2aPEEQKvJuxF_hOKuEfip1Aucl8iSGsRXfL2DIcuc-g2QyFYID38PimY2PHLV8SMg8viC3oaZX4Ez5T81SR64Od0NWEy3KcYf98rFfhbej80qo1UxAancoyIPPG-h1T1OTD0gm2CDlYHCxVN6z_jRW4FgOVE_qeFsXoFYV3AMxVxsOMHC4cnGxqCZI7MDN0feCwdwzRPw9kVTfDvMcloZ72GiO5Pz6fAfqW031_XwnbP9PVXGkC117TlkjZ9R2OyfJMVMXeUMsxvF07uiKewCIBxjWPPKxA6Gt5P0Icb0C00GT-3NJqWFeMn0ecPQjNa7QtAjHllA2FZ7srYyMrsLy-15LxPZle0QBh2QRRhcLkMVwr7zDJvtiIivAoDIjRJBWDEhfuv_sLiY-ySgq4X4hLGFnNqMUGMBCGgYlKiKb0PeiHZ2rzpcZq5Q2mhRKhxubsi4Vg8jhrm18A-hqYzi_HZ9R9Yq2W9q3yAKRLm7zQtCVuTSR2e7uM0QoQ01c4iS0jUhReABxJ3Y20lhU6WOjn8Z16zdFj0G4paGngHYdHkJn60Sx5hw_HTXUP-e2m-Ua4xMb9Bh4UiEsmQ-1yxFLHVA4Oxh5LAXgJkFsPNr82uc_cEv-ihk5Cq2jEoqAXx_ZYFZA_CDLnp1a1FfhcvF9NBn_j4ZkFTwGX4axpm6GbMd-J5b0D0Wq8PJ5h0uM-w1JA4_YQAs_J8H5ASfqSGqQjvGavGGBdmBmELNIsD8D7mx99Ylc8klZhlWD2iwqviqBwxgi3QdgYYj4ERAcoIoDvfzYLMvTUVCnRqzafMjqn2kUfRkqpE4D1gXO44dsQCJKvj5EnHDGyLYPXe-Q79So1xWqGMvo5t5X8O4jsHHFQR-3-X4OJK5kqvi5GZhOPp8wMstFZyHw_p9wcKWAKBQhkxhRNzdVFMj4tSGmaHh59T-DmrDIpMVuR8UMG0dJugbiVvcllJHFaKq1waBqTT9AXOB0qWfqA8avupt2uvkYoZQURH1PBSOtx0xvJsdRNHud3T1ZlkZTVat7Q_l-LWtuKFXGAi6PonOpCX0TfjpBl33jp5kNwGozvcGUmBwAyI2NY0aYxOkhKswvTh0HsIJ0m6irldLLKi9EJnKsqf3PykbS4W1t-OR3Q4Sw_e4G8k2e5b5b-uolvMO_f-FYirx9uXXlJVOXdpzkDNy8GZM04kNtLr53VdMkTJH7kTPGhVmPyHrqA_gH1tWqmB5mp2ZN9Tv4gg0bYQ1O2etoUZLKsJ6XaFeFcGWzfzKyc4RFMfd1J8gN1nLbt0dh1Q7KaNB0A006_MKASoCjrDIMUqJcq58MjzfhQaIEdwBli70eNP7WbGJ-Q-hoq4CF7pxe-9_pRvrc3qtJf4kvOLo7N9J-obT3kVQXJhdZoNd0rHFYQT8MEIzF1QGqpanflmjwTIais0CHHRVx9RGg88RKZ5n07dvmqRC0NZK1_tCQwyf--Emscq4TqE65cw8ZBEmmSExxn2thhdmzLGdRNh_4uiBdtD6fsnq9YM23VzepRsQ5GYHtPCP3Dj05FvovPMFr0A8GcSiAtAxLWa_n--niH5oLfgPwsq-n_OFLbl-2Ac2SD0lTIItj3-VSFO31WZX__xtIzhU0HjnTrRSyBGXEBSoj-_ddDmO-_17sRx8ZZZ0fUpBVjbJexMNa2kpXLblJziOFMl3kMuW3vdcn7xT7yrugM6ar5-6ZNIly0GD6AUsshG60XVYT5xxyFr9GfO-BPWS-AZXgzXB3dC3E2uYbOle2wxUuVUlBJjbF5n4BwcGVsFsvNf5jJo2FKxyADbOvbZwTiZgiY2biBjavlmM4PwoFLiCz6UrBI7p62DeAZBSaz7vXJQ2GAyShyVwLeu_AIjaQRVZq45yCxR0iUFDz5XU35MhC9yYirC1oncDBYJqiSecA4xEWuG8MoeJ8TttpRz1DDF9psw2crhux3YS3RpVBP-3SN1iSQdmQCkUzwZe7sI8fnlwQAH0xrnB-Gy6msdJvzd8vfV6wpX1aDP0yzNPKAenyM33NMzBeGzZuFZz3An8_UBlVsa68H7lzikR5_MNPfzEFFLR-4O8wz_8qYfhvngzCHm4jPaaaco8d-bcgEernN6FdmX9yrAaYbi1dRLQx6iBCezYmoKNVQgXQss_tSuZt1nasOW3DkL8TA6_g72R9-RqHRI3I---cwBFUX2FslySkt7VwiOAOxvgRbpfoFL1ZLXzTc-SFmCLGvG64IvPUotOmjyOysS9ujxfd-FXodoRwGz3rGTtBePZUKO1oJ9cEb-l_8P6SHofTHXY-457OcHIg7OzlNVZ06TioK4UVA8WfU_QJycHKlcYipTxdI41qI3WEWMTItxwlnvMBsYm9wv1xq8hrclhMjT53wg62bTNjvK4qmfnU6d1nUP7NiknupfaR1GEfsE1rqZOfS6JnFLozewbFqePikXutTX44eXI4uX1hRtI2T-3srjvtnn8a3iVEefdZrMYTHSVUJcd1_HeJHHC6yxACO50zxGDnva0Bf96VbCs0JC_OyxIxGZ2boRJK9ur1FBpEZ0WIHbNISlnT32RjuOsPLpyzvY7H_XSzxoZf0_9JroDtp7ygu1u_Gw1L_ZZKEgy2qem1giL8wpOHaqggEv5vj5DyiBS8oAceTXAVk2XoH_6zZy0useuK3qqTk6B2GAoTn2kVejM1QwABdBPbfLChubbhlrM8_L8Rh-6ugIdq82MA1gv8q03_yoB9SaAyObQdWuo93T4OdHZAnR_AvEsshf89gNSt3onO6XQHnbm6Yd-Jy2f4LQLgF5X7klUh_qMW4HVjmu7Sw-C4BcmEC4VeswTB4EAel0mTF0avnpRLZSJi2p79rfMmlIXyy_lAaEiZF7ZyXbTUWPQdzZfCO4mR-PDIP8yosJL3V6TUGvhHFvY_bMtrcJ2gO-kGkfYTvXqcRDiIQS7LXYHNdgqGpqszpIpI2VvZR02veEXgQzPVjXsQ35-jBBIwrDcWQsgi_IpmLyS17AIKyOVLVp_VvdW76vdPNxOOkANX7Gsc5-PbXPa1ZjfBpKnfmJXjpiVt4U8FeuUeIjg8bLerbe6L6RLlsyVUldaQje3I-atjRyGZjMDBdd61HrLjw59ByEseVCR2vg4JiCoIIyWcO5qVoqAn5fW0Gwb17fl6vhg19JHrCavM6t1ZvymoTQmbGgPPVa2H_fI6AWUGHZEKdnj_N1Ii79pFxeCR9l4vgvWxjyvR5z7yvnHIBItZitgodGb2ShGbF3JPuXCLwbBOFGEUj4uctMBWRlb9wMmaWIAP6Q6kQomjykgU2PsTb2eq9ZVK-Zo_RVC6UjSqApicdeqnj3-hPqGNyV_yvnFw_R39_kZWbGSescph6Pv8fKHnBgEDLl5kUuYw5gAMFbdczh08iRLM4faj6pGMD_NwCVLlYg_8RYljLmQS8ZRkQEvlh8p3OPmN8kWZNYhBVTCi2ChtONgK9kbY2h8SGcOYkh7ikspCkWtF55NCUQJpNauZPl6cIEIx5_Wp7L1OLCSp3JJP-jE1_ZZnvEMIEcgPJbdOXTkUjIRLNixbUFugfnuWeOnVh4KWfhcr_1J42FNLZD-WaSmyFABPp8BkpB1JyvyhUCkRaFUyCy29_FdlCyKP7aoAXTVnPzReoaZT5eyefzGohM96E_KHYiN-0tyydXFRjJ9tUA1MVGp_d8nXJUAlb1BVSOUebqirSWk7h4mXV3e-Cc1tffVUIXpwgNjvm2YANta2EV4UFnMZtatIwbAQGsNkoAn2UXg1FiTA1RDHzbM185CdTgco0p0prj4QViXcIxRvKMfyllYmXuXNww2cgh--iL_EA_fCw4Du9qvSBW9f2mg_UamQvs_MYTfuub0SzGGhdC1_VTstKzs-RVHO5Nw0U6AEGE2Z2ITA51deplms3nZxSpRk0XxxNveyRfj9yGDJiZA1AcQ1lTL9TTrc8Xnp87VJF1whS-XO_HlQL8Ex1C3okzmfpswhOb4VH1sxlUjuucQptnuu0fjNgl90hkV5eOZ4QPxN9y3XyhL-POGtMgyjp_csfxdn3-WAr_bEDmwo8QdCUrAenCxsLGvYPTkDEhUHkPM0CrCOHkmOTmEowQah4V8n6Cc2GzGAlV1fHDjCYlz9WmViIWLXspc0hCq7Ugt96CMWzqR4H-ZUnfGQfGf4u0SiBthqT4oT0gLdYq6Qh6lYildLlx3ReNmPtGgBta22MXyGtinlqFXl4EIDb1hPWRcq1owleGjaNHZLGssNQZyoNnJRAnTNMVKA7GeBuQ4uL0_xJ485ix0KXT-Sizjn0Y8HJTRRtWlKuLUxbRW6-2bgjIWXjzQshfgoQgSzMGxgyqj3Pfl8OKm63n6vclO8S_-qWzoPGAJaBLG7qnHJ6W5uxKYJAal0wyIAC-u06A8qJgObJGOeepqDGJ1rb7jmqtu5bab2pqHc4Be42NsZa2nVWhrMYSRzABgUvTiPQNRQopLrNAZpWx2nqKS30PNF58nXldzJTf-sEz2f2FygjOiInVKNuWfm4aSVRMVM0_mUOZVaDJMwZzJ-9A39fuLkJNAlnISLXKchUS41VLSfyFc6pwqET5qzUpBJzzdPZtI1oIqPfi6PcbeDugoTAtCYdYzqFp-V_RvzZZC17K9Jt-Tedh9_wrpt7Ul3f4KzKwZ_8Mg8uo9yUyRBp5Qd7ZFa_E10diFWOvMk4a7e_DrhEj_grwB4AC-OJ0cKrJCgKW1FZtspPnSK42zh7cpTGFryCD4y3f7NzMS5eV_HlqOmex8NAu3Fbkelz6zpef6eUlzrchWUo6Il9wkg4kmeKi-jbXAI1tUtqwigE6ojiul0iEgU3Vq9YmxlaYBBKCs4niv2nHbZthbymoT_gMK13LxncOWAIK-iruBpk-e2jYuufETmvq7MA0ZouWIQTb2we1W6hlkYcRM_HTtCxdBXIvsfkrhyO-c2dT2Hy9nyAgayZQxUy7jolWyGVxiyS3u6wkXStgWy0X-RV472lNVCVyZ5H8xLr3SF5XqjRXsHWneqf4QnpH5_uvWBLbqMUEnJ8gZyc9fYQXchXY3HH11cEJCYkTQK5WXitADXXjnBD8_HkSbV0HPXa3UYzAb23TMguz9mi8QGC_045XjTfJMNg9rH_IGxIT0sFKalghTRn-DGK-bvcxgdYLGAwsB49_balID9riKCR2--bvvcuSwzqU1nOa6Yb6vdcxSGMmHuLhiH_G_3Zj64uC4Sj8MYTZnpLa7tz7nqr9cSm5qk8a8jIsAuxy3J2oYYZHtEd6d8WMXx6dPggFpEOZuhgkZOwHq3G3D-0Jrc7LZC7ehGM3uNJPiBy4KQhy2tw7Nkbho2ckg2k12uNGMHPx_VYf5fa53tqL8tNu5MYpl2g4nBPGSIlpM-kOM7m570MLpaM9-QFhogqREftlDBvCA7TTTzD8N-Yl3VRi-nlqzgt-IghU5B6_LiuwrfYuTU7SKG_HEPQYKhFxGP5A1i29H9uqCm07njkWD22NtY_xqxBDDt2oy0EXGUApSSokGyibLF28oMX_4x0WQaH3x5IH5_Z8l_zlKH-Slm_HopxZ1fc2LdfjEPLOdcuJ6-5aB5k46oG85N5NKLA7ZFLT_qdLmKNB3drmAoDOoWezMwT5iSqk3CHWlUheIgX1bGsSdoBzHHhnEfZTsICCWDR7gk0nfQD8oCC95H7I4AqCIS4eGXwdKVYETPIQCPPgFnacItHBRHywCV8PKdujh38rxnS351ZiRSJCkDKoPaTanuv3fRn1c4PuyoZjujvwQJor4PHJREKNAcmpGDaE9hC0FNfQNEl8g3KN2TnurPN7dTmuND_FVEMMBZZXvtxtc-t0CFUVspMSMSbR7py2-h2d5MlDc7oENT8NtJ9yS8Q681W2FKoEeXlB71sNtWSQ9REqpay8vziVQB9bB1nqchXeUYoR8cfI1X0EZ9HRyOkomENxoVjn6DNPWdIzn4WVGWthuSFvtMt5IP4mojIfzZvfGqddEGCON8kMPb3F8iw0FMEJ7lzRz24fxD7TgVOMvEnrqawkzxs5Xau0UwKIxkbBPVP0q5q7W3I5y5PHiDLcKGWLtVridDfd_qG3iBQL6zxx1RJkSBvbB2PA6BQwo7gdLV9srarA0AHlW0DlYJZGKSIjCzb7_4pX808zlOKYqJ7WLwE7JKtADFPoKw1_Yw9aal12f1SRreaIReTtTuo1JxOOvD0QWpoj4xVupXCPoUJYdNTeqdI3O8qGVhumsbMoakM7M8EQVzjZIPVw6BKfMcFQWEI-mohTjYkoytiJ7pTHlba_QKPXZeN6klY_vvriw_CqjedtUiDdZ5LKU_WSdB2_O53txZO2FETNobuMp-a87IKr8qWHvNKJXj2HBz5vnrS6wutAry3ONAspEbm78Z7C1Cydh6DYj3pVDBVYToAC484S64ezWQ3KxxA_kVDgI5RRVUFTnfhDAo59M1rMyvfxTWq4mP36vhZdMGz_7ZCV_Yhpu6tkswlCx0qcarZJHNtR9laUYIPTitS162q5csvFUtucYIqxYs3mDw5igt_vLgDPeAcaW1IRGAL4WVgHM15fBkNcVk1Ob-iBRUldp9VRLMbJgrsE_0ukXlYIkuwD1dA-KSQGC_ou-jyQr_8ltcMR9jOhRWW-bmdLTwbZU4LowBbNCybQ6YuueOrb7-XwHCBZWytKIluR7nSd7-UugDL2AUVo13X4HImMJCS5mOkyVBHbuI-XpeLAsyQytCWNvz6hOta7P8a5J81a2_E-fd1gzUqQYQjnnGutDGSeeoeufBFnEWn5x4tk0bOKyHeEYH39S_IsL23Dw2seK8UHWxlSi4-aPQZX47vvazZA-68uebWLvKVRqFwZLDdABDOi2RcGCwMQK-F9W4BFaPccdgxxsrqWT29ud51tntbq8z_ikQv1WVWi22dYIrCzrq0OxTXpAqshwMv4q9u8lInuMD2VowLARcL7EP-0FjRAr3aQ6zYYnDGiLpliFSa42QN89iXVSpDo-FQzxTHnLdENwYCIZffsMwxHbOVeC9pGhG4M3eiI8NHtYf464kLhTlLvZK7UchPlU_lWZ4dnNZKnVAotpv3sF0YJgaclkOk9I9I0BIeMs2dYVmO5l348dm5izua1aeglN8T7JPCK_kodUc1ulnPGf639YJzb0eSDtCCj_BhPDTgpMpvbanrxNTRdW_7_MX-agEFpGS8DF-Aku5875Z1yBfhX_6Ye5GnOFvezHeasGrmP4wGFf0iRcbgm69DgysSWFf9gbSdGmTT6tVnHfr_7b_b7ed3LuJQPmvWje16vyo4K6igUflZ7CYtlcp9su9V1gQCDSfjo7M9GeRnqYMu99XTShiy_18VEGODzo0vg1UWWISVVDvGp4a_yGyWRdu1dJqUG7iOLLIfOMZYaNUD92xnroxIvRAvDUi8wrZQpJ1z2qmaxXHoCjQRgSrJhbjs2aEdCWjLMJNI-oQL008Oe8AGJ_JX0ZeJYc28CSdXiRKNXyeyIrYQlv1_EN1xNUxDr-NkZVbm8WAQES8YBCMwVXBmnBwBE9iUwLAaZuD0UQy8p7pSR_tZfdexJwNK9BRPgn06Qo5v-BClZbbr1aD_Q8PR8Ez_8EDMX3DDEcKu2OkDzrIVVUXNI7CBQdh6IYloUUVC0f42vVOnv1RAqE03fPp2oSnor7IeW3fA_LQDR09mV0kfbH1NPcKRf3iIOxHtOuZ7ZjCQk76MXWoFZ_An6AkwEukUDtlr5c8eTTk1x5yCUwZJw3NG3vmpqcYTeHwoJkMTgmhgWxQJWR1JZSVrBCk4CVyz3wZqe6xODJqDOj-ws8OQI1WVhG5NnvJI6unwGofxZ0Gn5r2kh7TnhzKxM3ajOPBMsO2M5S9Mudfx2QnlbD4yzp4E8aleN791fskMZrj3Iu7QeQ5XMb5SdzWuGvGg0GY0ow-YCa-bydBfXttYXwMOi1v6ybIYx9pxwQ13whnN79AAqpAcxHQ3xMtkhEKsRNoNX3W8Xtd3pgXIkfGSqGoowwrZVolGy5PG31Op4ljLJZqVopXZoq5rObZMcQgMQTWJKEN4K_muEWELmKpnWNpZg0AnZlLlx6RE6voSCUB8jOr07bwjvJ8lPhYxU4pRdhpPWY4UpiU0kvJrwhrbPzlMhUs1O8T-NPbfmFCaSn6aSh6bD0JSGO10sqay2K9mFY2wHcCBrvD-ge1J2rgE0Ea3oa4Rc4JSqTxvlWwtp3GyHGdzdif8MnpFD0nNrlPPmF55pGLuh8C7MTllAJcFdu1xzFXR8W1AK0RRV9_rf6kTg94dglz8jVC5yNbwu8XvNjsgRrogGByhxwWD1daqIh9XpEva_foxGHsWRESIxUO-Hf-XPdy9tsdslfKPcEMWJ1VpPG6YviG0GDwLeelcmJ6hcwRrKsAoIRmWqIOv4bWK6zOV-1hP53wemToP4gr8lDrGAVy40Vk7GJ7EB2APxljfQIf3f9-XFZg3UIfZ6MF9BEBtabU02Fvn0B3gh3E7NM6gri7_1yiMFmVlIySkGm3VHa7FX6SU-TtCw4MrxyRwWa5XpQ2uufaHQe9UJazH5pRSt4Lk00hpG95V9mNQgjAvI7Y_hD85cSXIdJQR2Oy09eexbZa-j7lLV-J3_bCFTEzNzkYR7np_JhG15InAWTZSYKMYUbsb5Xu4qaSNZBOLGb9_oZNrdnlCCAeEYxzhkmrPP8JB-64V2JFg-l2ZfaA5iW9FHjrgWCvF6vNjCfATUa6RJq7-p1nhXERqH9k3dVysioVOOwtfa2aMlkFq4DfWXPdOWqC6dBK2KOn9ZwiL8aIMWw3JKFxGO715f9IlFUoHAYs8ndHjZ3jcCXnTewuixdMqHEwMqWNtFilPpcLqW6fxZn9LCgrVAnM_SRZVfUeIhyHB3_XsaGPw-eMlfhYAgO94UCO-K6hjdLGOufjTlBpZ5bTVOiD7fLyb0_KjHmZhYYztewXJRozsk40fQ-SIdcKzfnvaliVjhZgLdxHJ8OMAlPPhDuusNj3szUopO-0Z5krOm1QihFjWpHUvdRJ1CbtAwrfsvqVEE1mnerrygiaxN22QQE8-T86QnSHNIlBitZIUsXSkdG7j1Yw7bNBxxiMCxYA1ZsDrS93pEnIdQCHuJ9re6rNr2A9ua-8aq7Ul_A2EanRWgWoFsQGK0YxcQ863wVcKLE008t7pcxURsmIYSvGaW4DJjL0T_x56g3sqJ7zEPhYLLXsOVlRCuECofR_f8OiWrCRJ9zNiktjv7_WAThMzllPeIE148aTPlCVTEdkAAvn0XlR2mRv0ceWyqcm0CrdVUSz7h5b3SPi9xid-le6XJSns7bPylDqWEwLpyq9dnoFFyI_562fLtrtsI5WRu8FeybbVKPKKK9NsF_2Z-ovALo7B2eHDevVX7uuGj9rHoYy97grHeqeO4hF9zJiuwZ5TxgyJtEp1eDJPWSF9w2eGg8gYO0HYQMJMyKk-I45sM4_UtZMZOWlS6FgZh9JO8A6ulNpV7BYGJiR7nGkyuRjvUd3MCC6l5wSZRmoAIx1ozERwiAVCqeyRPcnjur4QyKXOdERz7yiz86R9XShPnkgjMfTrOX-dB2zadxohbNzH3ARqP5SASxkcdSHvO1-zlE4ak--gofuGTyCVMeMoz7lPlhJTSu9fPUEGrK7JaaRIunueNPjG1hUk3OJBDXq2ut7XDHx3Rl9ZMgxdNPMPUdi5oi8mHzJytxq9UXnd4BUlASsLTLFoIrQ9KySXC0ynIykFFgR_1dgB4nTUmJpkF6cvI_4zqRmBWc4LkAbU6FYwuPvgtF5MuSc7uejjG5yQWSLXhmdcp4opNQmCFSVyRv5NRoClOcvJTqkI69zrgoGiNOLKLpQIVZyqBc6jsd1w0ToKiPXpj5ovIDh9EnrF9GfmQbvraElZGVkPmV8uueEEZRjrMUObOK7eBvtlKUqASnlRNto8dRJ0FI6VuuAxyqK0NcRXjalPVysHSpCbJbDZFU-D4YTqLT5Et7tmy1BAe415BNgL2m7EhSit8hGOWZFFxDKC7tek2ESSYZuWOvyVCEuqdBztq0lkODYl5zNAMBOy_uiNZinciI8w_Pxk2q2DMFciAAjk_MVhQnPO_vMD8hjm2ylFTkS8YJ_m2SYfB5qSgBbYp7SHeCQEpmfFkN6fDb94efX2OR9XzY1M_-8y8Zz07kkbmF3MHV8kViOuWMFuwkH2iGeBpE3B-TvxhJJYN900LnABRbpAEyIZZK1EbNVpIBvFl-vpLpOtNj5pmZevf_Jm_eCg7Rc5dejlYRHsKGCk0efnnEblU4RDucdNSCrzMbA7Ql72syK50a86s-0ljfpcqM9opE-2pVBYtg2p6jd0NCZtpp7PcImLD74hKJEZxF2DYpdORdyx9_lLAUykwimFHKGfCCrx4Egv8aRHGUNvvx2Ud9K7tGGikmc1dZ599FYoDIUXuzV0VCnrPBlCzC9Qp-Q2AgQ1vr-66il2zRq22qpd7w1K8sfypUk6ZCMHltUOMLRaA6gOXSnwwO7qR8utfGze8hreMGHCSxPkg_NukUUPgNhXTRQwKyAFnYYnQQLIq1K8_XPRB7T3GEO5rnJqTLjsFZYEPC-kD_n7oFqjXNmF3eXtCncsWP8gZn3Gyur-XVp7fLb_KKQiZI5WVdZW1x3E7oFAuIDikaO_XvnXJpapARmV6JGN8sj9inlPa23DgjHcXu6QMBUdkBkN0-P7RBLPsc3WhhydWsE0VLFGY1YPxVkllYKkrBdj-uvbQUKxTn8-14nXXW0c-PiVL62XlK5hVZZyS1k_B_HB6w0nakW3vR-WyK2Vb3CXq8cN2Y6ZyPmlVjZWB5u3O9f6Jz7SXVnL7xboOd6LWL85kljtlo5FZdysd6OSPFpmL8rF_v6TngsG5H_w3MjmFfXnZQQ-u8LGsOhCxTZ5qlqqeWsLw73nudStw5o3yfBTfyRZppb74SuaCiZc4lokLqfSQtptMbP2kULmR4jaDb0fZOUaEM75QQVXhgK8DoeLrsnq9Pw6yfgrRV7Lhqw3-tRmjyluElGV2qXJNzuhhW6sNYPfbc_SJzseXjA5cX_01yT2wkMiBxWfTVg43hMbUVu26qeRETuthIepOMRDkt_JRaLM3fJUEVMto_S9D8u-XRLtTu2_5_PhWlyZgKLhbMrH4vnecqJLsr5PCVyT0orc6D_BtnmGm91f_ZsWsqo4iQOvSt5I5QdbXLluT9x9owaow-M0YAV392XrKInOn059uh8bw3H4g-QCqqkn88fQ6Qn1An4zgqISiH99KjKVWwjPQEUU4UDCFobK19UHXexyYaS_aaKkj36d0oCzBIRmNxOPSJVp31r_gJxbxNtXZGcY9OZjrXeYMXCq9_TJhuD0ZtRkRrlDI0T5dI0BSPof7jdE1yefkMK1FLgOxkyDxKvi_6eNa1_lM4nUj1_X2k5mFwEJKTjkem7BLSNRSAf4LRkV26bN8C4Jne7g1xBAPULGR324EqZ7tQUpthp0EDaD26yFbkTUTY_F-gKVjcc8bIvr9n_WRqyv8xsxvHAAjC2zwdKfzlc0BVQ7FOOnO4HO04-J9J5-YPsCoqyl_71-TEkvSrk_DB2uso8Xx5itSsB-zruKfvRnOT0aTwwjRCY6KfDRBXfEP9lIiQOf5OcTdmKQN53G8UJz308LZaSrIay5ycwZPGhHnsYP3dsBToWkkFu7SAeH-SzAWMDFqaUhFlnr-3H-MbTu2YH0SXXE6pM6m2NUZ8sdBFnSJhC4XJjQviICoApc7h3di9d6oKrd85ucQ1I1Jzkfno23UN-ku1oX125KVW_IFdOWaI1VkTZLtBUBKz4dGHZDh3cUM5fg5k2ucxW9Ymf5b4mkeS-nEdQrCotAgMxPDFP9dDpTSWlQF3dwzf7t8HzMkRIdFYgOEwqY6Hepvg4brlZN1Jvg7--_Y-tGvF29HU4REpySxNUmxSwSKrlcInqhoiVWO1o6HcCIW6eUjRXEUCpfvGW2Ts8ILH3YiZpiy1fgltyi1ucLcHSi_YMC_RTETeo4FbwDQ9TE7jswWf3mq9e_s1lhQZ_5ARewkFV-QP-h-D4yr7DH9hIa_PqZDK8E_tGFBgrI9fZzet_PCeyL89mPcTJGraWucwFGJPP8knQ7GG3T6LysXlHthUO3xviL-kAQovV8QVlUszH2BW6kUz104B-297HY_QxucvCUFxqMpv8GpKIefX6elRKnk2UaQf9OJkL2ijb-FIyPXBvrVMNLOTy_ZTO41K1L_xdVyvK6yjLhYlpZqC5OPuCIbT6h2-RwuKxlRcRnnSzEkPtJLsHCUB7z3y6ZAW4Zj4z085d6X4sC7W_qkl-1_vf_fmnd_yB1gqStLi2D9RPT72U-GgsO_-11Vqai99WXELKEyMjBzSqkN_1KaINll9-bcbRdX-6QmeBICaKVbEgFEhUwL7zA6e_Xioyd9Ynq4hZ12szsinlA6QOXAWCFW4CufZFq6WUZexe_SqjEHQ61cO9Jk2JWYyu_at-xXMTKvB2vXTYa1xWtC3fvObHDLqT-F5uE_ETzPxRvVHY830ZxdpZQifm0R0MHgoTduTsPVWCDVFcox_Ewj1yGPGBA9hU4hO-pB_2OQc1kIQUGR_X3gEVAoiPKi2aOaBbWlPZJtg-5gcAemq5OImS82KaLCYBDleZnR1cTzq9DpR7yyIvHwwDoOvqMjQSXGUDxbVMl3BdkNp5wm_ruFjwmQWxJ_S8gsYljtHSR7hHRqhFd9NhMTry-xTdSo0oZs9BM5ds4u3do__gw6veWNreQP9CyUrc9qmm_ptRfxZZdTP62l1pbJE2r62KZYCo85GCaR3raroene4hM8P-ObK1dTNDndsFgFNRe4RVLR7TLpbaRCrngSzlopMTH8le7hZ2-b358cpRLZqqqpLbwKpKngMd2VwPo18mROGTysfsf3K7rQpmXHhPzTwzKIif1d3Wxjzbd3EoAiC0S9frbVQWzQrCnp9opWcqzGhhpk-BSUAQdUtLPpeIcSLJLELOZEu3byVz_fG6j1cBgcjcLyuL4siT6IwnxDGdMbopRhbDyDsNS9dP-tygL6uzBOB2zif6IgIrkKLotJQv1szRUOfp2x62rl_OWzKIWxeDAMOdrlavescCGvVhPcD6Z7xqAvG45tztjwzgZRrp0hkj_9ZGJkP4MvZRRcJf_XnriJcNlAhsPi_2h6mArnVWUSS2hgNeOHdlft8JNeKE6x_s2IApXh9KeBkV6msoDMedaQKuz2Hep0XwKVyQIB0HWmF1sKUV_8hvypZMa9UvoqSoB20F727zJ3L7emeRn-WS0KiJeQcWhSc2-eXCes71Wz4jbVDKnPMvQMexEyBjW5pTvF2J-GATFxOXCwPJo5nW_9yNcWiK8RNA7_RJT9-xOyRgYKUZ9NvN0G1Yi794cQ3Zh6eUyeZKqYy5cXtUwuCOaJP8EAIU0Zbg_uQH5y65-Uj9F7auIQWCre-JUV40VyV98ymoUnjSSEZLm_K30RvZwGjJhmGgMlrW5gqcxpf4Dd0qBgSQQMQTTXDae_Op_-QtVIIXjx3m12Fst1q0Bc5uo7egT4tP5E_yykcdxuvGK9MecGwreV2Q_fe0V27qPCEzhtPGH2q5yeElmpsk01NwRv8VfbMYz5VdUdfBuxO31jeADcbSTcM2Zm7SlSLEzdf6lC4KfYM29PJxiYrYr9uVx9ERhjcrZxl7elWmv3vAF-OktSTdhXHvWpBXBVwyY8_vkjR-I_ZE6DndCFzVSNvKoDW3Vr82k0l0pyL471cm3Bb638XkiLMrJ1Msj0A6Fu31FwAG9VCXSnng7FHS2LU8yrhXQ7gCzYCIiTqFmSyJ-xsQjwSabseJ26fFf58QFXWbUs8AZvLkfgXeceDnbrJhQkl5PtOCQy1fvHLkld9p6D02GecUWUofJ7ruVJh4ICg-In4_vXPmqtgBrWxA9X49-K5Z3BCW5PlodSpQRoSdd4eV9ER6gkZofOpgqHQlQvIXt6J5Ew6SHJnz9c6euR6meOyaHQ3Raz-mDuG6F2_EjrCE3UMVirs9ouk_K0jZOAbIdv1Dca59KzNa-IylWpxXitUInz0Hc54FeRD60iygjvOMrlkIQVpMH38HjoeAnRqQFBYP6L1nBE0RaENmYSUrSJ6pCUOayhz_rTT-_UGB9-aFoBG12NrcGRAYs5xo1vc_rNoLyAy0jUSWSzFwbtky9IfCq68ZS1q8HgIISJxfk3QJUAphpztud4RKK5Satd2oIksghmQei7Jhy1N3qoihh7URq5ovSN0cBCUbfDxNMfN7sYyQuubE0xliSOQT6aQ_QAbarVawlVGOtmpJwMwO6d2O0ec6P91d7f1M6Gt56YRZ8fzLDvZ_qyAc9RmN0UYqS609gE9Svsi0QicSTCKaxbF2duv8LdghNIaIZfiBEPVW3EvyGm7Xubuy7RNT8hmFROilgcLaocWkgrO0eY76P6-9K4WdzkIU8sEDOQe4nkQGnHPZHQwqDPcqSN8fOeHK4tGFh73iNNtXGbcQbDPW36l-6U-TEXtSmKMnfhDGAuL-0rr6LWYOCpjTPPgEMKgizsR67sPASPkWIPZ5ZDtZLdzeAsZ0uJfJ_I-TmUma9wVxi8IBS8WOjaAy77AiuwN1ZEQ5exAW_uVuFwnpwl4WGNzBNUU6AYa68Tlh4GWpSaHlH3sNQk7dQLBLJAoZXGw5R_mQ_J7bJer8f6wkaHkQmSO-vvdZbeKHol4oiYEd3MXlrpTElbtLJMihUA8Xteib3MewetskkbbGC6zZvn20MumvamhVaBGQyvKGur9L-tG-XYs7MjBDbMgB3M0yTYOI_-jdw9qUi_eGwdrV4JaqU93yEi2s9G5k5N-CYtTgtzRYhsTiRd3O4hN3KEKqJSG-YYeQONa7HSKdMUHotif5OEpcjggV14bvz3CPdeSh3TOtyp7wWLoG0lNY5U8lhuh_TK6YtwUdcvacAgHTAIsbpL9ZPQi65FQkE_7eQU8-dGjP0OsPV_I56VG0vfyK0nsCq-wm0YSoW24kaBumwAv1Q5amImfS4KgN8RW5d2ZqQ8wVq8KElMjsu6auoWHh05AY-X5UAKtf3EBkJ5KEpwdKP8M2f72zjGLV6LGowXfpTwEvQwNKFek01dJyyv5H8zH5CNCtMcejJue_oWznyD-PHQUqVgoluRzLmbxABT8OyM27wY-PnNl4GZxCQFgti7bxPD4299msf3jAY9TthJ1IrQRmsmphDPrgyHjn_50Bg9en7wN9_eRk5e346jpd4LUAxc-iKS7Izrj3m_bxcIvjtzlzcxP2xyTSTUcB8WCJXngVQTHL3wyy9rN-iw_PFk1MXvZja2c6yP7NhjetENXucowKuVYj0J34ojvZ941ZqrcGc6EIKPquawMrUPLLfEf7slow0I4lWN9V3Ku4VsY9hQtESyfN10eUsJGt_7-6GTLOjLDGiO2yr4SHsQ8qOqrbQFAHDXRtusXwC1ogImb8F6QkhER-_09sGMgcTwFlyjf0OZc4e7fIsp4UgVeGBHiYiC3tW0t_qFRPDkSZBg-xT7f3gclWX9bzq-DkJcQQOxnOeBHMEQnMl7K_Z7m2LLpoNpQOgCbOEH_b9E1npLnLyH_Y_3uSRvVanTIjDk3BdXsMtfmntI_hRoG0Q9xPI_ESyhSlw_aaLGCIi1orFMSe3lUGBFFuaQndczku9vatn1HiyRxq6c_wd3Ke8E08b7Udg_gtgoveV3hzl-W97Ydv1_guVq5xQwR8o6gwK1qCQ7Pynw_aLzQCGUGUChjconJm6HurjG2osToHtEd0bFVkEASNNMGALCzKLrYPp4HF8FELCAjoB1BJsU8-qc8QzeeyG8YFMHD_SYDvuVoiGNcupbuTfaBmoIOfP44Wirlm2e7aYogsKO9kcyvsQ1f3mplixBOjx5LINQYxP4YNE27pyracg6vY6Wpysqu2L0mV6Z9Focf_1swk5g7yKX1WWHYC_uCI3Z0AjgPDorinS3Xu6VO7tfEtwQB1PCYtslAjQBFs95yzhizehrLM0RDOyaq_oLd3NjaYneNHJXObQk9JS5mAeOOnrtQFZfP_aopL_eWNxPha70i5ywlmOeSHQNWBWTbQjPE-hBEAl9ImHOyyetcvuq_VAV9m0fSifMHLPGqn1bKwPDCI7ZfIMGjoIYrZAOlXf8Uc_kBrBTqCy_2SIg8des9xB-41gjEqSMbMZ6xdEerP2M1YSm18Xc5yO8jr7ZrFrrK1Vd1S6ZzBd4dFCncDuFdkYgJPvpnyPWroOKcnLTuqOWhLNKweUKNHsle9vz6StnexU_a-dFQfT6KZ37sd8YVF94P1lchoOvRNFjKm_QzQFD0rKPNTLPajLkGkRk-5lD3iyStLWYTfpKp5iK8jnlmuDtyc65Rw526oqcCumdKpFPVEzH1bwQgSHvVkXKoi1F-8Qn0wqg_quy8xaPIcrb3PDPMJWLsO0B6PjxDuZrMr92a5qnbnlKscGd83857kuXuPcslp5RB3PKAKXY3U6xvI1wvW3Ez-EhbvlDf5nVRM9L46GyQfgC49eClyOcrY5V90P6SFU4_vkna0Om4qhOoQGEnxPShQfFC1kKpHQgbsqIq1Yn796k9jpLmbqHska3rjQ-pkINAObiMMwxEABWOVj_7naV_2A0ogUYKwe4nNYPRg-CgUc81V4xxhKswVNFiCG6ilS1hVX_rUr5vqxMZaN672cv_bHbTLp0nadZ_0B4sgFShnTJp7w0zMDSLQ4UhtxzU8gOnN5cMRw7t91GNtJhKYxxK8f6FAaBRsyWVcRBc6apUV8drzQn56rFyzfIWrtfct8YaC8Gu4KXpRZjA_q-cFuqEgN2dXUbzhtIESjO-1f9mFSj7iQrc5gSMdmqK1h_vho0LujkeK_bjwABo6DyDfridR1XYpLte7MRzdq17J7ikiizIBxl3wnhFNj0i5vtR5S6D9w047wkSHIH_4yYYznZJzsemXb_m4hPDuhGVEjzkf3iLLUlKCEz0msUMQY1kdyN1dHlBF6K0AjJra6cC9OvyqfXyPVtkM3w7RG_SJ3Glh_IKw1GrUFJeLnSLspjFUNF5RijUQkgs9y3pUJ5L56imKAQSZ3AYxCEd2muMVP4eRzYxzxDugFnM1NgIJqp8ydUOwezpiJepuc7StiCxA7F6D5B_g_cBjJGWT-kMy6fdvI4jtTAofSnR_wrX16iG4crV-CUf_Odo7e3ZWqEeVu6QnKCw8hS94kZE81gpl2UfTB_hBmJZj1Wq32-tAM53T4bzFOvw8uyq2IdAkR0_CleIoAMkSW2nFXKkAJgu-zrdui-MZCldVBFr37ca7yKoG51EcCrOo8_1gRn2Jiae9WF0SkTIjDPejGJFF-CI-CmU-uDya8v8jgR_0CfRtDUX-PWBpuAJ1izXUuptoC4ypjfTNLtkdwiKZm5vU65y8BhettH0jhx-nSTnB0R5FsDHDXmDPoqj4NS7FqbJLENcqmUaZHtPezZvX84qMJfCRexP3ga56lelng4SzYKXF44Str4toe0Z0ou9gbr-0KpTHUuzklBiN_dWcQayobeH8wYhv9MrdZVdz18Zms5eh63-yYUQmgLAKwPNqC3fhg3bcJ2VFMfKhbl1gKBR9_tLKzHQkkkwd8FG_b7pUiGiCu35Z5ewuLkqkmIB_H9z47cUwJpGtN54_2uWm2Hg1voh-WKglyprRNYu5vaBBp_Pj_v260GchLcm-ZK0ht0phPTuhIo6o0u_NCRg7202Tq-ianH25HVcg22ne7tM816eJ0Nmi4AWKLzQtPRr5fOdWX5B3BPno9HcHYcMx_5MlfRkErpKdfEl2miVyVDTMCiLxoLVObjCto80-B_83ZroG5wcm_Dh7LPpQngLWN2feEdglUjRm_7EkvXEQX4yXgnoaVHzFjB7GSmJEztPxoU6eC1AvtW6Y9HvNIQsYvmBlP97GWNdinf-iwHZmIpMr-lAQDVidLovvMUMuPiti_nzxYzya2OhW1Y6hjn3wslCKUFbuNJfRLQp8y7wU5JIxZHI2FxPWY4LjyKF9XySfP2-NrOIyPdySYgSTehQs7LMrnhyNZIx3r5S7vwZblwSlHJg_ePyfsm2Abjd0RxyyX823LEwY34ZXLO3DMtItytgdS6QVsVuObqrYmA2igKVLirK4QN7tM7ujlHavMeQ-a6QQ-Edxh5psDim0sVC_EaDRroS8MHya7V1LBgMcPSo16vOYKIcAvr9SOcHQ95g_fXsVeMjnBswCaL7VIOG-WZe2HePIq7imLF8BFmhF2hYmtiguFOQKKguz5U4qENlb_yPCwk78DVu_7zzPUDqpbyyAbRPRMx2HW6BqJ45TvABxXyKCl-JgYLlUaM-eNcdLuY74dkI_fNO0-GhMEmLemAZXNkj0k6908GwWZ8mhVhqbdbG5tQdXcgwcjQ4WmlT_1jrgTFxXIy09YwwPrpi8M6vWWDE-o6q3JhbuGQPkmEue-R1T2Kyh2pBpn8EHbatF45nWr0EGb5l9o-_WbSZD0fIaOM4aOMvj3Dv4bR-00bjF0--CqpXqiUQ3ZL7Ea73YubeGgy1R6I9iWMN_0L2pCmlssjH3NYP6JUvekH5yNYfaB9o3XEZbwBWODh--MwR3D3zqao8nvJNi9dRyRU0YTNINlQso2ZjgTEJoRHTn7joQjvhpZjKq5TQ5r4hqckUZFhVoweW9KqFctRiz4MO6_Y5AQxmmp1V1mcJ6ezW83iSrHVbA0Hk7auXXtrg95Vp7F425-4UD5YUdUXP4LCevCjKrnz_vKCFxUAZ5-AqKNtUfNIJvOrMPSuOjeGFRTBpO3rLO1R_KW64NGpojRkWc2M1YrFd4OBgT9qRyXXuqIvFVJDPr0aVlhNGXdEHOpPWBe6TMmv9fmtW8WWE3IA8df27jEAxh-w9Xh_LSoWC81KOZ99T1eWrcxBi5FEFBUtLu5T-jcEMSmmfMuLpI28r2uZzT29FfqXGGXb3IKehA-APd4wPDMACeQfZcwVh0rNoVGOXkB0KZudwLQsNjp7rpAQxHPyKW2rqF7tK9LB5BNp8lNrIYs1qjAakgS1dMa-NZtnjLpjkz2MTeQ0FNAb_gBtM3gg_wJL6rOnvIXpYEWGzj1hxQ2C-tWPq0Cq80iEJTM1bYA-B8U8lCvGZVIkkOTIWM8zXByrzmRbqiI9EFiDclUNNpbgmvDliJ5TzevU6lAttMejJZQUNaWcCzpnrfn5fmv-BjJGGJgdw4wchCR-u2_k_y8lORDnw_e0fmsjUx-JJMzF2Wz7ylIyUPs9FLJ5HSxrZjVcxSJ9bzDtV__e6RyamJDeaYsp6vHCAfCXOqrdVSNtSwKJ6zsCupdSCIkrxinBdtkcalUj92uvwlayvERPpPWKmNgioQTupgu7x1nJyagKLKigPpENyqhqHtlFhR8nkOd1tAj_ecp9nWjWnwX_IV4oELBTZ810Q4ScBOZjIlp_t3qZDN5h_8kXXSYX4G8YGblGFG9DwSBwcKSwHi9RySNMbT4Ox4K5E8Meh6xR8yyLp653EhPfnDjVHE01CzWpSCvzCWhtuCh6zOSjgTJsN72blLeIYM--FOh2_8aJD7hEDt-vBTE0S_J_7a0sa8Wd1hxkbYv1PYPj7qzMwyTN32pacINzVBuHDQ5PZS6BcNiIAIPDRA9H6ApWTj6eMP5ScH9f1WBiRW6HMcCkzh_hr_h_LT7UI3FO7U0F4ynEvdyF76mQiYgp35E9Y0UQa2uGYDJYZJRLVP7da4oZ75BFK2SpEWJYcHsxt8FeluoxKRy0Vz3ahUKuMXhXhetsmZn_NqMpmp3BOG5szjb4MUxDUZIbJ_112teBddpNkMNcnZJLLrEXMUs2Z0bsNeR2yhWrYu0lCEaJOeZbPXejFuFbrtTMEa5qRH3u7Bl3CoFauYK7P3vdMiH1USeVg1GOynhV9L69KqJw1MkQS910B2c6tutcSILe44wZtfSebzADPRiCrFZPcrXF_V3eb-zupMlDQtdCsyfSY1c58bxFqwveYNImpX0WX-HIsEJzg8JOuZkGlCBzdn57LzPCWmU6JfnbCpGxhmRsCvc_WFGy1F1_1C4E7QRwohOmvc-BR257bX5ovqgv8ozXVmUMbKT2hEfjWzPOhF70ewo3JBP_F4urpJot1TLsPAp19iJxsRn0W7LixFCEQrFGznEJXW50YZZCEn-rF3oSZZ-i5sCSUr50kpi1mtujb2Czf-0bY56D9wxOWK4dUG3JfSb3G3sfO9jEANtaQFs1Pyfo6T0aCZ3Sd2ByQ6S1pmlu6PHpwvgIhXzbeNx14Sgk3gdMWOA6c4WLhZl2WZfiscnIEAzlIdff0vgFK3_2mXorXpyclnpi0tCncB0ksvPBiVocF9Fdzpwqw0Q40_5YSEgycXGpBKxHiaLt_lFQNKkir-yzQNStYHWDU--6YaZQ_dfBY80n21I7HbwwU9L0-ad_W4yrSgVTnTXxsX9Zye3UOnLl2gyLMlxAHCQCCYClnyjSRhd3i_Uj3nW9LeAsQ75FE2RzgwQRqhBpjCPKxk5Ynmvgt1iCzMdODvmja4nREtStlr1H2fSR5gkZbw5qyuXiP_IXEuexLB1EAcYvHnwjjzjDI4JUQBdw_TxeNuTGmWg5YVeAFmTZikf7uaP5eFVnSFD_HDCIQRLr3mN0ZRkmn9BxfLB6DxMKQq0Aoas0_Cnc6kPRgP0dNvwvYFc4vmJV5MA287qtwqVqTZh4Jy6Vy2bQZhke3tA12B4LCAk6aR2h-HbnRDSgGEy5uMl0_ZaZiYf_W_eJCdnklhpCE8tBEDLoAam_26e5TIH3KU99yudukTRZWHEBQlzPUuEOjNuthcpOTroH7SMP20e772R00zRI-VooqZYjoppUOj1LhRkmxvoSosJ1kU7X8qVcMu8pE48o1XD8Lx5iKLqul8LudLo8RO9xgKiyKT1cp3_3jFmjLXdMNDZSwZVn2busEydc8PWmUkNOQT6gJOvgAHOQ0_iccDCXfSDLPc8upf_9wy5xSl4bf8xDh-xCd_-mpPMl-GT22G9zUWsGoelwa26stcsFLc2TbSNftN8K5scLaGxigQteHH6RaAEe9u575lAZ9XBBnNX46aX4nAZzKxrW8CBSfsViDdkiRV6ZgubikST0GkeyGExcibqeiE6wdGhNRDhG-aA8Z5RDcue7BrXY2VaDhrgOcU1CRvhZ11CuczsZUJ_xxrFYWWFCeR8LHaa1hO_ukePLJnBP2PmSGAxcEEnPW1v_emQd-CuGCFP8tFmjk4GwW3G3PyB752ocbtXfGRVS_QEIE5y-0G9_L4g_Xiwgnzl3QLmItN4-X_JtU2-Dx7m_aRdgbAazynI5YM0YMPJPJRpnhltGWs_o5a-jKtCBOmbA628xne22CL-8wBbJ1x8lEF9DQ18owBlyrx_CCFhNac1ZmmUxdbjLRFn-Ee1OW8d6Yz3aAtKC9u74D_08XxBpAUWD2_nOPEYQxfbpeUT9W6yNG5wqZ9_rNe1CquJgl7KdHcqz32d9fQxp67drN29U5KoesXyuQt7vZ7b-kq96kEZ8P6VorA5ouBsAsAsXP0pALoiiTbInOyKxxF_FoLnOZLpy0_bO2dyki67q0aX_mO964PYdz4-LIF6XNKZ0HI-Z9YJxPgxmhWvpsR_FpWc1pKoCuANEiTABHSd1tH7Gx7THrmWASIRvcVpW74vPdGKRk5U1blOaRVSIXWouNlfgixWyqknc3YuyxCVVeiquwrCC-tuwz4JWuZjh1z8hhR_K1F26JrLbwfUUAMXeojPQKMEVlAHZ6v9RnbwSOg0agU3KJSoaT8kugjIk6J_fQchZGOBj6o4ZVnpLvHAxr5ufz4U-FNxTmi0waC42VHwc77tr9-LG5Ph4AKIptnYeQUn_9caiSW-kBDdlkQE8jEoEHQCMU-MR3vXEqtHLM0vtLuumy485u62-ku68lLC2BdRmH2WFHxoibl85OxIcWiJMgP4jf2yZG70AbrZQPrYsvPwZmQVgIGm3tj_zyEUbZaK6mZPRo9kd5IjY19n7pzwF4dKcz-n55vVAnuTCzVDsZdZER9f9OBw88JJGQSy4JtICFUefS2qyd7vy0eo0P2UxGvwrC-aX0UtLpmAPMPerRt09fEKsGvpCk0AJAwKy__0-_uxDrZB04c3VwrPYZl2MMKLV0BaaHyiUtXsZ7UvIM3kW5PPWQmAkU8vJc6lGvpSY8NXhYy9lCtSNIh3aoFvAOUaNx_ZKGA10faGJhRtCCGaiQkr7gVppoXv8yzAQbaf71E98Qzl_B2NZFznJtH2PXcAtT3geUDzFE-4wutpIYseqGC4iF7fsLwzZ3OmiqElIyDwinwmQ1kZiJ2L2y8GRo2FrvsXAmCiMid1YpmDtMjW8EksTlxJn1nRAqk-thIrQwWw99qTz7LT_2EGzH6LeUkRci8COVDqtn_sKVI86f-KxCTsFukAK1WxHefASppGRYIR-EKUceoyiWdLHjmOPOc9B5ZlD5YTsiCEt0bEq6E-ORNzvmED5LQB5gT1mYwZcKK7I9fxEzu-ro-j6AWxEDxDnCPBGv6BQNXIqAYommTxraKaiVuaulaMpdmTuHxhk2RCbL2Si3IKWvW1vj27oXyJ6cA8je6AktWhIA9-0WzLSjvwvr7Ga3345fG0R7KUoVCwm8syVZt1K9nEIzKMn9FNaZjTApmjXANjcgY_XSS2mw_-e5HAmQGIJKr48a6km6hDwtKvMhx2DDO1zhMEylMpbKS4J-ZiOdX121Vu_Slx6bTbCJsWTGpsVH3o4udVRgebfEm3bWPfRyFQf6knmpHojzphY4u2bLHnNN4A9Zd14bNXZgcZuWFIhTm3FL5hiWkEZcxgvSOdn765-rv7xKCNlfgD5xQwbGuJfH0KQcYSHDn28aXKEvnnlQLfBPoRRVJT0Sas7Yw-JnYHI71MykGVdQNNs09mh7x7U5gbA2QWgYViQWEkq7avZRod2zR2OZZmIOLowSQX9Gjz5P3Fba2eWy3men729oB05hxwnCSS6i0glDntOzhn5D3q2krhv-f1ImRfpt0c20pCddP-Pfu8Bfd_vPHueXqvAReNeS4gzlahDwZLNYCuzTRsvxomaeqJ79Vpj6e7lFV7DhW7-AFY3m4rWoTnjm_9fU67jF0Qv5qKcivjvHrfNMeU-jyOQakRTfT1zNAQuBt_eMCQJMTnAFGXVTJxtW1ZZE4xw5I-WGtjXYuHUSBuXHhoJZ8Ps4m2EmTVdBJEVZ7vT4-YwIzaZRNqukA859Zec_zrJ-HV-mMhw1qz_FAHaPNYkZMd7bwxyUQKEZAzMTP7h6dRPV8HkHTIZTZZGu7XyiN19ESQky0TmRudGA9MB-LFe0m1XN__FeZjGDChSzU6JqVbbBoXDkNDa98wtTz5pIrWNTojfLdwMgCXh4Cqz8NWre9hM6yYRmXLY95Y3KIDd79oybgUgG7xfrJvuEDVwTL_G8lBlmWhXJT_FkJOHhqH_kUGUG0RY8oUTGc58Xg_369ZvNiMKoPvlphC5dN8kKH7KDzNRY6Zt75CdGL_VIpc10aUSbXG62EStdIM7gk8qa23pFG_UGjMLjrETHXpjKR6AMNLQfbyNDQGOucsaILD99I-1qWOwa86CMtm7Q1o4NxDZJrmQeXfEjlxrO8r_YD_B71tWKuAIfAnvfDBapuR13BUUsRBIs7l6c5Bn2nuRNs2hh3IzU1NSTA_lZrWSCEVKEjV5R7dAzEvpLrCGFSxnnqGDB_zoZaRZgNzJ2NH6RJfXE2Da9rkqoGPzXtdR5u5ht_FlIdrdMSbavmpzec55PYyZB8L_spENyFb759pqgUjLB-gDjlAgXAnwMxU9aD7pVJ1SxIW1bJ7clobCTzy1gn_Bf2j8zelKNyNSIy5XATch4XaETnJtZyGFLVlkorKngvKTg1Xs5O-4BtaNTXw9tpo5tfRw8UokqXG9XNzCOoQ64ORK7IkEV7jQGeTxxEQI8ty-MvNmOhiUqCdD5A0_SKWUunjvbTXEVdYu41on8dG_hHZzSO6W4QDwVFc6HZZCjRflJe6u109TlhHbLQrTT_M4o7Yl_7Gg5MkUZxJR7jRVFUKwqyOBDuXNEC4rHTQDs6n5yFtd_vXrRJhcNE4kKUb4VFe-WHudrwJa-9zYaiMIKvTi271x7G1U3dd6g-rjsL642enbK68fK6sqhLSHyYj2khkBLorRhFb0dl-sZidq9NKEJe7d1Ib0eI6DZhx7wKqaiB9ScpHXAXxqDoK7Rge4_Ia-1wPYqG4VjYMOlzo7GM3BnpZuxgQ4v9W5EVA6GymLhsptC-9QfR5IiGsP5_cGxxYVXSBWlPRo8i7Wqoas73txmcdjZCnjILehjWNlbvhuflM31IoQn_6PEp1DX7FTg7QF3RKLVXITQ1DCBcswJCo8yB9jdMjv4DBGuhgUNlHy5JCGWhnMvTpQOpYqSl7lOTWOpIw0xHf2fMbeCnOPIPbAJ-mwFxtp-SnGnhka4n73ZNBKuglUoKQ3hIyhHiCMWhNa-ChqDOOFRLwSvW0tHVksOubpZ33oEv64OEMhFUrcTPrMgxIGMOBh5YkWRyFW_Butgvwiitaq8_Vij1S4YTOADcZwKkiizv2ubcM1Co3EfYIY25PUMpzKWdMHcAc798c5v9MODdUbRYGOmyGSZRYannYYmAXKeseN4dgHnOfG7iQkNc2Gz88xJSH8W3sxrNMDveSLu1YX1GIiHHu9jiKsw2znN8D1KDR7QfmCHD798QMljbLMVyhGZo_jmkDylSCWciz_sJtl0Gp44bmgvxHuPqGncCnWI7QyzP9JyLvky08z2iu-FW56O_3cMqX9K96CBWtIXYFe8FI9gr4p4EOLBlwwG8TI_kAGIfoFpUhU8J30UDtgkVVR8rk70ONMcKc_Hab7M8C0nINRaS_VVSGYIq59DIj__AAV0Y_5f1u6vVnLeAwt754VQaYzokMRmDuhE60QvQzn-zglJg1iCk-HhoffiAWHH7PuAQiFGgvK6dVo9wE7CTnZLjED3zqnutnLrp_BIUYe89ZFTknTXWu3pezQhmvZRiqk8bVXvQ0ruCWafLzT1YV7uzn7FPGE8KKT9iot2kpxKQvgJa559F4RADSkoM2OsES7vXbhCkXP6tSCbCAzyVhFriD9yYU6cm-CZbY1HGAUhKFPq9zK4Dc5Edl3tRU8SlJMnS1m7Qsk9gO_bCT2CcYw8Zp-gbWQITjzQuh-mciP49m2YHMQbVpGdKJZ1MW8jdrwFZwvEIcfgdaZ-KDbe9vre7VRo8DQolzS1hcaK-_kbW94UFEm0j4i4urOWQI6bjBiOdz_ukpAuY3unfFmj0qNEH-bxPB7vjJSjUfmwF5MChsl7iDkZbvuEX2a9dacEtxfQSjKQk8yjIehRZtmYo_gy8cDKGQ1z1kHj7GX9-pSB1VEk6H_PuY2ikMxJ1x-WhNIgUeeSh7kw9Im-NSvDzKi7RT9bcK40JskQ86Sr2i0tS3A0BUTOZVbCl6DkZLunN8NwtkLfw8bJKI0tQ2UNB_VSkDe0_v-1S_q4Ava1W4ER-PbCgcrq0G-b8cjzT-BCmyhDgmwT8E5KE5-eiNozae5tmRn1Lxx6F6VT3H85Sd1oVleX6sVYmsiS_HMFzF8NSJgGymvXhb441GkFLQTBZBoGfH5pA5TA5iMkq4dFXuUM4rUIP6qkNxeljgg_dIQiLylRvTsYNKeoklrptHaywY7C6LVWRBMZ3OhJctmGzrQjD9UX6bwAl5h4M7zw2cToCg_fjGyj0rvQ1pvRrs5vo7ymik4f4YyLojnO8BF8FrBu-hKaIFyl9FMbPDqTJiBwBB8HuHxU8BHXkLds5dAsk4SDjwsgj_kR5ya_0as0sF7qdOXRg09CDuuBKn6XPVHwU6tp-pssxjCAQ5bO69rCtCp4mxphN5Hf5AGSqoYEeYLuxbElUBnPvHJyqDWgKJs6Y9eOtAQuMcQuT1vhSXHYBo6xYLwTYOOk_Ja2Ezl2MUDx-EKxZ5r3PSulicS1PUJl29-xBooeCShFmQ6QwFXhKggjQtz18bONNrIn77yFSEV1dHsp29xsLmx4VsZpBL5qC6Il8RrUPHbkTs6_9ghYVtHH9KRW2j0OFfPkXDtRJ82hPHSLsRPmWwamgAoiXfjPwuT7YKNgzuVIOwTR_g_4jpwlDd5Ga3Jzrm1nosXnfY0Bsc6k6x4gYhKKBgypDifmeThexUJkoqhyTRJnGlCJrrQUR0vWqcRsLclFVGhrTujy1D62tSZsQhRA8DTHf7Yl2c4_d0AhYVOtUN5kzw37zppu3uVETJT4hHqJSuUl_VrfMNWm6kZtkZp7EFQIGkyD8NYXAMWm0WbNpbL9hJ9wkaV5yLNFFagqjLvzdo99i3-2VKAQdDGTE8yxhZCl3zbCAvnxxZOU5KgDQde6_WbuRUGP0OW-Gap_D9nFLUgIW1GPzVTVhvcX107FPSclnRNMh8AXXzjVYTMDRdxNhmhB6_-VMTyQZsrl5tX2BBq8yDSJpMAIH2u7abAyWR_DWbcsR_F25xzAfP7MgKw1Hv9Php5yQzErM-jsLEwyXvKRePbrYPbfUW8IztQQwq6JEeJwlZs52rhu-slSW50vN205xJuOW3veRAvS7XUM_PEY00Xc8c9zuxm9Scnk7NiYd4ETSdE_LPZt-kRW7Fc0FcgIe6zy_jzzEquQN_3EA-vZO1ljqKg6x61Zs3XgzACNLDiCixv8oArLKariANkMXp_wcPYtDQcKGLHRpXwKpFZ2bbq_-mvo3vQF1MyD2DSi0T5Olw9FCIBrHoa8-kitXB2IVzIxingcIKYFM1-67GlfVNl19q4lFCz-0_7Hhg-9d6iwtEsrHq8aZKvZNUtWnIQxQKefjiXjoukIBtHcnAL-ptUXLHUNHEL7IdCJNZw7Nw3DMZwOeGoXP_knwW4kbUkcojmuowvg9EJNVwxoIuOgZpctRHq4y9WizizrouRP6QDQCIxDm3BATQY2ZuQP3Azgn36tGox82LKvZFQutVoyx1fDgYWorIOlyRq-WOkPn-gOFCCDLaimUt3hgRNwJ3nvQmdpBAyRXmNTlR_Dt_Ptghw7kGm_Jl3YWCdvq_2WYmFFlVCgDOZMeS-LCBSVA3mVqIYJTE90oWa1lYNc9NHKDaPkmjBxSHhyYXme76SuXtNlaI6AHdKCtOcJ6Gy6jsZEhGxDuUD1-dlVJB3eEx7Ze-7TZqa5s7UWOrH12fA2sYwLFMB22EX30s-CmKnVpIb_56W6NAeyvIWwHtKqh_8MMlAyCIZzaAj923zc_jQjchVnsBi6P6JH0UbkC8AIp7QTlg9zUALktrrrWMC7fgmJPb2CxgpGkYel_Tk5D9RVxsdOa8YiaDD2rOhPSKl9YGVSlUeCkF0mdxfHZOyeUtN7tYVyYv589G8k5SM5O47VyZ77hz49L4nu2DfAwNW40kOzv9fuWWU5AojnTD2T-1THzKJ5FowoRPxvrA8NppBrqUb0_gKxXwa6fJK3dhdFr8_pKfBpWCeRh6I2xuv2rqPuI98eXLOYMPGt3HNT8VPYzN3rm2it3XB7J-pKDXfu-wG7_uqTjNtcF5-7_04mRpXQSsnFd7wuEPnP_3VGW2ERoqxhy6QxBBGNVOdgJYyTw_Z-Rsnwi044C4ZiUjNkzarapu3F3DJdn_h7-3SYnlvHN9ThKU5nmZORsJucesg6PQEO7kRA6yRiv-G5zhZKXHQxvhMujkVniNJln9PJ36AKhru5uHOuH1tEdf--LjdVOKwjbZjE9vyZqdezv3UW5ZbuG6c82r5V3nsx5Ibtt-SikC8MxnIgugyYVb4kt-7grtHeL-WpSUSm2sM9crtpdPs96z8hfaU4R7vXUFWU2mG2BySIzjaephWosxzl04X4vQcv-NHZZVs6vT_sd-Uc97BH9jolni57c5qG1sBLfqUDj2GNTAWZqn9KkEbS5x25NS5CgyOMwY24DnFPHh7l-uDZZoK_GJ9d3Js9dvkhX8wJwBRsq8ZhIPkyH29g3p5xQy9UJP_G19LJq5uToD3PZ2aTUaKi-KF9ueZvYQl0A4hJMjU41ovSpC4QXBogcRITPWnM71AJpWKCdDEF4USHznkyEnwVBIadVTueT4OI_BkslbblZK165xm51Vvl6zCnt40lmXC92aLIa1nE4jGZffZDwAgST-ufHVOEHBm8hgHnzoFMuw6fuf1yCoOP3PvXXnPnw2izXw-CLaTQdCotz0kFC86SMd4JuIfq2ORiQ4kgh7r46XaCBz_IOtvHUu7hYPHDjNLAd999XM0G89L5Vo0hBx5rT8o1UUbTFojisFp7W_GzZHnmgLltvWZW_j1PzbcNKfQhbgwx4otzH_5Rr3ko8YXTSuzfasprnf37a2FPGG5sGn-VTe3PCiOZT7jYIDGASWYGYHzyUspWE7ggS3YQPyCIllZQm6xggsXFF-C5N58B-Pz6IkU61fcZi6TK9tgNazUZ_mG0DlRzv47xwum2Vm7afqLR9UNQ_HpV7YxGbPTLjL0oK2DL6h2Mcwozac8572PkVZJGhFlSCfK46yI5iPafH1hftF-6kR7ItttAtoi2xM6K7BlTQSqjuSb65SGM1_nj1KGzatkUPGJItvuDPuCiOmRDO338Egn7dl6oPg2weB3MQVCz2vuaL-0lLxAT5mwz_6xNrsIlPVdvm1L9EzDTzeiCC1cUZYHtBVYX6uF1DIMZPbWZ4FoRrZGx_eaXFSXprDFOqQhvcEldqEwsaGzDsBSMSmdJzvLRJUE8AHly-Ll5DTK8OyFrhEIQtBXTI8nEL5iGZ6yaIYvFihjrH4hxhUrvTqk2BSUC8TfsEfjzv_BM56E1HLE3KpgTDNFPYHSD2Zt2qZaBRztcHC3x5L5eX-wO-AOh6aT9SQp6g0r2P8fVtQu8BgWCIPLRVlOAY-BNawi6-8zMgxF6huK8CF1rnoSXqhz8gUlH5KNsGFltJ0A-6xYHOyEgraOjVnweLqUo4R1rg9w4Lp2EtmncINhZackfZAR2qUJx9j5qtmNRFvbcJ-VcGKAI7tERZpl8b81uhHPy-t5iAO4CkV9bb57eYF84gHYVmCddqVhnxZKx2IX3Sx0DoXS7zxGyw4NIqG2VZqQYLVZHXsdSbJFmfUPfMCA4KkO0rOaknmckaat3eF2mMDpD5wX7855ssQgmEoKZMATUsRN795sJKweJ5h-YeaWlG0AAG30eDAam9dLe8TQXgFbi9GJ2sje08PmUNkCk1gglmEHiuUo4MX1w3ZT681cdQWHT70m3Fii-Tebohy7c-1uroVy_2FvUqrRE36_k8QvlMU3pJxAd5bhlUmyqC2n96OojslJ-mUgCvFlaBzC5nLyqVoETQt-0GE-zwYyokJS547LJ0TAHSJhE6-n9LJKHBuFwvAGp5SlrSWq9tGFiQvLDIuVhYvSdr_firAP7yoqwspv38qm4T6f9UQ0m-hagKpj2OvwtGoChMlFjECqlL54eElGJkzcwRdTepf6QctOpEkrW1g-cVMeYcKjq9eNHU0_Afe5U5MAVioFMKuGm6p2MeeII3nWm_8EI3kNY7KaNs133JjJZaFa80rk4ZDjizYJjEby9-zWMRZbx-DjRh1HQ91NBCrGvwQAmgmZTCmleajTni65hKQK7_HS4pJHcHNigzXCQ2lLY0ltMAHapl7BqbSenrzcLSr3LVc5mBZo7GkqhRZ_STzx0nA_k_8pTIzMXibmk8_Jt_RpLkP2lOSijW2MZgdwie7ltal4sQiqFsMP_ZUALq9ZzdVgLXOFc0-v4KJNBUghHtfTk1h0hXRDt8lb1M9OOKiRhXRFYYq12X3uRRrC0yJL3rMwYwduV5EEpc_8XHSRHQw0v4cS0hkCeTq25abLp20fU6fEwGkATtfh6bMf_SoQE3RE9O7us5jK4EnJKU7SJNVLlMxag643_rCH1OB6bhxJSu1OYku15MAZ7jkqEwrRt--eVMVRLxjiYVAGi8rV9Mtkk-kHorkPzgyLnD9MS-qQbPXrR0_Ch3SKpBCFfiyUcwz-qsBfSFNUZq-ECjfLFnBMqOCIiGRtCHd9qURIiQy8O0484Kn6hqPKbE_TYFs8qrrZooP0fB7J1CmrU-OCknqw5ZzKFTmB-vC1oZp_jCa125o2bq8oacnq0QpUdbMUScFvhjhTFgG2gPzLCZbnCM7pvDe7Y4cnBAsvaEBBZBiMBwCLh9pjybvFwTQAqGalwggxJ536gU1QP81IybKSW4GXVWs6lVg7Uv_zpveQW0HSHHmWpMP5u_khlMnrSzt6cwbRIRdXRRnhZSCZiVTMdRyI0PNYpLdAxbdE_z27y0B_oOsSHXrW5hBJFQMqXLUOWPPZOw5jUm_4A_peWwV9pSHJA_KbGs2os18MbScX3Vm2E1VgbHxK4FvIBA_ynX9DDcqyGCaljld7zPyn-9qOQWVCBdkmwEJvc093u1jf-60HCS-OXLPVf3qgrc8K4gYcKJBoEF1xkUfOkC2ES341mnaLDwc7VzeF2FJG7IqeKy6Js6gluAPxwGQX0f7E_yg57Hi8aODhseyONQlrh7W4Es39UNfzAl8STMnvVf2h9PJIcLRO5qbHgoa4HBYKDXzCb79uMzC5EaQqMNNyPviCtJphY8C4UUPYuKfpd16kmE0ZXh64PUMFmy_wPL5ITMpxSTbxw4frThOHfWbER1KrilCDOFV5bUk_4xnXoA5XqnU-AtvQoD5zBxDnDWIjAf_6uQ4BgG7XmFb4Sm89CpZTxwE-NbUepPzf67VKgBSavVmZpA7H3P5zBGgH5syVNyZkn44_RNWPKCsyijnPlo5ixo5w3RvDw_LZEiWTa0cdC-Ejmflur5z9w0BiFS8Z3ov54n-Hfb33wNIV5K4VXMup97Lf6FXHFX4GyOEIuT4asXQo3OLOavJYKn88aMxmKgf2f2Ck1a8qKhEORy5u-eqD2O29pdzZBtIrwISF2lh48_maB3cA9CD3_ydOZEHbaMsTTtIgFMVpIZ3orNxHpdPVGptTCe21PhbWYj6akBeEPWvmyd2iszR-0wDadJvVv4rwvobjrxaEYGcNrGuT9ZyJ-61HHBbmfWj9inakFmHT_ZKVxB-N9JxlUkeCeEMyziv8IIL-oCV6z9uPKL-iRjDAEBTYYGdFFWDQHOxDi_HDPC2DHGbchzciFn7QZuxkfnwqT4iExGybHl_58thXPhMogA6V580ZGDBe7mJ45CDjJjXidbee7ZpgpmeUoIN2KHqkyFvoGLQ5cVKiDCNcegCthElCfdwW7rlDsn4KmJZH427AFU9MSdMf5H9A1XBiaJvEF_EZmi27E1yhZtcqOkYaZhWkEFYu_k1-BLBZzVY0nF7W9oFW8l9x6blixW9xRYSTpKtO8jgR3-QuP-7SZg0oj_aVURwNtvMaVulGa7jmOQYmCANy1-mj4XT3Glggb4Tum9Hy-dJhnyt5Go7jl8VvOIa-EkO_pJBHI5gR7ncsnz-OOToVzgnEqWWAI_PbF4sSLOBPpPj43mDRELhl3lr14PY850jdBiN-8rYjLAGVabLFE709z3l_fNOqzwRndxR9dZ6gicVFgmnJpfIf6Et4W4lwPJv_hdNoyWG0007F8nr8DJc3ZGGQ_FJ_gesyy06i-Ta6yQcvpMy1t3DXHLcguoz3TfjchX6wtcE91Wdl5emk1xivG9vCKG8cXA9QMkfXmQbiPiLUzQWXaA9-J4kXyj3arTvSLxrH3vHzxX6nV_ieW4EqqM_0_0pBtbrNxkbXk2B5j8McPq25aZASVo7miyKLlMOY6xH2N4xqV--hhbv2gveJNlSqHVnyBJbcywkfslOVyt39DVIc6zyYt6t9IZKxi8KmPGHD8ZfXM0LNgl53VOotsVdVlVMvMp7CAgfjjHwoq5g2_jLIFzcHUZo5b8tSU6KPRC1nMTyvhUH5dRqHIl09Smjd28H2eoF7iny05De9URl7Gb0LR3hF4SG-oCkmjFAE8AqgLcUnTjkGrsbY157gMeJY-mpGHpHqBLu371v3W9ZOrCIBvgOLXWCEMXy7B0mg_sra8EBP3_Z84OPD2EDPw7Ic5kCIM8mD3vxo7NNz-lfj6VmYVBxNAC90_hR3ZYDIBNhw13yMdcXLfKegF43lf1ZtTUDLm09WL1rniBeHoMd6Z95uTeKkW42z-hPzEsQTLlea3I8feyN2w2RPpVYQjVGmmvRZuJPdI3DE2xk9uRVaAQeFIoGzQhVpCGYtp_afs45orTzV3McqIOUOS73Nr8QfW7LxEJJVDXtQnJzKXLzpGDnJYabkTu3Bu1h4SBb8Ng2ht30tq2YsRgS7EPj_AIThgloW0N3wQTKX4pXaTbHNoZLaITAAa715MQ48d96_qvrsEjyaj-1oXmnVzX7NpxyZdHw7ftMaYGQn2lQBmBWozWDP7er1UmubSRDjl36C8R3dBvwJcAIQYKJXv5HEm1ojYiIf5O15-12B09_-SmUK4zH-tmvzfRgQQbazi5wKQczabT4ln0Udey3wVAiR5t4fgqAEPymWasja-qjtoy8Lfc4B3Sl8wEXWZD7piq61RMc6pvQNH2ZhqsVcMchcCoPENzKLOYA18ZyQFPMjQFeJGrTIPRTk9mRp6P7PI3_ChdGwx_3fbD2I6MzWoo0ih6IAVktRV4R6m0JpRIXg7aGcBG8m173rqKaD1yc3CUpOnzijX_6tDxgTpqBPb3HFr5Y7b6fXrICH4A59Ws3tnJfu-3rh7kKErq27Cr2k4M6P5ESNJdc6K25W6gdIbyle39bmaceGydvx7jD2VtHhS2ZI58zwGMCa-XzmWcOpF5KO3N2H41Z2-nYkP5gjstvzxublx0h6Zc_EWoCfuRA1It-fmvmO29INQUylDfJDOcB55JfDdR0OE9alxFXf8rv24_ods2aJLjuH_7dfNgfbxPp-yYz2Oj6RaV8Md9Dl-ha4yT4rmompMT6EeKWvPq4YmQLOzkVzvbdxbTUs8et8V9m01wwMYZLR0jD2Jx_O_xUhJZoBfRi5RH7BQV1oDDv2quBaAiJ4T-7Bz92UI7WaYbHKnR72sK9a4wJp-41u3jE2UGVT3zHobLQO_aicoeeuF24_WuuYgaj1jxJVSgszTIU7C5ddldZmsbL8jUEknHUV_MRAKqdsyUUBsiwxjQSvqGt4wC879ORNgDP9KZ0CSG3JV9uYlgSJWNbqbWJKgSx5R1ogSAII5QphtuxDMR9ulV02nL_Y54m-EmpEglEcC9m9LnYk9ttsMo3MDMQPUrLyXMi1MBWyLjMxHY5tXL4PqzeRbBuVGqgGYvrP5urfI0xyHUOFwIX3v1vI5oA0vFyzC8G1CGH1Hg2EaL82-p4RmXnaaJKUAjK3gCTNhCq5xGC54Rxr1jsMnV6idASPA4BgT8Mp0OV0CqlCyD5evCQ8q_T-hz0NudtsMg34Pjvo1bR63bDBiinBd4fHWyDXMWblRw0rT4036hmyUkoaSx3pwjaduhCzv88Kt5KjU8g6MgzIAUEmXr_uRv_TywEtNJNvEpgz_KJuB2BIWbwdwij3fqMrB3Rvl_HlC9_ZNiHR98WPxG8sdTEh7NCL-tdTcMEY2E79HL5vArxBvga_PBHPrzcCkl3YrdfX8jZuWlANU7_xOEzzxgkF_0T52E60_-BfDRx5dYtKkjHwE9Yxn1kvk_b0N8A9O6lbRO-qvNSd6FR719YZoMN83dLxe-9AVJ4FnJSd3_R7KhVNXlE4zJfsJkRb7hU1pHGA8Vbpcov6dMv0E8ecBGPwfNxUbKZlPn5GYgsHDgXT5cctBkAT7c0FH3HyEKvu9EfU6Mhn-fIQZatnYyK-eN3yq10EcOBKMJqEM3qFiSyzwJLz__H0PUPW-lD9BtIkqk5ZIaa-dQNasKg2ij9_uSM0gELP4ailyA061HmUyTdfVeVqysWcCfJ0PQ-N8wq1hfrYV4wXfMaZl0cDUU6iVpHbydWXNFtUrezN-aeV5r7kyWPDmjHhKMLNlPbE0jZw5OdVWIyr96cBAQpKD4e1sdGhVTfeRVKAUidOhW3N2hePGTiHd9XjAm5QJ-DPbxYhRN4huqU7DHjaxuNhITjG0fgcIcjRZhAmenHQ9crYzh8VZp-reFX6A3q85BhZGWa7GnRq-L1ShDKwbbwEsm_X7-BTvEYm2uWPWR04SYS76s2WN3NZ0uQTUicv9TLnfrBEdwEN8MteawcoGLAIjbeH3a7f62OrXNA9nu4r2VAz9v-xFZ0vqBJnb-iVsmAZLpAceqDK6AxrzDXUXRCiUpVYYhaYtE4SegRhj4D-rX_zHN6xHhbmt5HGxJp34QarLQXgteYODV9lppQkwlxeyxURs55-yG5qHwl1CCyDKD9QA5iSDi_gUHRF_0tNcTHZGMv7a0386bHTxpxQ6RiNfokw1-kqppu3w1WoaBwbf-VH-b2CNG-Qu5ozLbwzOgnMWB0dby_UmFNqzYYZ-HIczNGQi0FKh4DYM008_qCQ9kBMKikhJMSl5WuTPZpHeOSRxEzUjF6TOuEdSKvYryjdH3WZxEjQIPVZo_-03mMk12UdYFVfn9g5PeNvbzYZ0bRRqUSO7Fx9wGTVnxZbcwRjQhLeyph13zl4fyxXYyekZNWXbV-NcxLzDE88CncQvp7hZy8r5-gU0vFQAZwFGAVe_PO6-nHVr16KhdV9sIr0A7A0t-edb8hN6-ItxaSpV3DX-ZC5xoNmisUF9Zortibwj7KTlJzqN1UjqJFpKVQm1zZwmGBnzH5vLuU12eMoaCpSL635eitiyqXxEztr9yS_QC8cqzKbAZbrGDiQDI_aCagV7hzHe7eYIfEloqxd5ey9IZkwalodA-W5e8Rxd9F8-E0Y5gc57GibWJdxdMcLBuZcmyix7gGFxXwym8UhKKSbTzB2Ai5AKi3WdlGmqUkJbvKbi0eQAuiZO9Skx5td34904buMRWqOhv47Zp-FtYpPEoZzPkVbsuvIEGzytkxF5wKEpi8iZEoVsR-iq2z7aq2LiO9kkHUSo4o-L_BkgAUQRy_KTcUN1CkmVRY36N1QbW9CD1FWrZP09BKS54nPCBB2w8yj_x_BnqFINXGB-YSXZlPKM4V60mRnBZzV3fFIUBmb2r4uaQUB_0WZpZzrirxOpU41iFYuASjoWqYb-sQBNLjgHO4xWAnt4eVoyCHpdW4PdYvdJDYoAJ5VvE50TTs4Mhhm3uWy-pfzQizmhSO-JlKcypgYpqYkovuTWYqBOybbeebPZMhcSLXNPWEfSshNSym4X0wKUIJDiS9iUrZ4384mS9zbSPQk8n40FvSeVKRKs_uLBJrXtBFR-E6sjKDcrTmxp-HIQSIS9CX11VADuXVWbLUYDeLcKBfX_snE7Ng1lpFxP_c1oJqptTez83cgFUcExneUZ4vVWiIynJnUNFdcOG2CWajV21DlZWxdI0w-jF8Y-WrCPIm3VuhbRvPEpo7r_0EkSa9dZytUYzlFjyUb9sWA4FIf27NDkh5wjsBKC4_3_M1X5U2D5yGxdnMcMtr7jPsHr8hF2ShkcnZYMjnkzWDI_PuJl9Uh1EThetss43bnO69MpeAQXCe0ajNzEKttSts3Iv4NCFWTl3DN6Ks5Dv5vYDrbXYwDDEqR1U6PnnwePujth9RFx_Oj0g6df2FMpfibUm8Nl05YKkoNKQPs73j3w7E8BLAFRMQnqZYmPll8b-F9S2NeFJgn-xkLPEA8eDnMwh-9OOY9nV_cyYF4ou2_Bs2O59bYhCnhzhijz5cDe8F4HN4TWSYqWoZC5Kr1LHZLDQhVXlox47Tbg2MIw4BGD8lb-gLDxTEyqAC003n6A6WMQgQVKBD8AV6Ebl-u-IVkdifA9WXRrZxUbWnGMzHYhiMc6Llt_0gI4mNmdFMV1gM2sdSaTMzSp8KDTTNtKjjFHkZ6LbSQoEQ7L9UeEN3E2TTxgFrMIUc6LNFWq98EC-Xv1GHRLr4eSzyCBHePzJYJVkauBdY22LDWpH9dkA2AlA6QlZw8i93yrZ3jNtMttya29FgZW3uZlD8dlJVPKxQ4P4emXHJXvK-11EEB3pXyeM6wQbLt5iXKSslIe7DDsIbHTtP4ksjlHg0u0fn3tSlvN9r8MQSxLH4WNxdJ3jWAl9aA0KxrQjaGvUdQ1ESZ1X4JT342bApwcj_kARzROQfC5Rv4RqeS08jSVR2qILRJdSxma7UZ0xaI0s9Nwfz8WjbKAvurTcvU6TieLpYGxEgF_LNiuRVsCFwJpBxYM1Duxvs7o-9X5xBQHlozfWvnzuY50yHriAWMSdHZyb0tkeRlCIFoO1vjBSMeQTbv2OBga5HyUtsEK47yi3uC7J6tin3xhmrhSt7UJoESKJ9tWS4g70W33_tX4iHeAxVs7E2t50CFA8mWL1Z1OEAC8lUlPuzRg9C2U_Fuw7TiyfWmZ16uUfUVYePorN3PAJOR93x6Lk-qpelygW4gQwtlJa0p8jEiKck4I-DLK_uaoI-gP3M2-MX4o7LsxhBx0KBxUzMmtyd4zkpqatT0PfzroJALCfsHHkTzwX5IwO4YXwWSHpO-zGtSlkFw5xzk3jMHZgypRtDI-xAN1kyRmnQ8QZPz35EiJiU_aU1JeBWiFHhn06JMfTb5qMWr-6jsr0Of0JtSXIwdOVkM1z6EUgmT9g7c2uU4B0A31X2sqlSKOkmup8TNz8CXfWSi8IfKq4hT3nUD9qWkprqfhjPauTOMWa9DEqB_yfNT8AlTvV1hkpuOxur_f1oWwR3OWToiGcdsJ5hLoxZEADL9blyQOGxe1UI7Oz8gRhuaqXlMrXDsOseGciYQ1VeFUOyDACqY2yrdGmPTDFkC5IV63lmYa-N2AOGRz7USTUsJygyQq7PC4qKKeeCHLGFqhhiUlFbG_kpplcv3KjU8wmcKhsIa4v-10vwTbwfkblRIkhgS4wdWpoICzUZ8Q90kRTQUlOqOu6cdwanqyh6YmuCCjZIMQKNTT-uLIBPV9hDkWsPcbPQQF13zaEVIIe-38AlF4cVXNpzjDUC3JwWjGA4YHHZ3RLsVYAZauj3gSNnY4BuGd_7hRqwZhLnuunAUlgxbhPOhFxuYibDFUgREusClAWsizMhogImFfqTGQ6CCJgL1ZfAG6K4QU2WOnYeLffRQ6s9h4dWR0yV2iLatzznOEmWQmtw66CQq_ptu4Xd35reskb4wZjiIQPO-zVjajta82I6WQMouFPyakttUNDIeZnVeqYW9v1LMEPas9KRpIbBrk1mJYVHUcEmHJZKuMTPPgFuHmsWkaBjRwH90gwYkfp-hrrjHZjfHsQ3JkexKAQ8DdZHc_74bw3kzLcfHLLyNM8ozFmwmPzY_3WZiSzRQ1mdG20JtUW0dVMA4ffnn5QqYIFtwMzuzeoA1tSaoKoYRt3dj0Vhd4CSUNbNM2MGh4DTkRa5aavJt_2gxDDnq6r5YHHvklmIgcza5i1rPfsIdtreiik1DQBQmZWvzVaVvIxpoLmdjyP8EOJuzKPL3oCZbfY-XPuzS0IqmvHuXkr2IVU13TY9ZzEW1sykSSt5PrFy_r3e7cPx3HnwC5DFhEst6RAxcicBKoqYnid8PgNd43-g8WTzqZjpLVNnMRbfv_XO4yjgQwIkWzR6iTaAYQQJW9pvZ8eOrrDpREWwu5MPKutpO6yboZ3ClF9GgJLmhTHYhC987Ffvztr18E5yPrwo2fn9QhRkJEntGrYuXzRr8K6a_dMMqUK_EEqRB3ZwBuQ-mRw1XTkbHqdDGGt-0E4dyEdAcYp5NZj5-xGKp4Xul7IaMgMUh5sH8nBTG4_xhVxdBRt-tjh3LvfMcLIWJBnXit0Z3iovvFiH4U8b0UoAqJW0GFb2DG7ljKft0rDjDZzlWNc3hUCn8Px2zrjILTzAGLfm30yvGvN95QrsXuGLI7yVmQYkKkJoMUuU8LaBRG5yVtuW-0q9NiRr4TdmsU_sf2031ZIxoS2etqwZIpPSycfP6jXxieOQ5d-reG-gia0dE74wn57NjEmzjlNtSmPgiuJDJYU1EM6ldvvFJBu6Nef_Nbbj8uEfD5vh0zvQ7KUstuz2AvGpOUXsOtkff3ksRjhWJv3dkHAmpRrprbWUaJaVEJnqCr3ns2uOmR36JM8iHfmQ7b9g8a0sdmyNjW7aVsemvARzOPWRdaOvkywaj2NDE6vvpg1DmnfBfhaHFP5ABsSt6p3JiaPsOAiZwqEkSxmDnfXTZen5fUJab8-35NRaMm6vSbPbwMVRjn_a3JShT54ebgcF-eGZYI56T4XQb8mt-7vxF0A-xWtl8MLEPBx_EUa59rema_m98afzoqbw2w7fK3pma2DOOcTRsxv5umbNumGah4K6gusatcyoOoSOGnHhfW3zwF0bNnYCcEWxk-H6iEOgytw7l1Y1bhHKCBG7cY9XloFUh7WlOUZyyy0iqgFcfnnpnTHB0Dt7k2dJvFmVPyAS2epUMOXuOttcMwPM04Xl7EVsW1Dbcn2QE3CcRHpeAJ0I0rGsa38WwuC8ZTypBE8X6wKz1ih4fPmi3qMJQ6W_YzFnlYPcB2wWgkR13iPR3hf1hg7qaaQW-JcJiY6byAqJ1mV6GG6fud1-bNfLsV5psF7ayodtUUqZ63PWlz1uMH7ggu8hkX7EV6UX0b0Uj4t_HP2Js5NGuTfhDbKJBmU3k7jZcu2QwIpHhkT4_pW2VTCnllyXOhxHpMdSOqwVuM1hcv3PjRU7IzSDs2y9afzR9JC5WHu6VQmQrJG6rppQjYXk2AYBQ9lwN_0n9utrMA7KQj_K2XPL3IcmSCEO8nLwPvqlmzUB3_Km287orExxjb5tLIOkxXd6qNSdMKmlSRUWpgM60QwETq9B2FDV6DZBsVWql-MehCk2FDKRJIbK5HKsDbEiH-nCYcfLCNZTKpC8yvCKRiTmQ-rXQlDZUcAzpUORkgs7mhHEtir8_T2MSGJ9W-4cYLDu4Ipsnv7uWNcBzRdCHWjxoiLhVwMhSwtw3xkSURf3jtWj0FtxYW78zp0DIObVAhEIGu590LL8UASSREnJVfSMgDUC9yzxUtMC7TYAzSkiPx-kaRJ_rM-GjupeXvJmTzRlR_hfwt7xYJNGhdGSGtvzOIQCeqEPupP3aR-ahk3NoFml3SZ9BYCe_y1BUy71A88A-R78QsphflOGMe8BlFG7a0d8iIMl-8uBK2dJuZIqgLtUwY0TG-RZBhb832HNqA2mlmQFqz2joYA98DB6-GuO8CfF-1tc7_CpUBuuiq0xonWSyR7w9OuDn0RWcaXQNPoU9NYo_QN63TU5aTXZdjA2P8OrzqKc2R0Ov-eSjSw4SWaMDs2-Qi2in29g9iuN4FmHYz_yRiB5HplH2yr7246NgQbYicsuD8dvRG-okKC4soYW9lcB8gDswtc0NFGWSFR-twgqXt5__H6fYujhAFJ2T3FAIJzhaZJAYrDdnhKd9SNRwbgReiKpRJ2O0qWVj59K-ckhfog-nkdSexvAQz4msY_oLK6P2vTFbuvz_RspblO2h0cJXRlCDcjGG2PGsWKW-MSwP5IPdQcSaB3MK4sW_P4ulOEqafoiYiaXmaQQCConDpGHKZr67RPO7LQM7pbhBLjt0DL56Ih7_P5WjqBOpDbcdih0BLJTTCXoQI3omh4PFgu1-5ocZhqAIeOOip5_jWrPoOcajgaSBT8iP2rJ96je7_8w79N-Jf_I2RmdWL6rclJA8C9sLj93gN4Y_MLu6mVa1JlXWatgrWYAIx_FjwRjJa6LT5N5Tyy9tbtcCOBRIUrNY6gnWSuFyMX6Q6xL-8e8N3qYgby3zplwhgGKOvnz1UGEqRL7NIpKbZqjOlMo9vjTj9_yLXCWP5tPc9Ah5WujtwRgAmh3pG85KZrX1TSfK4KIjmkn2HC55AEdy0S_WF5iSjZHy9K7GPnZOcRx8G42Ob6jzdClYvV7YJgc1zg-rb3jjIwckC5-miFegh0g== \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/app.py.backup_20250616_223724 b/backups/refactoring_20250616_223724/app.py.backup_20250616_223724 deleted file mode 100644 index 4e6204a..0000000 --- a/backups/refactoring_20250616_223724/app.py.backup_20250616_223724 +++ /dev/null @@ -1,4475 +0,0 @@ -import os -import sys -import time -import json -import logging -import requests -import re -import random -import base64 -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Import and register blueprints -from routes.auth_routes import auth_bp -from routes.admin_routes import admin_bp -from routes.license_routes import license_bp -from routes.customer_routes import customer_bp -from routes.resource_routes import resource_bp -from routes.session_routes import session_bp -from routes.batch_routes import batch_bp -from routes.api_routes import api_bp -from routes.export_routes import export_bp - -# Register blueprints -app.register_blueprint(auth_bp) -app.register_blueprint(admin_bp) -app.register_blueprint(license_bp) -app.register_blueprint(customer_bp) -app.register_blueprint(resource_bp) -app.register_blueprint(session_bp) -app.register_blueprint(batch_bp) -app.register_blueprint(api_bp) -app.register_blueprint(export_bp) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -# @app.route("/login", methods=["GET", "POST"]) -# def login(): - # Timing-Attack Schutz - Start Zeit merken - # start_time = time.time() - - # IP-Adresse ermitteln - # ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - # is_blocked, blocked_until = check_ip_blocked(ip_address) - # if is_blocked: - # time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - # error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - # return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - # attempt_count = get_login_attempts(ip_address) - - # if request.method == "POST": - # username = request.form.get("username") - # password = request.form.get("password") - # captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - # recaptcha_site_key = config.RECAPTCHA_SITE_KEY - # if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - # if not captcha_response: - # Timing-Attack Schutz - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - # return render_template("login.html", - # error="CAPTCHA ERFORDERLICH!", - # show_captcha=True, - # error_type="captcha", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - # if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - # return render_template("login.html", - # error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - # show_captcha=True, - # error_type="captcha", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - # user = get_user_by_username(username) - # login_success = False - # needs_2fa = False - - # if user: - # Database user authentication - # if verify_password(password, user['password_hash']): - # login_success = True - # needs_2fa = user['totp_enabled'] - # else: - # Fallback to environment variables for backward compatibility - # if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - # login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - - # if login_success: - # Erfolgreicher Login - # if needs_2fa: - # Store temporary session for 2FA verification - # session['temp_username'] = username - # session['temp_user_id'] = user['id'] - # session['awaiting_2fa'] = True - # return redirect(url_for('verify_2fa')) - # else: - # Complete login without 2FA - # session.permanent = True # Aktiviert das Timeout - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user['id'] if user else None - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # reset_login_attempts(ip_address) - # log_audit('LOGIN_SUCCESS', 'user', - # additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - # return redirect(url_for('dashboard')) - # else: - # Fehlgeschlagener Login - # error_message = record_failed_attempt(ip_address, username) - # new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - # is_now_blocked, _ = check_ip_blocked(ip_address) - # if is_now_blocked: - # log_audit('LOGIN_BLOCKED', 'security', - # additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - # return render_template("login.html", - # error=error_message, - # show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - # error_type="failed", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - # recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - # return render_template("login.html", - # show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - -# @app.route("/logout") -# def logout(): - # username = session.get('username', 'unknown') - # log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - # session.pop('logged_in', None) - # session.pop('username', None) - # session.pop('user_id', None) - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - # return redirect(url_for('login')) - -# @app.route("/verify-2fa", methods=["GET", "POST"]) -# def verify_2fa(): - # if not session.get('awaiting_2fa'): - # return redirect(url_for('login')) - - # if request.method == "POST": - # token = request.form.get('token', '').replace(' ', '') - # username = session.get('temp_username') - # user_id = session.get('temp_user_id') - - # if not username or not user_id: - # flash('Session expired. Please login again.', 'error') - # return redirect(url_for('login')) - - # user = get_user_by_username(username) - # if not user: - # flash('User not found.', 'error') - # return redirect(url_for('login')) - - # Check if it's a backup code - # if len(token) == 8 and token.isupper(): - # Try backup code - # backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - # if verify_backup_code(token, backup_codes): - # Remove used backup code - # code_hash = hash_backup_code(token) - # backup_codes.remove(code_hash) - - # conn = get_connection() - # cur = conn.cursor() - # cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - # (json.dumps(backup_codes), user_id)) - # conn.commit() - # cur.close() - # conn.close() - - # Complete login - # session.permanent = True - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user_id - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - - # flash('Login successful using backup code. Please generate new backup codes.', 'warning') - # log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - # return redirect(url_for('dashboard')) - # else: - # Try TOTP token - # if verify_totp(user['totp_secret'], token): - # Complete login - # session.permanent = True - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user_id - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - - # log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - # return redirect(url_for('dashboard')) - - # Failed verification - # conn = get_connection() - # cur = conn.cursor() - # cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - # (datetime.now(), user_id)) - # conn.commit() - # cur.close() - # conn.close() - - # flash('Invalid authentication code. Please try again.', 'error') - # log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - # return render_template('verify_2fa.html') - -# @app.route("/profile") -# @login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -# @app.route("/profile/change-password", methods=["POST"]) -# @login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -# @app.route("/profile/setup-2fa") -# @login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -# @app.route("/profile/enable-2fa", methods=["POST"]) -# @login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -# @app.route("/profile/disable-2fa", methods=["POST"]) -# @login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -# @app.route("/heartbeat", methods=['POST']) -# @login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -# @app.route("/api/generate-license-key", methods=['POST']) -# @login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -# @app.route("/api/customers", methods=['GET']) -# @login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -# @app.route("/") -# @login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -# @app.route("/create", methods=["GET", "POST"]) -# @login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -# @app.route("/batch", methods=["GET", "POST"]) -# @login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -# @app.route("/batch/export") -# @login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -# @app.route("/licenses") -# @login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -# @app.route("/license/edit/", methods=["GET", "POST"]) -# @login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -# @app.route("/license/delete/", methods=["POST"]) -# @login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -# @app.route("/customers") -# @login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -# @app.route("/customer/edit/", methods=["GET", "POST"]) -# @login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -# @app.route("/customer/create", methods=["GET", "POST"]) -# @login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -# @app.route("/customer/delete/", methods=["POST"]) -# @login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -# @app.route("/customers-licenses") -# @login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -# @app.route("/api/customer//licenses") -# @login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -# @app.route("/api/customer//quick-stats") -# @login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -# @app.route("/api/license//quick-edit", methods=['POST']) -# @login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -# @app.route("/api/license//resources") -# @login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -# @app.route("/sessions") -# @login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -# @app.route("/session/end/", methods=["POST"]) -# @login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -# @app.route("/export/licenses") -# @login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/audit") -# @login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/customers") -# @login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/sessions") -# @login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/resources") -# @login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/audit") -# @login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -# @app.route("/backups") -# @login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -# @app.route("/backup/create", methods=["POST"]) -# @login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -# @app.route("/backup/restore/", methods=["POST"]) -# @login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -# @app.route("/backup/download/") -# @login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -# @app.route("/backup/delete/", methods=["DELETE"]) -# @login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -# @app.route("/security/blocked-ips") -# @login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -# @app.route("/security/unblock-ip", methods=["POST"]) -# @login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -# @app.route("/security/clear-attempts", methods=["POST"]) -# @login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -# @app.route("/api/license//toggle", methods=["POST"]) -# @login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/licenses/bulk-activate", methods=["POST"]) -# @login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -# @login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/license//devices") -# @login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -# @app.route("/api/license//register-device", methods=["POST"]) -# def register_device(license_id): - # """Registriere ein neues Gerät für eine Lizenz""" - # try: - # data = request.get_json() - # hardware_id = data.get('hardware_id') - # device_name = data.get('device_name', '') - # operating_system = data.get('operating_system', '') - - # if not hardware_id: - # return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - # conn = get_connection() - # cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - # cur.execute(""" - # SELECT device_limit, is_active, valid_until - # FROM licenses - # WHERE id = %s - # """, (license_id,)) - # license_data = cur.fetchone() - - # if not license_data: - # return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - # device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - # if not is_active: - # return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - # if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - # return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - # cur.execute(""" - # SELECT id, is_active FROM device_registrations - # WHERE license_id = %s AND hardware_id = %s - # """, (license_id, hardware_id)) - # existing_device = cur.fetchone() - - # if existing_device: - # device_id, is_device_active = existing_device - # if is_device_active: - # Gerät ist bereits aktiv, update last_seen - # cur.execute(""" - # UPDATE device_registrations - # SET last_seen = CURRENT_TIMESTAMP, - # ip_address = %s, - # user_agent = %s - # WHERE id = %s - # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - # conn.commit() - # return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - # else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - # cur.execute(""" - # SELECT COUNT(*) FROM device_registrations - # WHERE license_id = %s AND is_active = TRUE - # """, (license_id,)) - # active_count = cur.fetchone()[0] - - # if active_count >= device_limit: - # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - # cur.execute(""" - # UPDATE device_registrations - # SET is_active = TRUE, - # last_seen = CURRENT_TIMESTAMP, - # deactivated_at = NULL, - # deactivated_by = NULL, - # ip_address = %s, - # user_agent = %s - # WHERE id = %s - # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - # conn.commit() - # return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - # cur.execute(""" - # SELECT COUNT(*) FROM device_registrations - # WHERE license_id = %s AND is_active = TRUE - # """, (license_id,)) - # active_count = cur.fetchone()[0] - - # if active_count >= device_limit: - # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - # cur.execute(""" - # INSERT INTO device_registrations - # (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - # VALUES (%s, %s, %s, %s, %s, %s) - # RETURNING id - # """, (license_id, hardware_id, device_name, operating_system, - # get_client_ip(), request.headers.get('User-Agent', ''))) - # device_id = cur.fetchone()[0] - - # conn.commit() - - # Audit Log - # log_audit('DEVICE_REGISTER', 'device', device_id, - # new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - # cur.close() - # conn.close() - - # return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - # except Exception as e: - # logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - # return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -# @app.route("/api/license//deactivate-device/", methods=["POST"]) -# @login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -# @app.route("/api/licenses/bulk-delete", methods=["POST"]) -# @login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -# @app.route('/resources') -# @login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -# @app.route('/resources/add', methods=['GET', 'POST']) -# @login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -# @app.route('/resources/quarantine/', methods=['POST']) -# @login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -# @app.route('/resources/release', methods=['POST']) -# @login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -# @app.route('/api/resources/allocate', methods=['POST']) -# @login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -# @app.route('/api/resources/check-availability', methods=['GET']) -# @login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -# @app.route('/api/global-search', methods=['GET']) -# @login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -# @app.route('/resources/history/') -# @login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -# @app.route('/resources/metrics') -# @login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -# @app.route('/resources/report', methods=['GET']) -# @login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/backups/refactoring_20250616_223724/blueprint_overview.txt b/backups/refactoring_20250616_223724/blueprint_overview.txt deleted file mode 100644 index 14bad8b..0000000 --- a/backups/refactoring_20250616_223724/blueprint_overview.txt +++ /dev/null @@ -1,25 +0,0 @@ -Blueprint Migration Status - 20250616_223724 -========================================== - -Blueprints erstellt und registriert: -- auth_bp (9 routes) - Authentication -- admin_bp (10 routes) - Admin Dashboard -- license_bp (4 routes) - License Management -- customer_bp (7 routes) - Customer Management -- resource_bp (7 routes) - Resource Pool -- session_bp (6 routes) - Session Management -- batch_bp (4 routes) - Batch Operations -- api_bp (14 routes) - API Endpoints -- export_bp (5 routes) - Export Functions - -Gesamt: 66 Routes in Blueprints - -Status: -- Alle Routes aus app.py sind auskommentiert -- Blueprints sind aktiv und funktionsfähig -- Keine aktiven @app.route mehr in app.py - -Nächste Schritte: -1. Auskommentierte Routes entfernen -2. Redundante Funktionen bereinigen -3. URL-Präfixe implementieren diff --git a/backups/refactoring_20250616_223724/commented_routes.txt b/backups/refactoring_20250616_223724/commented_routes.txt deleted file mode 100644 index 4bf1107..0000000 --- a/backups/refactoring_20250616_223724/commented_routes.txt +++ /dev/null @@ -1,60 +0,0 @@ -153:# @app.route("/login", methods=["GET", "POST"]) -267:# @app.route("/logout") -279:# @app.route("/verify-2fa", methods=["GET", "POST"]) -358:# @app.route("/profile") -368:# @app.route("/profile/change-password", methods=["POST"]) -406:# @app.route("/profile/setup-2fa") -426:# @app.route("/profile/enable-2fa", methods=["POST"]) -464:# @app.route("/profile/disable-2fa", methods=["POST"]) -491:# @app.route("/heartbeat", methods=['POST']) -506:# @app.route("/api/generate-license-key", methods=['POST']) -551:# @app.route("/api/customers", methods=['GET']) -662:# @app.route("/") -892:# @app.route("/create", methods=["GET", "POST"]) -1123:# @app.route("/batch", methods=["GET", "POST"]) -1378:# @app.route("/batch/export") -1417:# @app.route("/licenses") -1423:# @app.route("/license/edit/", methods=["GET", "POST"]) -1515:# @app.route("/license/delete/", methods=["POST"]) -1548:# @app.route("/customers") -1554:# @app.route("/customer/edit/", methods=["GET", "POST"]) -1638:# @app.route("/customer/create", methods=["GET", "POST"]) -1693:# @app.route("/customer/delete/", methods=["POST"]) -1731:# @app.route("/customers-licenses") -1824:# @app.route("/api/customer//licenses") -1927:# @app.route("/api/customer//quick-stats") -1960:# @app.route("/api/license//quick-edit", methods=['POST']) -2030:# @app.route("/api/license//resources") -2080:# @app.route("/sessions") -2162:# @app.route("/session/end/", methods=["POST"]) -2181:# @app.route("/export/licenses") -2291:# @app.route("/export/audit") -2415:# @app.route("/export/customers") -2519:# @app.route("/export/sessions") -2658:# @app.route("/export/resources") -2787:# @app.route("/audit") -2881:# @app.route("/backups") -2916:# @app.route("/backup/create", methods=["POST"]) -2934:# @app.route("/backup/restore/", methods=["POST"]) -2953:# @app.route("/backup/download/") -2985:# @app.route("/backup/delete/", methods=["DELETE"]) -3041:# @app.route("/security/blocked-ips") -3082:# @app.route("/security/unblock-ip", methods=["POST"]) -3108:# @app.route("/security/clear-attempts", methods=["POST"]) -3124:# @app.route("/api/license//toggle", methods=["POST"]) -3156:# @app.route("/api/licenses/bulk-activate", methods=["POST"]) -3192:# @app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -3228:# @app.route("/api/license//devices") -3283:# @app.route("/api/license//register-device", methods=["POST"]) -3398:# @app.route("/api/license//deactivate-device/", methods=["POST"]) -3440:# @app.route("/api/licenses/bulk-delete", methods=["POST"]) -3485:# @app.route('/resources') -3625:# @app.route('/resources/add', methods=['GET', 'POST']) -3689:# @app.route('/resources/quarantine/', methods=['POST']) -3747:# @app.route('/resources/release', methods=['POST']) -3798:# @app.route('/api/resources/allocate', methods=['POST']) -3946:# @app.route('/api/resources/check-availability', methods=['GET']) -4005:# @app.route('/api/global-search', methods=['GET']) -4068:# @app.route('/resources/history/') -4155:# @app.route('/resources/metrics') -4319:# @app.route('/resources/report', methods=['GET']) diff --git a/backups/refactoring_20250616_223724/git_diff.txt b/backups/refactoring_20250616_223724/git_diff.txt deleted file mode 100644 index 375ad25..0000000 --- a/backups/refactoring_20250616_223724/git_diff.txt +++ /dev/null @@ -1,29251 +0,0 @@ -diff --git a/.claude/settings.local.json b/.claude/settings.local.json -index 0238830..a95b7a2 100644 ---- a/.claude/settings.local.json -+++ b/.claude/settings.local.json -@@ -63,7 +63,8 @@ - "Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"BACKUP|LOGIN_2FA_SUCCESS\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/app.py)", - "Bash(sed:*)", - "Bash(python:*)", -- "Bash(awk:*)" -+ "Bash(awk:*)", -+ "Bash(./backup_before_cleanup.sh:*)" - ], - "deny": [] - } -diff --git a/JOURNAL.md b/JOURNAL.md -index fc95379..be0c5f5 100644 ---- a/JOURNAL.md -+++ b/JOURNAL.md -@@ -1,2732 +1,2732 @@ --# v2-Docker Projekt Journal -- --## Letzte Änderungen (06.01.2025) -- --### Gerätelimit-Feature implementiert --- **Datenbank-Schema erweitert**: -- - Neue Spalte `device_limit` in `licenses` Tabelle (Standard: 3, Range: 1-10) -- - Neue Tabelle `device_registrations` für Hardware-ID Tracking -- - Indizes für Performance-Optimierung hinzugefügt -- --- **UI-Anpassungen**: -- - Einzellizenz-Formular: Dropdown für Gerätelimit (1-10 Geräte) -- - Batch-Formular: Gerätelimit pro Lizenz auswählbar -- - Lizenz-Bearbeitung: Gerätelimit änderbar -- - Lizenz-Anzeige: Zeigt aktive Geräte (z.B. "💻 2/3") -- --- **Backend-Änderungen**: -- - Lizenz-Erstellung speichert device_limit -- - Batch-Erstellung berücksichtigt device_limit -- - Lizenz-Update kann device_limit ändern -- - API-Endpoints liefern Geräteinformationen -- --- **Migration**: -- - Skript `migrate_device_limit.sql` erstellt -- - Setzt device_limit = 3 für alle bestehenden Lizenzen -- --### Vollständig implementiert: --✅ Device Management UI (Geräte pro Lizenz anzeigen/verwalten) --✅ Device Validation Logic (Prüfung bei Geräte-Registrierung) --✅ API-Endpoints für Geräte-Registrierung/Deregistrierung -- --### API-Endpoints: --- `GET /api/license//devices` - Listet alle Geräte einer Lizenz --- `POST /api/license//register-device` - Registriert ein neues Gerät --- `POST /api/license//deactivate-device/` - Deaktiviert ein Gerät -- --### Features: --- Geräte-Registrierung mit Hardware-ID Validierung --- Automatische Prüfung des Gerätelimits --- Reaktivierung deaktivierter Geräte möglich --- Geräte-Verwaltung UI mit Modal-Dialog --- Anzeige von Gerätename, OS, IP, Registrierungsdatum --- Admin kann Geräte manuell deaktivieren -- ----- -- --## Projektübersicht --Lizenzmanagement-System für Social Media Account-Erstellungssoftware mit Docker-basierter Architektur. -- --### Technische Anforderungen --- **Lokaler Betrieb**: Docker mit 4GB RAM und 40GB Speicher --- **Internet-Zugriff**: -- - Admin Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com -- - API Server: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com --- **Datenbank**: PostgreSQL mit 2 Admin-Usern --- **Ziel**: PoC für spätere VPS-Migration -- ----- -- --## Best Practices für Produktiv-Migration -- --### Passwort-Management --Für die Migration auf Hetzner/VPS müssen die Credentials sicher verwaltet werden: -- --1. **Environment Variables erstellen:** -- ```bash -- # .env.example (ins Git Repository) -- POSTGRES_USER=changeme -- POSTGRES_PASSWORD=changeme -- POSTGRES_DB=changeme -- SECRET_KEY=generate-a-secure-key -- ADMIN_USER_1=changeme -- ADMIN_PASS_1=changeme -- ADMIN_USER_2=changeme -- ADMIN_PASS_2=changeme -- -- # .env (NICHT ins Git, auf Server erstellen) -- POSTGRES_USER=produktiv_user -- POSTGRES_PASSWORD=sicheres_passwort_min_20_zeichen -- POSTGRES_DB=v2docker_prod -- SECRET_KEY=generierter_64_zeichen_key -- # etc. -- ``` -- --2. **Sichere Passwörter generieren:** -- - Mindestens 20 Zeichen -- - Mix aus Groß-/Kleinbuchstaben, Zahlen, Sonderzeichen -- - Verschiedene Passwörter für Dev/Staging/Prod -- - Password-Generator verwenden (z.B. `openssl rand -base64 32`) -- --3. **Erweiterte Sicherheit (Optional):** -- - HashiCorp Vault für zentrale Secret-Verwaltung -- - Docker Secrets (für Docker Swarm) -- - Cloud-Lösungen: AWS Secrets Manager, Azure Key Vault -- --4. **Wichtige Checkliste:** -- - [ ] `.env` in `.gitignore` aufnehmen -- - [ ] Neue Credentials für Produktion generieren -- - [ ] Backup der Credentials an sicherem Ort -- - [ ] Regelmäßige Passwort-Rotation planen -- - [ ] Keine Default-Passwörter verwenden -- ----- -- --## Änderungsprotokoll -- --### 2025-06-06 - Journal erstellt --- Initialer Projektstand dokumentiert --- Aufgabenliste priorisiert --- Technische Anforderungen festgehalten -- --### 2025-06-06 - UTF-8 Support implementiert --- Flask App Konfiguration für UTF-8 hinzugefügt (JSON_AS_ASCII=False) --- PostgreSQL Verbindung mit UTF-8 client_encoding --- HTML Forms mit accept-charset="UTF-8" --- Dockerfile mit deutschen Locale-Einstellungen (de_DE.UTF-8) --- PostgreSQL Container mit UTF-8 Initialisierung --- init.sql mit SET client_encoding = 'UTF8' -- --**Geänderte Dateien:** --- v2_adminpanel/app.py --- v2_adminpanel/templates/index.html --- v2_adminpanel/init.sql --- v2_adminpanel/Dockerfile --- v2/docker-compose.yaml -- --**Nächster Test:** --- Container neu bauen und starten --- Kundennamen mit Umlauten testen (z.B. "Müller GmbH", "Björn Schäfer") --- Email mit Umlauten testen -- --### 2025-06-06 - Lizenzübersicht implementiert --- Neue Route `/licenses` für Lizenzübersicht --- SQL-Query mit JOIN zwischen licenses und customers --- Status-Berechnung (aktiv, läuft bald ab, abgelaufen) --- Farbcodierung für verschiedene Status --- Navigation zwischen Lizenz erstellen und Übersicht -- --**Neue Features:** --- Anzeige aller Lizenzen mit Kundeninformationen --- Status-Anzeige basierend auf Ablaufdatum --- Unterscheidung zwischen Voll- und Testversion --- Responsive Tabelle mit Bootstrap --- Link von Dashboard zur Übersicht und zurück -- --**Geänderte/Neue Dateien:** --- v2_adminpanel/app.py (neue Route hinzugefügt) --- v2_adminpanel/templates/licenses.html (neu erstellt) --- v2_adminpanel/templates/index.html (Navigation ergänzt) -- --**Nächster Test:** --- Container neu starten --- Mehrere Lizenzen mit verschiedenen Ablaufdaten erstellen --- Lizenzübersicht unter /licenses aufrufen -- --### 2025-06-06 - Lizenz bearbeiten/löschen implementiert --- Neue Routen für Bearbeiten und Löschen von Lizenzen --- Bearbeitungsformular mit vorausgefüllten Werten --- Aktiv/Inaktiv-Status kann geändert werden --- Lösch-Bestätigung per JavaScript confirm() --- Kunde kann nicht geändert werden (nur Lizenzdetails) -- --**Neue Features:** --- `/license/edit/` - Bearbeitungsformular --- `/license/delete/` - Lizenz löschen (POST) --- Aktionen-Spalte in der Lizenzübersicht --- Buttons für Bearbeiten und Löschen --- Checkbox für Aktiv-Status -- --**Geänderte/Neue Dateien:** --- v2_adminpanel/app.py (edit_license und delete_license Routen) --- v2_adminpanel/templates/licenses.html (Aktionen-Spalte hinzugefügt) --- v2_adminpanel/templates/edit_license.html (neu erstellt) -- --**Sicherheit:** --- Login-Required für alle Aktionen --- POST-only für Löschvorgänge --- Bestätigungsdialog vor dem Löschen -- --### 2025-06-06 - Kundenverwaltung implementiert --- Komplette CRUD-Funktionalität für Kunden --- Übersicht zeigt Anzahl aktiver/gesamter Lizenzen pro Kunde --- Kunden können nur gelöscht werden, wenn sie keine Lizenzen haben --- Bearbeitungsseite zeigt alle Lizenzen des Kunden -- --**Neue Features:** --- `/customers` - Kundenübersicht mit Statistiken --- `/customer/edit/` - Kunde bearbeiten (Name, E-Mail) --- `/customer/delete/` - Kunde löschen (nur ohne Lizenzen) --- Navigation zwischen allen drei Hauptbereichen --- Anzeige der Kundenlizenzen beim Bearbeiten -- --**Geänderte/Neue Dateien:** --- v2_adminpanel/app.py (customers, edit_customer, delete_customer Routen) --- v2_adminpanel/templates/customers.html (neu erstellt) --- v2_adminpanel/templates/edit_customer.html (neu erstellt) --- v2_adminpanel/templates/index.html (Navigation erweitert) --- v2_adminpanel/templates/licenses.html (Navigation erweitert) -- --**Besonderheiten:** --- Lösch-Button ist deaktiviert, wenn Kunde Lizenzen hat --- Aktive Lizenzen werden separat gezählt (nicht abgelaufen + aktiv) --- UTF-8 Support für Kundennamen mit Umlauten -- --### 2025-06-06 - Dashboard mit Statistiken implementiert --- Übersichtliches Dashboard als neue Startseite --- Statistik-Karten mit wichtigen Kennzahlen --- Listen für bald ablaufende und zuletzt erstellte Lizenzen --- Routing angepasst: Dashboard (/) und Lizenz erstellen (/create) -- --**Neue Features:** --- Statistik-Karten: Kunden, Lizenzen gesamt, Aktive, Ablaufende --- Aufteilung nach Lizenztypen (Vollversion/Testversion) --- Aufteilung nach Status (Aktiv/Abgelaufen) --- Top 10 bald ablaufende Lizenzen mit Restlaufzeit --- Letzte 5 erstellte Lizenzen mit Status --- Hover-Effekt auf Statistik-Karten --- Einheitliche Navigation mit Dashboard-Link -- --**Geänderte/Neue Dateien:** --- v2_adminpanel/app.py (dashboard() komplett überarbeitet, create_license() Route) --- v2_adminpanel/templates/dashboard.html (neu erstellt) --- v2_adminpanel/templates/index.html (Navigation erweitert) --- v2_adminpanel/templates/licenses.html (Navigation angepasst) --- v2_adminpanel/templates/customers.html (Navigation angepasst) -- --**Dashboard-Inhalte:** --- 4 Hauptstatistiken als Karten --- Lizenztyp-Verteilung --- Status-Verteilung --- Warnung für bald ablaufende Lizenzen --- Übersicht der neuesten Aktivitäten -- --### 2025-06-06 - Suchfunktion implementiert --- Volltextsuche für Lizenzen und Kunden --- Case-insensitive Suche mit LIKE-Operator --- Suchergebnisse mit Hervorhebung des Suchbegriffs --- Suche zurücksetzen Button -- --**Neue Features:** --- **Lizenzsuche**: Sucht in Lizenzschlüssel, Kundenname und E-Mail --- **Kundensuche**: Sucht in Kundenname und E-Mail --- Suchformular mit autofocus für schnelle Eingabe --- Anzeige des aktiven Suchbegriffs --- Unterschiedliche Meldungen für leere Ergebnisse -- --**Geänderte Dateien:** --- v2_adminpanel/app.py (licenses() und customers() mit Suchlogik erweitert) --- v2_adminpanel/templates/licenses.html (Suchformular hinzugefügt) --- v2_adminpanel/templates/customers.html (Suchformular hinzugefügt) -- --**Technische Details:** --- GET-Parameter für Suche --- SQL LIKE mit LOWER() für Case-Insensitive Suche --- Wildcards (%) für Teilstring-Suche --- UTF-8 kompatibel für deutsche Umlaute -- --### 2025-06-06 - Filter und Pagination implementiert --- Erweiterte Filteroptionen für Lizenzübersicht --- Pagination für große Datenmengen (20 Einträge pro Seite) --- Filter bleiben bei Seitenwechsel erhalten -- --**Neue Features für Lizenzen:** --- **Filter nach Typ**: Alle, Vollversion, Testversion --- **Filter nach Status**: Alle, Aktiv, Läuft bald ab, Abgelaufen, Deaktiviert --- **Kombinierbar mit Suche**: Filter und Suche funktionieren zusammen --- **Pagination**: Navigation durch mehrere Seiten --- **Ergebnisanzeige**: Zeigt Anzahl gefilterter Ergebnisse -- --**Neue Features für Kunden:** --- **Pagination**: 20 Kunden pro Seite --- **Seitennavigation**: Erste, Letzte, Vor, Zurück --- **Kombiniert mit Suche**: Suchparameter bleiben erhalten -- --**Geänderte Dateien:** --- v2_adminpanel/app.py (licenses() und customers() mit Filter/Pagination erweitert) --- v2_adminpanel/templates/licenses.html (Filter-Formular und Pagination hinzugefügt) --- v2_adminpanel/templates/customers.html (Pagination hinzugefügt) -- --**Technische Details:** --- SQL WHERE-Klauseln für Filter --- LIMIT/OFFSET für Pagination --- URL-Parameter bleiben bei Navigation erhalten --- Responsive Bootstrap-Komponenten -- --### 2025-06-06 - Session-Tracking implementiert --- Neue Tabelle für Session-Verwaltung --- Anzeige aktiver und beendeter Sessions --- Manuelles Beenden von Sessions möglich --- Dashboard zeigt Anzahl aktiver Sessions -- --**Neue Features:** --- **Sessions-Tabelle**: Speichert Session-ID, IP, User-Agent, Zeitstempel --- **Aktive Sessions**: Zeigt alle laufenden Sessions mit Inaktivitätszeit --- **Session-Historie**: Letzte 24 Stunden beendeter Sessions --- **Session beenden**: Admins können Sessions manuell beenden --- **Farbcodierung**: Grün (aktiv), Gelb (>5 Min inaktiv), Rot (lange inaktiv) -- --**Geänderte/Neue Dateien:** --- v2_adminpanel/init.sql (sessions Tabelle hinzugefügt) --- v2_adminpanel/app.py (sessions() und end_session() Routen) --- v2_adminpanel/templates/sessions.html (neu erstellt) --- v2_adminpanel/templates/dashboard.html (Session-Statistik) --- Alle Templates (Session-Navigation hinzugefügt) -- --**Technische Details:** --- Heartbeat-basiertes Tracking (last_heartbeat) --- Automatische Inaktivitätsberechnung --- Session-Dauer Berechnung --- Responsive Tabellen mit Bootstrap -- --**Hinweis:** --Die Session-Daten werden erst gefüllt, wenn der License Server API implementiert ist und Clients sich verbinden. -- --### 2025-06-06 - Export-Funktion implementiert --- CSV und Excel Export für Lizenzen und Kunden --- Formatierte Ausgabe mit deutschen Datumsformaten --- UTF-8 Unterstützung für Sonderzeichen -- --**Neue Features:** --- **Lizenz-Export**: Alle Lizenzen mit Kundeninformationen --- **Kunden-Export**: Alle Kunden mit Lizenzstatistiken --- **Format-Optionen**: Excel (.xlsx) und CSV (.csv) --- **Deutsche Formatierung**: Datum als dd.mm.yyyy, Status auf Deutsch --- **UTF-8 Export**: Korrekte Kodierung für Umlaute --- **Export-Buttons**: Dropdown-Menüs in Lizenz- und Kundenübersicht -- --**Geänderte Dateien:** --- v2_adminpanel/app.py (export_licenses() und export_customers() Routen) --- v2_adminpanel/requirements.txt (pandas und openpyxl hinzugefügt) --- v2_adminpanel/templates/licenses.html (Export-Dropdown hinzugefügt) --- v2_adminpanel/templates/customers.html (Export-Dropdown hinzugefügt) -- --**Technische Details:** --- Pandas für Datenverarbeitung --- OpenPyXL für Excel-Export --- CSV mit Semikolon-Trennung für deutsche Excel-Kompatibilität --- Automatische Spaltenbreite in Excel --- BOM für UTF-8 CSV (Excel-Kompatibilität) -- --### 2025-06-06 - Audit-Log implementiert --- Vollständiges Änderungsprotokoll für alle Aktionen --- Filterbare Übersicht mit Pagination --- Detaillierte Anzeige von Änderungen -- --**Neue Features:** --- **Audit-Log-Tabelle**: Speichert alle Änderungen mit Zeitstempel, Benutzer, IP --- **Protokollierte Aktionen**: CREATE, UPDATE, DELETE, LOGIN, LOGOUT, EXPORT --- **JSON-Speicherung**: Alte und neue Werte als JSONB für flexible Abfragen --- **Filter-Optionen**: Nach Benutzer, Aktion und Entität --- **Detail-Anzeige**: Aufklappbare Änderungsdetails --- **Navigation**: Audit-Link in allen Templates -- --**Geänderte/Neue Dateien:** --- v2_adminpanel/init.sql (audit_log Tabelle mit Indizes) --- v2_adminpanel/app.py (log_audit() Funktion und audit_log() Route) --- v2_adminpanel/templates/audit_log.html (neu erstellt) --- Alle Templates (Audit-Navigation hinzugefügt) -- --**Technische Details:** --- JSONB für strukturierte Datenspeicherung --- Performance-Indizes auf timestamp, username und entity --- Farbcodierung für verschiedene Aktionen --- 50 Einträge pro Seite mit Pagination --- IP-Adresse und User-Agent Tracking -- --### 2025-06-06 - PostgreSQL UTF-8 Locale konfiguriert --- Eigenes PostgreSQL Dockerfile für deutsche Locale --- Sicherstellung der UTF-8 Unterstützung auf Datenbankebene -- --**Neue Features:** --- **PostgreSQL Dockerfile**: Installiert deutsche Locale (de_DE.UTF-8) --- **Locale-Umgebungsvariablen**: LANG, LANGUAGE, LC_ALL gesetzt --- **Docker Compose Update**: Verwendet jetzt eigenes PostgreSQL-Image -- --**Neue Dateien:** --- v2_postgres/Dockerfile (neu erstellt) -- --**Geänderte Dateien:** --- v2/docker-compose.yaml (postgres Service nutzt jetzt build statt image) -- --**Technische Details:** --- Basis-Image: postgres:14 --- Locale-Installation über apt-get --- locale-gen für de_DE.UTF-8 --- Vollständige UTF-8 Unterstützung für deutsche Sonderzeichen -- --### 2025-06-07 - Backup-Funktionalität implementiert --- Verschlüsselte Backups mit manueller und automatischer Ausführung --- Backup-Historie mit Download und Wiederherstellung --- Dashboard-Integration für Backup-Status -- --**Neue Features:** --- **Backup-Erstellung**: Manuell und automatisch (täglich 3:00 Uhr) --- **Verschlüsselung**: AES-256 mit Fernet, Key aus ENV oder automatisch generiert --- **Komprimierung**: GZIP-Komprimierung vor Verschlüsselung --- **Backup-Historie**: Vollständige Übersicht aller Backups --- **Wiederherstellung**: Mit optionalem Verschlüsselungs-Passwort --- **Download-Funktion**: Backups können heruntergeladen werden --- **Dashboard-Widget**: Zeigt letztes Backup-Status --- **E-Mail-Vorbereitung**: Struktur für Benachrichtigungen (deaktiviert) -- --**Neue/Geänderte Dateien:** --- v2_adminpanel/init.sql (backup_history Tabelle hinzugefügt) --- v2_adminpanel/requirements.txt (cryptography, apscheduler hinzugefügt) --- v2_adminpanel/app.py (Backup-Funktionen und Routen) --- v2_adminpanel/templates/backups.html (neu erstellt) --- v2_adminpanel/templates/dashboard.html (Backup-Status-Widget) --- v2_adminpanel/Dockerfile (PostgreSQL-Client installiert) --- v2/.env (EMAIL_ENABLED und BACKUP_ENCRYPTION_KEY) --- Alle Templates (Backup-Navigation hinzugefügt) -- --**Technische Details:** --- Speicherort: C:\Users\Administrator\Documents\GitHub\v2-Docker\backups\ --- Dateiformat: backup_v2docker_YYYYMMDD_HHMMSS_encrypted.sql.gz.enc --- APScheduler für automatische Backups --- pg_dump/psql für Datenbank-Operationen --- Audit-Log für alle Backup-Aktionen --- Sicherheitsabfrage bei Wiederherstellung -- --### 2025-06-07 - HTTPS/SSL und Internet-Zugriff implementiert --- Nginx Reverse Proxy für externe Erreichbarkeit eingerichtet --- SSL-Zertifikate von IONOS mit vollständiger Certificate Chain integriert --- Netzwerkkonfiguration für feste IP-Adresse --- DynDNS und Port-Forwarding konfiguriert -- --**Neue Features:** --- **Nginx Reverse Proxy**: Leitet HTTPS-Anfragen an Container weiter --- **SSL-Zertifikate**: Wildcard-Zertifikat von IONOS für *.z5m7q9dk3ah2v1plx6ju.com --- **Certificate Chain**: Server-, Intermediate- und Root-Zertifikate kombiniert --- **Subdomain-Routing**: admin-panel-undso und api-software-undso --- **Port-Forwarding**: FRITZ!Box 443 → 192.168.178.88 --- **Feste IP**: Windows-PC auf 192.168.178.88 konfiguriert -- --**Neue/Geänderte Dateien:** --- v2_nginx/nginx.conf (Reverse Proxy Konfiguration) --- v2_nginx/Dockerfile (Nginx Container mit SSL) --- v2_nginx/ssl/fullchain.pem (Certificate Chain) --- v2_nginx/ssl/privkey.pem (Private Key) --- v2/docker-compose.yaml (nginx Service hinzugefügt) --- set-static-ip.ps1 (PowerShell Script für feste IP) --- reset-to-dhcp.ps1 (PowerShell Script für DHCP) -- --**Technische Details:** --- SSL-Termination am Nginx Reverse Proxy --- Backend-Kommunikation über Docker-internes Netzwerk --- Admin-Panel nur noch über Nginx erreichbar (Port 443 nicht mehr exposed) --- License-Server behält externen Port 8443 für direkte API-Zugriffe --- Intermediate Certificates aus ZIP extrahiert und korrekt verkettet -- --**Zugangsdaten:** --- Admin-Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com --- Benutzer 1: rac00n --- Benutzer 2: w@rh@mm3r -- --**Status:** --- ✅ Admin-Panel extern erreichbar ohne SSL-Warnungen --- ✅ Reverse Proxy funktioniert --- ✅ SSL-Zertifikate korrekt konfiguriert --- ✅ Netzwerk-Setup abgeschlossen -- --### 2025-06-07 - Projekt-Cleanup durchgeführt --- Redundante und überflüssige Dateien entfernt --- Projektstruktur verbessert und organisiert -- --**Durchgeführte Änderungen:** --1. **Entfernte Dateien:** -- - v2_adminpanel/templates/.env (Duplikat der Haupt-.env) -- - v2_postgreSQL/ (leeres Verzeichnis) -- - SSL-Zertifikate aus Root-Verzeichnis (7 Dateien) -- - Ungenutzer `json` Import aus app.py -- --2. **Organisatorische Verbesserungen:** -- - PowerShell-Scripts in neuen `scripts/` Ordner verschoben -- - SSL-Zertifikate nur noch in v2_nginx/ssl/ -- - Keine Konfigurationsdateien mehr in Template-Verzeichnissen -- --**Technische Details:** --- Docker-Container wurden gestoppt und nach Cleanup neu gestartet --- Alle Services laufen wieder normal --- Keine funktionalen Änderungen, nur Struktur-Verbesserungen -- --**Ergebnis:** --- Verbesserte Projektstruktur --- Erhöhte Sicherheit (keine SSL-Zertifikate im Root) --- Klarere Dateiorganisation -- --### 2025-06-07 - SSL "Nicht sicher" Problem behoben --- Chrome-Warnung trotz gültigem Zertifikat analysiert und behoben --- Ursache: Selbstsigniertes Zertifikat in der Admin Panel Flask-App -- --**Durchgeführte Änderungen:** --1. **Admin Panel Konfiguration (app.py):** -- - Von HTTPS mit selbstsigniertem Zertifikat auf HTTP Port 5000 umgestellt -- - `ssl_context='adhoc'` entfernt -- - Flask läuft jetzt auf `0.0.0.0:5000` statt HTTPS -- --2. **Dockerfile Anpassung (v2_adminpanel/Dockerfile):** -- - EXPOSE Port von 443 auf 5000 geändert -- - Container exponiert jetzt HTTP statt HTTPS -- --3. **Nginx Konfiguration (nginx.conf):** -- - proxy_pass von `https://admin-panel:443` auf `http://admin-panel:5000` geändert -- - `proxy_ssl_verify off` entfernt (nicht mehr benötigt) -- - Sicherheits-Header für beide Domains hinzugefügt: -- - Strict-Transport-Security (HSTS) - erzwingt HTTPS für 1 Jahr -- - X-Content-Type-Options - verhindert MIME-Type Sniffing -- - X-Frame-Options - Schutz vor Clickjacking -- - X-XSS-Protection - aktiviert XSS-Filter -- - Referrer-Policy - kontrolliert Referrer-Informationen -- --**Technische Details:** --- Externer Traffic nutzt weiterhin HTTPS mit gültigen IONOS-Zertifikaten --- Interne Kommunikation zwischen Nginx und Admin Panel läuft über HTTP (sicher im Docker-Netzwerk) --- Kein selbstsigniertes Zertifikat mehr in der Zertifikatskette --- SSL-Termination erfolgt ausschließlich am Nginx Reverse Proxy -- --**Docker Neustart:** --- Container gestoppt (`docker-compose down`) --- Images neu gebaut (`docker-compose build`) --- Container neu gestartet (`docker-compose up -d`) --- Alle Services laufen normal -- --**Ergebnis:** --- ✅ "Nicht sicher" Warnung in Chrome behoben --- ✅ Saubere SSL-Konfiguration ohne Mixed Content --- ✅ Verbesserte Sicherheits-Header implementiert --- ✅ Admin Panel zeigt jetzt grünes Schloss-Symbol -- --### 2025-06-07 - Sicherheitslücke geschlossen: License Server Port --- Direkter Zugriff auf License Server Port 8443 entfernt --- Sicherheitsanalyse der exponierten Ports durchgeführt -- --**Identifiziertes Problem:** --- License Server war direkt auf Port 8443 von außen erreichbar --- Umging damit die Nginx-Sicherheitsschicht und Security Headers --- Besonders kritisch, da nur Platzhalter ohne echte Sicherheit -- --**Durchgeführte Änderung:** --- Port-Mapping für License Server in docker-compose.yaml entfernt --- Service ist jetzt nur noch über Nginx Reverse Proxy erreichbar --- Gleiche Sicherheitskonfiguration wie Admin Panel -- --**Aktuelle Port-Exposition:** --- ✅ Nginx: Port 80/443 (benötigt für externen Zugriff) --- ✅ PostgreSQL: Keine Ports exponiert (gut) --- ✅ Admin Panel: Nur über Nginx erreichbar --- ✅ License Server: Nur über Nginx erreichbar (vorher direkt auf 8443) -- --**Weitere identifizierte Sicherheitsthemen:** --1. Credentials im Klartext in .env Datei --2. SSL-Zertifikate im Repository gespeichert --3. License Server noch nicht implementiert -- --**Empfehlung:** Docker-Container neu starten für Änderungsübernahme -- --### 2025-06-07 - License Server Port 8443 wieder aktiviert --- Port 8443 für direkten Zugriff auf License Server wieder geöffnet --- Notwendig für Client-Software Lizenzprüfung -- --**Begründung:** --- Client-Software benötigt direkten Zugriff für Lizenzprüfung --- Umgehung von möglichen Firewall-Blockaden auf Port 443 --- Weniger Latenz ohne Nginx-Proxy --- Flexibilität für verschiedene Client-Implementierungen -- --**Konfiguration:** --- License Server erreichbar über: -- - Direkt: Port 8443 (für Client-Software) -- - Via Nginx: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com (für Browser/Tests) -- --**Sicherheitshinweis:** --- Port 8443 ist wieder direkt exponiert --- License Server muss vor Produktivbetrieb implementiert werden mit: -- - Eigener SSL-Konfiguration -- - API-Key Authentifizierung -- - Rate Limiting -- - Input-Validierung -- --**Status:** --- Port-Mapping in docker-compose.yaml wiederhergestellt --- Änderung erfordert Docker-Neustart -- --### 2025-06-07 - Rate-Limiting und Brute-Force-Schutz implementiert --- Umfassender Schutz vor Login-Angriffen mit IP-Sperre --- Dashboard-Integration für Sicherheitsüberwachung -- --**Implementierte Features:** --1. **Rate-Limiting System:** -- - 5 Login-Versuche erlaubt, danach 24h IP-Sperre -- - Progressive Fehlermeldungen (zufällig aus 5 lustigen Varianten) -- - CAPTCHA nach 2 Fehlversuchen (Google reCAPTCHA v2 vorbereitet) -- - E-Mail-Benachrichtigung bei Sperrung (vorbereitet, deaktiviert für PoC) -- --2. **Timing-Attack Schutz:** -- - Mindestens 1 Sekunde Antwortzeit bei allen Login-Versuchen -- - Gleiche Antwortzeit bei richtigem/falschem Username -- - Verhindert Username-Enumeration -- --3. **Lustige Fehlermeldungen (zufällig):** -- - "NOPE!" -- - "ACCESS DENIED, TRY HARDER" -- - "WRONG! 🚫" -- - "COMPUTER SAYS NO" -- - "YOU FAILED" -- --4. **Dashboard-Sicherheitswidget:** -- - Sicherheitslevel-Anzeige (NORMAL/ERHÖHT/KRITISCH) -- - Anzahl gesperrter IPs -- - Fehlversuche heute -- - Letzte 5 Sicherheitsereignisse mit Details -- --5. **IP-Verwaltung:** -- - Übersicht aller gesperrten IPs -- - Manuelles Entsperren möglich -- - Login-Versuche zurücksetzen -- - Detaillierte Informationen pro IP -- --6. **Audit-Log Erweiterungen:** -- - LOGIN_SUCCESS - Erfolgreiche Anmeldung -- - LOGIN_FAILED - Fehlgeschlagener Versuch -- - LOGIN_BLOCKED - IP wurde gesperrt -- - UNBLOCK_IP - IP manuell entsperrt -- - CLEAR_ATTEMPTS - Versuche zurückgesetzt -- --**Neue/Geänderte Dateien:** --- v2_adminpanel/init.sql (login_attempts Tabelle) --- v2_adminpanel/app.py (Rate-Limiting Logik, neue Routen) --- v2_adminpanel/templates/login.html (Fehlermeldungs-Styling, CAPTCHA) --- v2_adminpanel/templates/dashboard.html (Sicherheitswidget) --- v2_adminpanel/templates/blocked_ips.html (neu - IP-Verwaltung) -- --**Technische Details:** --- IP-Ermittlung berücksichtigt Proxy-Header (X-Forwarded-For) --- Fehlermeldungen mit Animation (shake-effect) --- Farbcodierung: Rot für Fehler, Lila für Sperre, Orange für CAPTCHA --- Automatische Bereinigung alter Einträge möglich -- --**Sicherheitsverbesserungen:** --- Schutz vor Brute-Force-Angriffen --- Timing-Attack-Schutz implementiert --- IP-basierte Sperrung für 24 Stunden --- Audit-Trail für alle Sicherheitsereignisse -- --**Hinweis für Produktion:** --- CAPTCHA-Keys müssen in .env konfiguriert werden --- E-Mail-Server für Benachrichtigungen einrichten --- Rate-Limits können über Konstanten angepasst werden -- --### 2025-06-07 - Session-Timeout mit Live-Timer implementiert --- 5 Minuten Inaktivitäts-Timeout mit visueller Countdown-Anzeige --- Automatische Session-Verlängerung bei Benutzeraktivität -- --**Implementierte Features:** --1. **Session-Timeout Backend:** -- - Flask Session-Timeout auf 5 Minuten konfiguriert -- - Heartbeat-Endpoint für Keep-Alive -- - Automatisches Session-Update bei jeder Aktion -- --2. **Live-Timer in der Navbar:** -- - Countdown von 5:00 bis 0:00 -- - Position: Zwischen Logo und Username -- - Farbwechsel nach verbleibender Zeit: -- - Grün: > 2 Minuten -- - Gelb: 1-2 Minuten -- - Rot: < 1 Minute -- - Blinkend: < 30 Sekunden -- --3. **Benutzerinteraktion:** -- - Timer wird bei jeder Aktivität zurückgesetzt -- - Tracking von: Klicks, Tastatureingaben, Mausbewegungen -- - Automatischer Heartbeat bei Aktivität -- - Warnung bei < 1 Minute mit "Session verlängern" Button -- --4. **Base-Template System:** -- - Neue base.html als Basis für alle Admin-Seiten -- - Alle Templates (außer login.html) nutzen jetzt base.html -- - Einheitliches Layout und Timer auf allen Seiten -- --**Neue/Geänderte Dateien:** --- v2_adminpanel/app.py (Session-Konfiguration, Heartbeat-Endpoint) --- v2_adminpanel/templates/base.html (neu - Base-Template mit Timer) --- Alle anderen Templates aktualisiert für Template-Vererbung -- --**Technische Details:** --- JavaScript-basierter Countdown-Timer --- AJAX-Heartbeat alle 5 Sekunden bei Aktivität --- LocalStorage für Tab-Synchronisation möglich --- Automatischer Logout bei 0:00 --- Fetch-Interceptor für automatische Session-Verlängerung -- --**Sicherheitsverbesserung:** --- Automatischer Logout nach 5 Minuten Inaktivität --- Verhindert vergessene Sessions --- Visuelles Feedback für Session-Status -- --### 2025-06-07 - Session-Timeout Bug behoben --- Problem: Session-Timeout funktionierte nicht korrekt - Session blieb länger als 5 Minuten aktiv --- Ursache: login_required Decorator aktualisierte last_activity bei JEDEM Request -- --**Durchgeführte Änderungen:** --1. **login_required Decorator (app.py):** -- - Prüft jetzt ob Session abgelaufen ist (5 Minuten seit last_activity) -- - Aktualisiert last_activity NICHT mehr automatisch -- - Führt AUTO_LOGOUT mit Audit-Log bei Timeout durch -- - Speichert Username vor session.clear() für korrektes Logging -- --2. **Heartbeat-Endpoint (app.py):** -- - Geändert zu POST-only Endpoint -- - Aktualisiert explizit last_activity wenn aufgerufen -- - Wird nur bei aktiver Benutzerinteraktion aufgerufen -- --3. **Frontend Timer (base.html):** -- - Heartbeat wird als POST Request gesendet -- - trackActivity() ruft extendSession() ohne vorheriges resetTimer() auf -- - Timer wird erst nach erfolgreichem Heartbeat zurückgesetzt -- - AJAX Interceptor ignoriert Heartbeat-Requests -- --4. **Audit-Log Erweiterung:** -- - Neue Aktion AUTO_LOGOUT hinzugefügt -- - Orange Farbcodierung (#fd7e14) -- - Zeigt Grund des Timeouts im Audit-Log -- --**Ergebnis:** --- ✅ Session läuft nach exakt 5 Minuten Inaktivität ab --- ✅ Benutzeraktivität verlängert Session korrekt --- ✅ AUTO_LOGOUT wird im Audit-Log protokolliert --- ✅ Visueller Timer zeigt verbleibende Zeit -- --### 2025-06-07 - Session-Timeout weitere Verbesserungen --- Zusätzliche Fixes nach Test-Feedback implementiert -- --**Weitere durchgeführte Änderungen:** --1. **Fehlender Import behoben:** -- - `flash` zu Flask-Imports hinzugefügt für Timeout-Warnmeldungen -- --2. **Session-Cookie-Konfiguration erweitert (app.py):** -- - SESSION_COOKIE_HTTPONLY = True (Sicherheit gegen XSS) -- - SESSION_COOKIE_SECURE = False (intern HTTP, extern HTTPS via Nginx) -- - SESSION_COOKIE_SAMESITE = 'Lax' (CSRF-Schutz) -- - SESSION_COOKIE_NAME = 'admin_session' (eindeutiger Name) -- - SESSION_REFRESH_EACH_REQUEST = False (verhindert automatische Verlängerung) -- --3. **Session-Handling verbessert:** -- - Entfernt: session.permanent = True aus login_required decorator -- - Hinzugefügt: session.modified = True im Heartbeat für explizites Speichern -- - Debug-Logging für Session-Timeout-Prüfung hinzugefügt -- --4. **Nginx-Konfiguration:** -- - Bereits korrekt konfiguriert für Heartbeat-Weiterleitung -- - Proxy-Headers für korrekte IP-Weitergabe -- --**Technische Details:** --- Flask-Session mit Filesystem-Backend nutzt jetzt korrekte Cookie-Einstellungen --- Session-Cookie wird nicht mehr automatisch bei jedem Request verlängert --- Explizite Session-Modifikation nur bei Heartbeat-Requests --- Debug-Logs zeigen Zeit seit letzter Aktivität für Troubleshooting -- --**Status:** --- ✅ Session-Timeout-Mechanismus vollständig implementiert --- ✅ Debug-Logging für Session-Überwachung aktiv --- ✅ Cookie-Sicherheitseinstellungen optimiert -- --### 2025-06-07 - CAPTCHA Backend-Validierung implementiert --- Google reCAPTCHA v2 Backend-Verifizierung hinzugefügt -- --**Implementierte Features:** --1. **verify_recaptcha() Funktion (app.py):** -- - Validiert CAPTCHA-Response mit Google API -- - Fallback: Wenn RECAPTCHA_SECRET_KEY nicht konfiguriert, wird CAPTCHA übersprungen (für PoC) -- - Timeout von 5 Sekunden für API-Request -- - Error-Handling für Netzwerkfehler -- - Logging für Debugging und Fehleranalyse -- --2. **Login-Route Erweiterungen:** -- - CAPTCHA wird nach 2 Fehlversuchen angezeigt -- - Prüfung ob CAPTCHA-Response vorhanden -- - Validierung der CAPTCHA-Response gegen Google API -- - Unterschiedliche Fehlermeldungen für fehlende/ungültige CAPTCHA -- - Site Key wird aus Environment-Variable an Template übergeben -- --3. **Environment-Konfiguration (.env):** -- - RECAPTCHA_SITE_KEY (für Frontend) -- - RECAPTCHA_SECRET_KEY (für Backend-Validierung) -- - Beide auskommentiert für PoC-Phase -- --4. **Dependencies:** -- - requests Library zu requirements.txt hinzugefügt -- --**Sicherheitsaspekte:** --- CAPTCHA verhindert automatisierte Brute-Force-Angriffe --- Timing-Attack-Schutz bleibt auch bei CAPTCHA-Prüfung aktiv --- Bei Netzwerkfehlern wird CAPTCHA als bestanden gewertet (Verfügbarkeit vor Sicherheit) --- Secret Key wird niemals im Frontend exponiert -- --**Verwendung:** --1. Google reCAPTCHA v2 Keys erstellen: https://www.google.com/recaptcha/admin --2. Keys in .env eintragen: -- ``` -- RECAPTCHA_SITE_KEY=your-site-key -- RECAPTCHA_SECRET_KEY=your-secret-key -- ``` --3. Container neu starten -- --**Status:** --- ✅ CAPTCHA-Frontend bereits vorhanden (login.html) --- ✅ Backend-Validierung vollständig implementiert --- ✅ Fallback für PoC-Betrieb ohne Google-Keys --- ✅ Integration in Rate-Limiting-System --- ⚠️ CAPTCHA-Keys noch nicht konfiguriert (für PoC deaktiviert) -- --**Anleitung für Google reCAPTCHA Keys:** -- --1. **Registrierung bei Google reCAPTCHA:** -- - Gehe zu: https://www.google.com/recaptcha/admin/create -- - Melde dich mit Google-Konto an -- - Label eingeben: "v2-Docker Admin Panel" -- - Typ wählen: "reCAPTCHA v2" → "Ich bin kein Roboter"-Kästchen -- - Domains hinzufügen: -- ``` -- admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com -- localhost -- ``` -- - Nutzungsbedingungen akzeptieren -- - Senden klicken -- --2. **Keys erhalten:** -- - Site Key (öffentlich für Frontend) -- - Secret Key (geheim für Backend-Validierung) -- --3. **Keys in .env eintragen:** -- ```bash -- RECAPTCHA_SITE_KEY=6Ld... -- RECAPTCHA_SECRET_KEY=6Ld... -- ``` -- --4. **Container neu starten:** -- ```bash -- docker-compose down -- docker-compose up -d -- ``` -- --**Kosten:** --- Kostenlos bis 1 Million Anfragen pro Monat --- Danach: $1.00 pro 1000 zusätzliche Anfragen --- Für dieses Projekt reicht die kostenlose Version vollkommen aus -- --**Test-Keys für Entwicklung:** --- Site Key: `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI` --- Secret Key: `6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe` --- ⚠️ Diese Keys nur für lokale Tests verwenden, niemals produktiv! -- --**Aktueller Status:** --- Code ist vollständig implementiert und getestet --- CAPTCHA wird nach 2 Fehlversuchen angezeigt --- Ohne konfigurierte Keys wird CAPTCHA-Prüfung übersprungen --- Für Produktion müssen nur die Keys in .env eingetragen werden -- --### 2025-06-07 - License Key Generator implementiert --- Automatische Generierung von Lizenzschlüsseln mit definiertem Format -- --**Implementiertes Format:** --`AF-YYYYMMFT-XXXX-YYYY-ZZZZ` --- **AF** = Account Factory (feste Produktkennung) --- **YYYY** = Jahr (z.B. 2025) --- **MM** = Monat (z.B. 06) --- **FT** = Lizenztyp (F=Fullversion, T=Testversion) --- **XXXX-YYYY-ZZZZ** = Zufällige alphanumerische Zeichen (ohne verwirrende wie 0/O, 1/I/l) -- --**Beispiele:** --- Vollversion: `AF-202506F-A7K9-M3P2-X8R4` --- Testversion: `AF-202512T-B2N5-K8L3-Q9W7` -- --**Implementierte Features:** -- --1. **Backend-Funktionen (app.py):** -- - `generate_license_key()` - Generiert Keys mit kryptografisch sicherem Zufallsgenerator -- - `validate_license_key()` - Validiert das Key-Format mit Regex -- - Verwendet `secrets` statt `random` für Sicherheit -- - Erlaubte Zeichen: ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (ohne verwirrende) -- --2. **API-Endpoint:** -- - POST `/api/generate-license-key` - JSON API für Key-Generierung -- - Prüft auf Duplikate in der Datenbank (max. 10 Versuche) -- - Audit-Log-Eintrag bei jeder Generierung -- - Login-Required geschützt -- --3. **Frontend-Verbesserungen (index.html):** -- - Generate-Button neben License Key Input -- - Placeholder und Pattern-Attribut für Format-Hinweis -- - Auto-Uppercase bei manueller Eingabe -- - Visuelles Feedback bei erfolgreicher Generierung -- - Format-Hinweis unter dem Eingabefeld -- --4. **JavaScript-Features:** -- - AJAX-basierte Key-Generierung ohne Seiten-Reload -- - Automatische Prüfung bei Lizenztyp-Änderung -- - Ladeindikator während der Generierung -- - Fehlerbehandlung mit Benutzer-Feedback -- - Standard-Datum-Einstellungen (heute + 1 Jahr) -- --5. **Validierung:** -- - Server-seitige Format-Validierung beim Speichern -- - Flash-Message bei ungültigem Format -- - Automatische Großschreibung des Keys -- - Pattern-Validierung im HTML-Formular -- --6. **Weitere Fixes:** -- - Form Action von "/" auf "/create" korrigiert -- - Flash-Messages mit Bootstrap Toasts implementiert -- - GENERATE_KEY Aktion zum Audit-Log hinzugefügt (Farbe: #20c997) -- --**Technische Details:** --- Keine vorhersagbaren Muster durch `secrets.choice()` --- Datum im Key zeigt Erstellungszeitpunkt --- Lizenztyp direkt im Key erkennbar --- Kollisionsprüfung gegen Datenbank -- --**Status:** --- ✅ Backend-Generierung vollständig implementiert --- ✅ Frontend mit Generate-Button und JavaScript --- ✅ Validierung und Fehlerbehandlung --- ✅ Audit-Log-Integration --- ✅ Form-Action-Bug behoben -- --### 2025-06-07 - Batch-Lizenzgenerierung implementiert --- Mehrere Lizenzen auf einmal für einen Kunden erstellen -- --**Implementierte Features:** -- --1. **Batch-Formular (/batch):** -- - Kunde und E-Mail eingeben -- - Anzahl der Lizenzen (1-100) -- - Lizenztyp (Vollversion/Testversion) -- - Gültigkeitszeitraum für alle Lizenzen -- - Vorschau-Modal zeigt Key-Format -- - Standard-Datum-Einstellungen (heute + 1 Jahr) -- --2. **Backend-Verarbeitung:** -- - Route `/batch` für GET (Formular) und POST (Generierung) -- - Generiert die angegebene Anzahl eindeutiger Keys -- - Speichert alle in einer Transaktion -- - Kunde wird automatisch angelegt (falls nicht vorhanden) -- - ON CONFLICT für existierende Kunden -- - Audit-Log-Eintrag mit CREATE_BATCH Aktion -- --3. **Ergebnis-Seite:** -- - Zeigt alle generierten Lizenzen in Tabellenform -- - Kundeninformationen und Gültigkeitszeitraum -- - Einzelne Keys können kopiert werden (📋 Button) -- - Alle Keys auf einmal kopieren -- - Druckfunktion für physische Ausgabe -- - Link zur Lizenzübersicht mit Kundenfilter -- --4. **Export-Funktionalität:** -- - Route `/batch/export` für CSV-Download -- - Speichert Batch-Daten in Session für Export -- - CSV mit UTF-8 BOM für Excel-Kompatibilität -- - Enthält Kundeninfo, Generierungsdatum und alle Keys -- - Format: Nr;Lizenzschlüssel;Typ -- - Dateiname: batch_licenses_KUNDE_TIMESTAMP.csv -- --5. **Integration:** -- - Batch-Button in Navigation (Dashboard, Einzellizenz-Seite) -- - CREATE_BATCH Aktion im Audit-Log (Farbe: #6610f2) -- - Session-basierte Export-Daten -- - Flash-Messages für Feedback -- --**Sicherheit:** --- Limit von 100 Lizenzen pro Batch --- Login-Required für alle Routen --- Transaktionale Datenbank-Operationen --- Validierung der Eingaben -- --**Beispiel-Workflow:** --1. Admin geht zu `/batch` --2. Gibt Kunde "Firma GmbH", Anzahl "25", Typ "Vollversion" ein --3. System generiert 25 eindeutige Keys --4. Ergebnis-Seite zeigt alle Keys --5. Admin kann CSV exportieren oder Keys kopieren --6. Kunde erhält die Lizenzen -- --**Status:** --- ✅ Batch-Formular vollständig implementiert --- ✅ Backend-Generierung mit Transaktionen --- ✅ Export als CSV --- ✅ Copy-to-Clipboard Funktionalität --- ✅ Audit-Log-Integration --- ✅ Navigation aktualisiert -- --## 2025-06-06: Implementierung Searchable Dropdown für Kundenauswahl -- --**Problem:** --- Bei der Lizenzerstellung wurde immer ein neuer Kunde angelegt --- Keine Möglichkeit, Lizenzen für bestehende Kunden zu erstellen --- Bei vielen Kunden wäre ein normales Dropdown unübersichtlich -- --**Lösung:** --1. **Select2 Library** für searchable Dropdown integriert --2. **API-Endpoint `/api/customers`** für die Kundensuche erstellt --3. **Frontend angepasst:** -- - Searchable Dropdown mit Live-Suche -- - Option "Neuer Kunde" im Dropdown -- - Eingabefelder erscheinen nur bei "Neuer Kunde" --4. **Backend-Logik verbessert:** -- - Prüfung ob neuer oder bestehender Kunde -- - E-Mail-Duplikatsprüfung vor Kundenerstellung -- - Separate Audit-Logs für Kunde und Lizenz --5. **Datenbank:** -- - UNIQUE Constraint auf E-Mail-Spalte hinzugefügt -- --**Änderungen:** --- `app.py`: Neuer API-Endpoint `/api/customers`, angepasste Routes `/create` und `/batch` --- `base.html`: Select2 CSS und JS eingebunden --- `index.html`: Kundenauswahl mit Select2 implementiert --- `batch_form.html`: Kundenauswahl mit Select2 implementiert --- `init.sql`: UNIQUE Constraint für E-Mail -- --**Status:** --- ✅ API-Endpoint funktioniert mit Pagination --- ✅ Select2 Dropdown mit Suchfunktion --- ✅ Neue/bestehende Kunden können ausgewählt werden --- ✅ E-Mail-Duplikate werden verhindert --- ✅ Sowohl Einzellizenz als auch Batch unterstützt -- --## 2025-06-06: Automatische Ablaufdatum-Berechnung -- --**Problem:** --- Manuelles Eingeben von Start- und Enddatum war umständlich --- Fehleranfällig bei der Datumseingabe --- Nicht intuitiv für Standard-Laufzeiten -- --**Lösung:** --1. **Frontend-Änderungen:** -- - Startdatum + Laufzeit (Zahl) + Einheit (Tage/Monate/Jahre) -- - Ablaufdatum wird automatisch berechnet und angezeigt (read-only) -- - Standard: 1 Jahr Laufzeit voreingestellt --2. **Backend-Validierung:** -- - Server-seitige Berechnung zur Sicherheit -- - Verwendung von `python-dateutil` für korrekte Monats-/Jahresberechnungen --3. **Benutzerfreundlichkeit:** -- - Sofortige Neuberechnung bei Änderungen -- - Visuelle Hervorhebung des berechneten Datums -- --**Änderungen:** --- `index.html`: Laufzeit-Eingabe statt Ablaufdatum --- `batch_form.html`: Laufzeit-Eingabe statt Ablaufdatum --- `app.py`: Datum-Berechnung in `/create` und `/batch` Routes --- `requirements.txt`: `python-dateutil` hinzugefügt -- --**Status:** --- ✅ Automatische Berechnung funktioniert --- ✅ Frontend zeigt berechnetes Datum sofort an --- ✅ Backend validiert die Berechnung --- ✅ Standardwert (1 Jahr) voreingestellt -- --## 2025-06-06: Bugfix - created_at für licenses Tabelle -- --**Problem:** --- Batch-Generierung schlug fehl mit "Fehler bei der Batch-Generierung!" --- INSERT Statement versuchte `created_at` zu setzen, aber Spalte existierte nicht --- Inkonsistenz: Einzellizenzen hatten kein created_at, Batch-Lizenzen versuchten es zu setzen -- --**Lösung:** --1. **Datenbank-Schema erweitert:** -- - `created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` zur licenses Tabelle hinzugefügt -- - Migration für bestehende Datenbanken implementiert -- - Konsistent mit customers Tabelle --2. **Code bereinigt:** -- - Explizites `created_at` aus Batch-INSERT entfernt -- - Datenbank setzt nun automatisch den Zeitstempel bei ALLEN Lizenzen -- --**Änderungen:** --- `init.sql`: created_at Spalte zur licenses Tabelle mit DEFAULT-Wert --- `init.sql`: Migration für bestehende Datenbanken --- `app.py`: Entfernt explizites created_at aus batch_licenses() -- --**Status:** --- ✅ Alle Lizenzen haben nun automatisch einen Erstellungszeitstempel --- ✅ Batch-Generierung funktioniert wieder --- ✅ Konsistente Zeitstempel für Audit-Zwecke -- --## 2025-06-06: Status "Deaktiviert" für manuell abgeschaltete Lizenzen -- --**Problem:** --- Dashboard zeigte nur "aktiv" und "abgelaufen" als Status --- Manuell deaktivierte Lizenzen (is_active = FALSE) wurden nicht korrekt angezeigt --- Filter für "inactive" existierte, aber Status wurde nicht richtig berechnet -- --**Lösung:** --1. **Status-Berechnung erweitert:** -- - CASE-Statement prüft zuerst `is_active = FALSE` -- - Status "deaktiviert" wird vor anderen Status geprüft -- - Reihenfolge: deaktiviert → abgelaufen → läuft bald ab → aktiv --2. **Dashboard-Statistik erweitert:** -- - Neue Zählung für deaktivierte Lizenzen -- - Variable `inactive_licenses` im stats Dictionary -- --**Änderungen:** --- `app.py`: Dashboard - Status-Berechnung für letzte 5 Lizenzen --- `app.py`: Lizenzübersicht - Status-Berechnung in der Hauptabfrage --- `app.py`: Export - Status-Berechnung für CSV/Excel Export --- `app.py`: Dashboard - Neue Statistik für deaktivierte Lizenzen -- --**Status:** --- ✅ "Deaktiviert" wird korrekt als Status angezeigt --- ✅ Dashboard zeigt Anzahl deaktivierter Lizenzen --- ✅ Export enthält korrekten Status --- ✅ Konsistente Status-Anzeige überall -- --## 2025-06-08: SSL-Sicherheit verbessert - Chrome Warnung behoben -- --**Problem:** --- Chrome zeigte Warnung "Die Verbindung zu dieser Website ist nicht sicher" --- Nginx erlaubte schwache Cipher Suites (WEAK) ohne Perfect Forward Secrecy --- Veraltete SSL-Konfiguration mit `ssl_ciphers HIGH:!aNULL:!MD5;` -- --**Lösung:** --1. **Moderne Cipher Suite Konfiguration:** -- - Nur sichere ECDHE und DHE Cipher Suites -- - Entfernung aller RSA-only Cipher Suites -- - Perfect Forward Secrecy für alle Verbindungen --2. **SSL-Optimierungen:** -- - Session Cache aktiviert (1 Tag Timeout) -- - OCSP Stapling für bessere Performance -- - DH Parameters (2048 bit) für zusätzliche Sicherheit --3. **Resolver-Konfiguration:** -- - Google DNS Server für OCSP Stapling -- --**Änderungen:** --- `v2_nginx/nginx.conf`: Komplett überarbeitete SSL-Konfiguration --- `v2_nginx/ssl/dhparam.pem`: Neue 2048-bit DH Parameters generiert --- `v2_nginx/Dockerfile`: COPY Befehl für dhparam.pem hinzugefügt -- --**Status:** --- ✅ Nur noch sichere Cipher Suites aktiv --- ✅ Perfect Forward Secrecy gewährleistet --- ✅ OCSP Stapling aktiviert --- ✅ Chrome Sicherheitswarnung behoben -- --**Hinweis:** Nach dem Rebuild des nginx Containers wird die Verbindung als sicher angezeigt. -- --## 2025-06-08: CAPTCHA-Login-Bug behoben -- --**Problem:** --- Nach 2 fehlgeschlagenen Login-Versuchen wurde CAPTCHA angezeigt --- Da keine CAPTCHA-Keys konfiguriert waren (für PoC), konnte man sich nicht mehr einloggen --- Selbst mit korrektem Passwort war Login blockiert --- Fehlermeldung "CAPTCHA ERFORDERLICH!" erschien immer -- --**Lösung:** --1. **CAPTCHA-Prüfung nur wenn Keys vorhanden:** -- - `recaptcha_site_key` wird vor CAPTCHA-Prüfung geprüft -- - Wenn keine Keys konfiguriert → kein CAPTCHA-Check -- - CAPTCHA wird nur angezeigt wenn Keys existieren --2. **Template-Anpassungen:** -- - login.html zeigt CAPTCHA nur wenn `recaptcha_site_key` vorhanden -- - Kein Test-Key mehr als Fallback --3. **Konsistente Logik:** -- - show_captcha prüft jetzt auch ob Keys vorhanden sind -- - Bei GET und POST Requests gleiche Logik -- --**Änderungen:** --- `v2_adminpanel/app.py`: CAPTCHA-Check nur wenn `RECAPTCHA_SITE_KEY` existiert --- `v2_adminpanel/templates/login.html`: CAPTCHA nur anzeigen wenn Keys vorhanden -- --**Status:** --- ✅ Login funktioniert wieder nach 2+ Fehlversuchen --- ✅ CAPTCHA wird nur angezeigt wenn Keys konfiguriert sind --- ✅ Für PoC-Phase ohne CAPTCHA nutzbar --- ✅ Produktiv-ready wenn CAPTCHA-Keys eingetragen werden -- --### 2025-06-08: Zeitzone auf Europe/Berlin umgestellt -- --**Problem:** --- Alle Zeitstempel wurden in UTC gespeichert und angezeigt --- Backup-Dateinamen zeigten UTC-Zeit statt deutsche Zeit --- Verwirrung bei Zeitangaben im Admin Panel und Logs -- --**Lösung:** --1. **Docker Container Zeitzone konfiguriert:** -- - Alle Dockerfiles mit `TZ=Europe/Berlin` und tzdata Installation -- - PostgreSQL mit `PGTZ=Europe/Berlin` für Datenbank-Zeitzone -- - Explizite Zeitzone-Dateien in /etc/localtime und /etc/timezone -- --2. **Python Code angepasst:** -- - Import von `zoneinfo.ZoneInfo` für Zeitzonenunterstützung -- - Alle `datetime.now()` Aufrufe mit `ZoneInfo("Europe/Berlin")` -- - `.replace(tzinfo=None)` für Kompatibilität mit timezone-unaware Timestamps -- --3. **PostgreSQL Konfiguration:** -- - `SET timezone = 'Europe/Berlin';` in init.sql -- - Umgebungsvariablen TZ und PGTZ in docker-compose.yaml -- --4. **docker-compose.yaml erweitert:** -- - `TZ: Europe/Berlin` für alle Services -- --**Geänderte Dateien:** --- `v2_adminpanel/Dockerfile`: Zeitzone und tzdata hinzugefügt --- `v2_postgres/Dockerfile`: Zeitzone und tzdata hinzugefügt --- `v2_nginx/Dockerfile`: Zeitzone und tzdata hinzugefügt --- `v2_lizenzserver/Dockerfile`: Zeitzone und tzdata hinzugefügt --- `v2_adminpanel/app.py`: 14 datetime.now() Aufrufe mit Zeitzone versehen --- `v2_adminpanel/init.sql`: PostgreSQL Zeitzone gesetzt --- `v2/docker-compose.yaml`: TZ Environment-Variable für alle Services -- --**Ergebnis:** --- ✅ Alle neuen Zeitstempel werden in deutscher Zeit (Europe/Berlin) gespeichert --- ✅ Backup-Dateinamen zeigen korrekte deutsche Zeit --- ✅ Admin Panel zeigt alle Zeiten in deutscher Zeitzone --- ✅ Automatische Anpassung bei Sommer-/Winterzeit --- ✅ Konsistente Zeitangaben über alle Komponenten -- --**Hinweis:** Nach diesen Änderungen müssen die Docker Container neu gebaut werden: --```bash --docker-compose down --docker-compose build --docker-compose up -d --``` -- --### 2025-06-08: Zeitzone-Fix - PostgreSQL Timestamps -- --**Problem nach erster Implementierung:** --- Trotz Zeitzoneneinstellung wurden Zeiten immer noch in UTC angezeigt --- Grund: PostgreSQL Tabellen verwendeten `TIMESTAMP WITHOUT TIME ZONE` -- --**Zusätzliche Lösung:** --1. **Datenbankschema angepasst:** -- - Alle `TIMESTAMP` Spalten auf `TIMESTAMP WITH TIME ZONE` geändert -- - Betrifft: created_at, timestamp, started_at, ended_at, last_heartbeat, etc. -- - Migration für bestehende Datenbanken berücksichtigt -- --2. **SQL-Abfragen vereinfacht:** -- - `AT TIME ZONE 'Europe/Berlin'` nicht mehr nötig -- - PostgreSQL handhabt Zeitzonenkonvertierung automatisch -- --**Geänderte Datei:** --- `v2_adminpanel/init.sql`: Alle TIMESTAMP Felder mit WITH TIME ZONE -- --**Wichtig:** Bei bestehenden Installationen muss die Datenbank neu initialisiert oder manuell migriert werden: --```sql --ALTER TABLE customers ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; --ALTER TABLE licenses ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; --ALTER TABLE sessions ALTER COLUMN started_at TYPE TIMESTAMP WITH TIME ZONE; --ALTER TABLE sessions ALTER COLUMN last_heartbeat TYPE TIMESTAMP WITH TIME ZONE; --ALTER TABLE sessions ALTER COLUMN ended_at TYPE TIMESTAMP WITH TIME ZONE; --ALTER TABLE audit_log ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE; --ALTER TABLE backup_history ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; --ALTER TABLE login_attempts ALTER COLUMN first_attempt TYPE TIMESTAMP WITH TIME ZONE; --ALTER TABLE login_attempts ALTER COLUMN last_attempt TYPE TIMESTAMP WITH TIME ZONE; --ALTER TABLE login_attempts ALTER COLUMN blocked_until TYPE TIMESTAMP WITH TIME ZONE; --``` -- --### 2025-06-08: UI/UX Überarbeitung - Phase 1 (Navigation) -- --**Problem:** --- Inkonsistente Navigation zwischen verschiedenen Seiten --- Zu viele Navigationspunkte im Dashboard --- Verwirrende Benutzerführung -- --**Lösung:** --1. **Dashboard vereinfacht:** -- - Nur noch 3 Buttons: Neue Lizenz, Batch-Lizenzen, Log -- - Statistik-Karten wurden klickbar gemacht (verlinken zu jeweiligen Seiten) -- - "Audit" wurde zu "Log" umbenannt -- --2. **Navigation konsistent gemacht:** -- - Navbar-Brand "AccountForger - Admin Panel" ist jetzt klickbar und führt zum Dashboard -- - Keine Log-Links mehr in Unterseiten -- - Konsistente "Dashboard" Buttons in allen Unterseiten -- --**Geänderte Dateien:** --- `v2_adminpanel/templates/base.html`: Navbar-Brand klickbar gemacht --- `v2_adminpanel/templates/dashboard.html`: Navigation reduziert, Karten klickbar --- `v2_adminpanel/templates/*.html`: Konsistente Dashboard-Links -- --### 2025-06-08: UI/UX Überarbeitung - Phase 2 (Visuelle Verbesserungen) -- --**Implementierte Verbesserungen:** --1. **Größere Icons in Statistik-Karten:** -- - Icon-Größe auf 3rem erhöht -- - Bessere visuelle Hierarchie -- --2. **Donut-Chart für Lizenzen:** -- - Chart.js Integration für Lizenzstatistik -- - Zeigt Verhältnis Aktiv/Abgelaufen -- - UPDATE: Später wieder entfernt auf Benutzerwunsch -- --3. **Pulse-Effekt für aktive Sessions:** -- - CSS-Animation für aktive Sessions -- - Visueller Indikator für Live-Aktivität -- --4. **Progress-Bar für Backup-Status:** -- - Zeigt visuell den Erfolg des letzten Backups -- - Inkl. Dateigröße und Dauer -- --5. **Konsistente Farbcodierung:** -- - CSS-Variablen für Statusfarben -- - Globale Klassen für konsistente Darstellung -- --**Geänderte Dateien:** --- `v2_adminpanel/templates/base.html`: Globale CSS-Variablen und Statusklassen --- `v2_adminpanel/templates/dashboard.html`: Visuelle Verbesserungen implementiert -- --### 2025-06-08: UI/UX Überarbeitung - Phase 3 (Tabellen-Optimierungen) -- --**Problem:** --- Tabellen waren schwer zu navigieren bei vielen Einträgen --- Keine Möglichkeit für Bulk-Operationen --- Umständliches Kopieren von Lizenzschlüsseln -- --**Lösung:** --1. **Sticky Headers:** -- - Tabellenköpfe bleiben beim Scrollen sichtbar -- - CSS-Klasse `.table-sticky` mit `position: sticky` -- --2. **Inline-Actions:** -- - Copy-Button direkt neben Lizenzschlüsseln -- - Toggle-Switches für Aktiv/Inaktiv-Status -- - Visuelles Feedback bei Aktionen -- --3. **Bulk-Actions:** -- - Checkboxen für Mehrfachauswahl -- - "Select All" Funktionalität -- - Bulk-Actions Bar mit Aktivieren/Deaktivieren/Löschen -- - JavaScript für dynamische Anzeige -- --4. **API-Endpoints hinzugefügt:** -- - `/api/license//toggle` - Toggle einzelner Lizenzstatus -- - `/api/licenses/bulk-activate` - Mehrere Lizenzen aktivieren -- - `/api/licenses/bulk-deactivate` - Mehrere Lizenzen deaktivieren -- - `/api/licenses/bulk-delete` - Mehrere Lizenzen löschen -- --5. **Beispieldaten eingefügt:** -- - 15 Testkunden -- - 18 Lizenzen (verschiedene Status) -- - Sessions, Audit-Logs, Login-Attempts -- - Backup-Historie -- --**Geänderte Dateien:** --- `v2_adminpanel/templates/base.html`: CSS für Sticky-Tables und Bulk-Actions --- `v2_adminpanel/templates/licenses.html`: Komplette Tabellen-Überarbeitung --- `v2_adminpanel/app.py`: 4 neue API-Endpoints für Toggle und Bulk-Operationen --- `v2_adminpanel/sample_data.sql`: Umfangreiche Testdaten erstellt -- --**Bugfix:** --- API-Endpoints versuchten `updated_at` zu setzen, obwohl die Spalte nicht existiert --- Entfernt aus allen 3 betroffenen Endpoints -- --**Status:** --- ✅ Sticky Headers funktionieren --- ✅ Copy-Buttons mit Clipboard-API --- ✅ Toggle-Switches ändern Lizenzstatus --- ✅ Bulk-Operationen vollständig implementiert --- ✅ Testdaten erfolgreich eingefügt -- --### 2025-06-08: UI/UX Überarbeitung - Phase 4 (Sortierbare Tabellen) -- --**Problem:** --- Keine Möglichkeit, Tabellen nach verschiedenen Spalten zu sortieren --- Besonders bei großen Datenmengen schwer zu navigieren -- --**Lösung - Hybrid-Ansatz:** --1. **Client-seitige Sortierung für kleine Tabellen:** -- - Dashboard (3 kleine Übersichtstabellen) -- - Blocked IPs (typischerweise wenige Einträge) -- - Backups (begrenzte Anzahl) -- - JavaScript-basierte Sortierung ohne Reload -- --2. **Server-seitige Sortierung für große Tabellen:** -- - Licenses (potenziell tausende Einträge) -- - Customers (viele Kunden möglich) -- - Audit Log (wächst kontinuierlich) -- - Sessions (viele aktive/beendete Sessions) -- - URL-Parameter für Sortierung mit SQL ORDER BY -- --**Implementierung:** --1. **Client-seitige Sortierung:** -- - Generische JavaScript-Funktion in base.html -- - CSS-Klasse `.sortable-table` für betroffene Tabellen -- - Sortier-Indikatoren (↑↓↕) bei Hover/Active -- - Unterstützung für Text, Zahlen und deutsche Datumsformate -- --2. **Server-seitige Sortierung:** -- - Query-Parameter `sort` und `order` in Routes -- - Whitelist für erlaubte Sortierfelder (SQL-Injection-Schutz) -- - Makro-Funktionen für sortierbare Header -- - Sortier-Parameter in Pagination-Links erhalten -- --**Geänderte Dateien:** --- `v2_adminpanel/templates/base.html`: CSS und JavaScript für Sortierung --- `v2_adminpanel/templates/dashboard.html`: Client-seitige Sortierung --- `v2_adminpanel/templates/blocked_ips.html`: Client-seitige Sortierung --- `v2_adminpanel/templates/backups.html`: Client-seitige Sortierung --- `v2_adminpanel/templates/licenses.html`: Server-seitige Sortierung --- `v2_adminpanel/templates/customers.html`: Server-seitige Sortierung --- `v2_adminpanel/templates/audit_log.html`: Server-seitige Sortierung --- `v2_adminpanel/templates/sessions.html`: Server-seitige Sortierung (2 Tabellen) --- `v2_adminpanel/app.py`: 4 Routes erweitert für Sortierung -- --**Besonderheiten:** --- Sessions-Seite hat zwei unabhängige Tabellen mit eigenen Sortierparametern --- Intelligente Datentyp-Erkennung (numeric, date) für korrekte Sortierung --- Visuelle Sortier-Indikatoren zeigen aktuelle Sortierung --- Alle anderen Filter und Suchparameter bleiben bei Sortierung erhalten -- --**Status:** --- ✅ Client-seitige Sortierung für kleine Tabellen --- ✅ Server-seitige Sortierung für große Tabellen --- ✅ Sortier-Indikatoren und visuelle Rückmeldung --- ✅ SQL-Injection-Schutz durch Whitelisting --- ✅ Vollständige Integration mit bestehenden Features -- --### 2025-06-08: Bugfix - Sortierlogik korrigiert -- --**Problem:** --- Sortierung funktionierte nicht korrekt --- Beim Klick auf Spaltenköpfe wurde immer absteigend sortiert --- Toggle zwischen ASC/DESC funktionierte nicht -- --**Ursachen:** --1. **Falsche Bedingungslogik**: Die ursprüngliche Implementierung verwendete eine fehlerhafte Ternär-Bedingung --2. **Berechnete Felder**: Das 'status' Feld in der Lizenztabelle konnte nicht direkt sortiert werden -- --**Lösung:** --1. **Sortierlogik korrigiert:** -- - Bei neuer Spalte: Immer aufsteigend (ASC) beginnen -- - Bei gleicher Spalte: Toggle zwischen ASC und DESC -- - Implementiert durch bedingte Links in den Makros -- --2. **Spezialbehandlung für berechnete Felder:** -- - Status-Feld verwendet CASE-Statement in ORDER BY -- - Wiederholt die gleiche Logik wie im SELECT -- --**Geänderte Dateien:** --- `v2_adminpanel/templates/licenses.html`: Sortierlogik korrigiert --- `v2_adminpanel/templates/customers.html`: Sortierlogik korrigiert --- `v2_adminpanel/templates/audit_log.html`: Sortierlogik korrigiert --- `v2_adminpanel/templates/sessions.html`: Sortierlogik für beide Tabellen korrigiert --- `v2_adminpanel/app.py`: Spezialbehandlung für Status-Feld in licenses Route -- --**Verhalten nach Fix:** --- ✅ Erster Klick auf Spalte: Aufsteigend sortieren --- ✅ Zweiter Klick: Absteigend sortieren --- ✅ Weitere Klicks: Toggle zwischen ASC/DESC --- ✅ Sortierung funktioniert korrekt mit Pagination und Filtern -- --### 2025-06-09: Port 8443 geschlossen - API nur noch über Nginx -- --**Problem:** --- Doppelte Verfügbarkeit des License Servers (Port 8443 + Nginx) machte keinen Sinn --- Inkonsistente Sicherheitskonfiguration (Nginx hatte Security Headers, Port 8443 nicht) --- Doppelte SSL-Konfiguration nötig --- Verwirrung welcher Zugangsweg genutzt werden soll -- --**Lösung:** --- Port-Mapping für License Server in docker-compose.yaml entfernt --- API nur noch über Nginx erreichbar: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com --- Interne Kommunikation zwischen Nginx und License Server bleibt bestehen -- --**Vorteile:** --- ✅ Einheitliche Sicherheitskonfiguration (Security Headers, HSTS) --- ✅ Zentrale SSL-Verwaltung nur in Nginx --- ✅ Möglichkeit für Rate Limiting und zentrales Logging --- ✅ Keine zusätzlichen offenen Ports (nur 80/443) --- ✅ Professionellere API-URL ohne Port-Angabe -- --**Geänderte Dateien:** --- `v2/docker-compose.yaml`: Port-Mapping "8443:8443" entfernt -- --**Hinweis für Client-Software:** --- API-Endpunkte sind weiterhin unter https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com erreichbar --- Keine Änderung der API-URLs nötig, nur Port 8443 ist nicht mehr direkt zugänglich -- --**Status:** --- ✅ Port 8443 geschlossen --- ✅ API nur noch über Nginx Reverse Proxy erreichbar --- ✅ Sicherheit erhöht durch zentrale Verwaltung -- --### 2025-06-09: Live-Filtering implementiert -- --**Problem:** --- Benutzer mussten immer auf "Filter anwenden" klicken --- Umständliche Bedienung, besonders bei mehreren Filterkriterien --- Nicht zeitgemäße User Experience -- --**Lösung:** --- JavaScript Event-Listener für automatisches Filtern --- Text-Eingaben: 300ms Debouncing (verzögerte Suche nach Tipp-Pause) --- Dropdowns: Sofortiges Filtern bei Änderung --- "Filter anwenden" Button entfernt, nur "Zurücksetzen" bleibt -- --**Implementierte Live-Filter:** --1. **Lizenzübersicht** (licenses.html): -- - Suchfeld mit Debouncing -- - Typ-Dropdown (Vollversion/Testversion) -- - Status-Dropdown (Aktiv/Ablaufend/Abgelaufen/Deaktiviert) -- --2. **Kundenübersicht** (customers.html): -- - Suchfeld mit Debouncing -- - "Suchen" Button entfernt -- --3. **Audit-Log** (audit_log.html): -- - Benutzer-Textfeld mit Debouncing -- - Aktion-Dropdown -- - Entität-Dropdown -- --**Technische Details:** --- `addEventListener('input')` für Textfelder --- `addEventListener('change')` für Select-Elemente --- `setTimeout()` mit 300ms für Debouncing --- Automatisches `form.submit()` bei Änderungen -- --**Vorteile:** --- ✅ Schnellere und intuitivere Bedienung --- ✅ Weniger Klicks erforderlich --- ✅ Moderne User Experience --- ✅ Besonders hilfreich bei komplexen Filterkriterien -- --**Status:** --- ✅ Live-Filtering auf allen Hauptseiten implementiert --- ✅ Debouncing verhindert zu viele Server-Requests --- ✅ Zurücksetzen-Button bleibt für schnelles Löschen aller Filter -- --### 2025-06-09: Resource Pool System implementiert (Phase 1 & 2) -- --**Ziel:** --Ein Pool-System für Domains, IPv4-Adressen und Telefonnummern, wobei bei jeder Lizenzerstellung 1-10 Ressourcen pro Typ zugewiesen werden. Ressourcen haben 3 Status: available, allocated, quarantine. -- --**Phase 1 - Datenbank-Schema (✅ Abgeschlossen):** --1. **Neue Tabellen erstellt:** -- - `resource_pools` - Haupttabelle für alle Ressourcen -- - `resource_history` - Vollständige Historie aller Aktionen -- - `resource_metrics` - Performance-Tracking und ROI-Berechnung -- - `license_resources` - Zuordnung zwischen Lizenzen und Ressourcen -- --2. **Erweiterte licenses Tabelle:** -- - `domain_count`, `ipv4_count`, `phone_count` Spalten hinzugefügt -- - Constraints: 0-10 pro Resource-Typ -- --3. **Indizes für Performance:** -- - Status, Type, Allocated License, Quarantine Date -- --**Phase 2 - Backend-Implementierung (✅ Abgeschlossen):** --1. **Resource Management Routes:** -- - `/resources` - Hauptübersicht mit Statistiken -- - `/resources/add` - Bulk-Import von Ressourcen -- - `/resources/quarantine/` - Ressourcen sperren -- - `/resources/release` - Quarantäne aufheben -- - `/resources/history/` - Komplette Historie -- - `/resources/metrics` - Performance Dashboard -- - `/resources/report` - Report-Generator -- --2. **API-Endpunkte:** -- - `/api/resources/allocate` - Ressourcen-Zuweisung -- - `/api/resources/check-availability` - Verfügbarkeit prüfen -- --3. **Integration in Lizenzerstellung:** -- - `create_license()` erweitert um Resource-Allocation -- - `batch_licenses()` mit Ressourcen-Prüfung für gesamten Batch -- - Transaktionale Sicherheit bei Zuweisung -- --4. **Dashboard-Integration:** -- - Resource-Statistiken in Dashboard eingebaut -- - Warning-Level basierend auf Verfügbarkeit -- --5. **Navigation erweitert:** -- - Resources-Link in Navbar hinzugefügt -- --**Was noch zu tun ist:** -- --### Phase 3 - UI-Komponenten (🔄 Ausstehend): --1. **Templates erstellen:** -- - `resources.html` - Hauptübersicht mit Drag&Drop -- - `add_resources.html` - Formular für Bulk-Import -- - `resource_history.html` - Historie-Anzeige -- - `resource_metrics.html` - Performance Dashboard -- --2. **Formulare erweitern:** -- - `index.html` - Resource-Dropdowns hinzufügen -- - `batch_form.html` - Resource-Dropdowns hinzufügen -- --3. **Dashboard-Widget:** -- - Resource Pool Statistik mit Ampelsystem -- - Warnung bei niedrigem Bestand -- --### Phase 4 - Erweiterte Features (🔄 Ausstehend): --1. **Quarantäne-Workflow:** -- - Gründe: abuse, defect, maintenance, blacklisted, expired -- - Automatische Tests vor Freigabe -- - Genehmigungsprozess -- --2. **Performance-Metrics:** -- - Täglicher Cronjob für Metriken -- - ROI-Berechnung -- - Issue-Tracking -- --3. **Report-Generator:** -- - Auslastungsreport -- - Performance-Report -- - Compliance-Report -- --### Phase 5 - Backup erweitern (🔄 Ausstehend): --- Neue Tabellen in Backup einbeziehen: -- - resource_pools -- - resource_history -- - resource_metrics -- - license_resources -- --### Phase 6 - Testing & Migration (🔄 Ausstehend): --1. **Test-Daten generieren:** -- - 500 Test-Domains -- - 200 Test-IPs -- - 100 Test-Telefonnummern -- --2. **Migrations-Script:** -- - Bestehende Lizenzen auf default resource_count setzen -- --### Phase 7 - Dokumentation (🔄 Ausstehend): --- API-Dokumentation für License Server --- Admin-Handbuch für Resource Management -- --**Technische Details:** --- 3-Status-System: available/allocated/quarantine --- Transaktionale Ressourcen-Zuweisung mit FOR UPDATE Lock --- Vollständige Historie mit IP-Tracking --- Drag&Drop UI für Resource-Management geplant --- Automatische Warnung bei < 50 verfügbaren Ressourcen -- --**Status:** --- ✅ Datenbank-Schema komplett --- ✅ Backend-Routen implementiert --- ✅ Integration in Lizenzerstellung --- ❌ UI-Templates fehlen noch --- ❌ Erweiterte Features ausstehend --- ❌ Testing und Migration offen -- --### 2025-06-09: Resource Pool System UI-Implementierung (Phase 3 & 4) -- --**Phase 3 - UI-Komponenten (✅ Abgeschlossen):** -- --1. **Neue Templates erstellt:** -- - `resources.html` - Hauptübersicht mit Statistiken, Filter, Live-Suche, Pagination -- - `add_resources.html` - Bulk-Import Formular mit Validierung -- - `resource_history.html` - Timeline-Ansicht der Historie mit Details -- - `resource_metrics.html` - Performance Dashboard mit Charts -- - `resource_report.html` - Report-Generator UI -- --2. **Erweiterte Formulare:** -- - `index.html` - Resource-Count Dropdowns (0-10) mit Live-Verfügbarkeitsprüfung -- - `batch_form.html` - Resource-Count mit Batch-Berechnung (zeigt Gesamtbedarf) -- --3. **Dashboard-Widget:** -- - Resource Pool Statistik mit Ampelsystem implementiert -- - Zeigt verfügbare/zugeteilte/quarantäne Ressourcen -- - Warnung bei niedrigem Bestand (<50) -- - Fortschrittsbalken für visuelle Darstellung -- --4. **Backend-Anpassungen:** -- - `resource_history` Route korrigiert für Object-Style Template-Zugriff -- - `resources_metrics` Route vollständig implementiert mit Charts-Daten -- - `resources_report` Route erweitert für Template-Anzeige und Downloads -- - Dashboard erweitert um Resource-Statistiken -- --**Phase 4 - Erweiterte Features (✅ Teilweise):** --1. **Report-Generator:** -- - Template für Report-Auswahl erstellt -- - 4 Report-Typen: Usage, Performance, Compliance, Inventory -- - Export als Excel, CSV oder PDF-Vorschau -- - Zeitraum-Auswahl mit Validierung -- --**Technische Details der Implementierung:** --- Live-Filtering ohne Reload durch JavaScript --- AJAX-basierte Verfügbarkeitsprüfung --- Bootstrap 5 für konsistentes Design --- Chart.js für Metriken-Visualisierung --- Responsives Design für alle Templates --- Copy-to-Clipboard für Resource-Werte --- Modal-Dialoge für Quarantäne-Aktionen -- --**Was noch fehlt:** -- --### Phase 5 - Backup erweitern (🔄 Ausstehend): --- Resource-Tabellen in pg_dump einbeziehen: -- - resource_pools -- - resource_history -- - resource_metrics -- - license_resources -- --### Phase 6 - Testing & Migration (🔄 Ausstehend): --1. **Test-Daten generieren:** -- - Script für 500 Test-Domains -- - 200 Test-IPv4-Adressen -- - 100 Test-Telefonnummern -- - Realistische Verteilung über Status -- --2. **Migrations-Script:** -- - Bestehende Lizenzen auf Default resource_count setzen -- - UPDATE licenses SET domain_count=1, ipv4_count=1, phone_count=1 WHERE ... -- --### Phase 7 - Dokumentation (🔄 Ausstehend): --- API-Dokumentation für Resource-Endpunkte --- Admin-Handbuch für Resource Management --- Troubleshooting-Guide -- --**Offene Punkte für Produktion:** --1. Drag&Drop für Resource-Verwaltung (Nice-to-have) --2. Automatische Quarantäne-Aufhebung nach Zeitablauf --3. E-Mail-Benachrichtigungen bei niedrigem Bestand --4. API für externe Resource-Prüfung --5. Bulk-Delete für Ressourcen --6. Resource-Import aus CSV/Excel -- --### 2025-06-09: Resource Pool System finalisiert -- --**Problem:** --- Resource Pool System war nur teilweise implementiert --- UI-Templates waren vorhanden, aber nicht dokumentiert --- Test-Daten und Migration fehlten --- Backup-Integration unklar -- --**Analyse und Lösung:** --1. **Status-Überprüfung durchgeführt:** -- - Alle 5 UI-Templates existierten bereits (resources.html, add_resources.html, etc.) -- - Resource-Dropdowns waren bereits in index.html und batch_form.html integriert -- - Dashboard-Widget war bereits implementiert -- - Backup-System inkludiert bereits alle Tabellen (pg_dump ohne -t Parameter) -- --2. **Fehlende Komponenten erstellt:** -- - Test-Daten Script: `test_data_resources.sql` -- - 500 Test-Domains (400 verfügbar, 50 zugeteilt, 50 in Quarantäne) -- - 200 Test-IPv4-Adressen (150 verfügbar, 30 zugeteilt, 20 in Quarantäne) -- - 100 Test-Telefonnummern (70 verfügbar, 20 zugeteilt, 10 in Quarantäne) -- - Resource History und Metrics für realistische Daten -- -- - Migration Script: `migrate_existing_licenses.sql` -- - Setzt Default Resource Counts (Vollversion: 2, Testversion: 1, Inaktiv: 0) -- - Weist automatisch verfügbare Ressourcen zu -- - Erstellt Audit-Log Einträge -- - Gibt detaillierten Migrationsbericht aus -- --**Neue Dateien:** --- `v2_adminpanel/test_data_resources.sql` - Testdaten für Resource Pool --- `v2_adminpanel/migrate_existing_licenses.sql` - Migration für bestehende Lizenzen -- --**Status:** --- ✅ Resource Pool System vollständig implementiert und dokumentiert --- ✅ Alle UI-Komponenten vorhanden und funktionsfähig --- ✅ Integration in Lizenz-Formulare abgeschlossen --- ✅ Dashboard-Widget zeigt Resource-Statistiken --- ✅ Backup-System inkludiert Resource-Tabellen --- ✅ Test-Daten und Migration bereitgestellt -- --**Nächste Schritte:** --1. Test-Daten einspielen: `psql -U adminuser -d meinedatenbank -f test_data_resources.sql` --2. Migration ausführen: `psql -U adminuser -d meinedatenbank -f migrate_existing_licenses.sql` --3. License Server API implementieren (Hauptaufgabe) -- --### 2025-06-09: Bugfix - Resource Pool Tabellen fehlten -- --**Problem:** --- Admin Panel zeigte "Internal Server Error" --- Dashboard Route versuchte auf `resource_pools` Tabelle zuzugreifen --- Tabelle existierte nicht in der Datenbank -- --**Ursache:** --- Bei bereits existierender Datenbank wird init.sql nicht erneut ausgeführt --- Resource Pool Tabellen wurden erst später zum init.sql hinzugefügt --- Docker Container verwendeten noch die alte Datenbankstruktur -- --**Lösung:** --1. Separates Script `create_resource_tables.sql` erstellt --2. Script manuell in der Datenbank ausgeführt --3. Alle 4 Resource-Tabellen erfolgreich erstellt: -- - resource_pools -- - resource_history -- - resource_metrics -- - license_resources -- --**Status:** --- ✅ Admin Panel funktioniert wieder --- ✅ Dashboard zeigt Resource Pool Statistiken --- ✅ Alle Resource-Funktionen verfügbar -- --**Empfehlung für Neuinstallationen:** --- Bei frischer Installation funktioniert alles automatisch --- Bei bestehenden Installationen: `create_resource_tables.sql` ausführen -- --### 2025-06-09: Navigation vereinfacht -- --**Änderung:** --- Navigationspunkte aus der schwarzen Navbar entfernt --- Links zu Lizenzen, Kunden, Ressourcen, Sessions, Backups und Log entfernt -- --**Grund:** --- Cleaner Look mit nur Logo, Timer und Logout --- Alle Funktionen sind weiterhin über das Dashboard erreichbar --- Bessere Übersichtlichkeit und weniger Ablenkung -- --**Geänderte Datei:** --- `v2_adminpanel/templates/base.html` - Navbar-Links auskommentiert -- --**Status:** --- ✅ Navbar zeigt nur noch Logo, Session-Timer und Logout --- ✅ Navigation erfolgt über Dashboard und Buttons auf den jeweiligen Seiten --- ✅ Alle Funktionen bleiben erreichbar -- --### 2025-06-09: Bugfix - Resource Report Einrückungsfehler -- --**Problem:** --- Resource Report Route zeigte "Internal Server Error" --- UnboundLocalError: `report_type` wurde verwendet bevor es definiert war -- --**Ursache:** --- Fehlerhafte Einrückung in der `resources_report()` Funktion --- `elif` und `else` Blöcke waren falsch eingerückt --- Variablen wurden außerhalb ihres Gültigkeitsbereichs verwendet -- --**Lösung:** --- Korrekte Einrückung für alle Conditional-Blöcke wiederhergestellt --- Alle Report-Typen (usage, performance, compliance, inventory) richtig strukturiert --- Excel und CSV Export-Code korrekt eingerückt -- --**Geänderte Datei:** --- `v2_adminpanel/app.py` - resources_report() Funktion korrigiert -- --**Status:** --- ✅ Resource Report funktioniert wieder --- ✅ Alle 4 Report-Typen verfügbar --- ✅ Export als Excel und CSV möglich -- ----- -- --## Zusammenfassung der heutigen Arbeiten (2025-06-09) -- --### 1. Resource Pool System Finalisierung --- **Ausgangslage**: Resource Pool war nur teilweise dokumentiert --- **Überraschung**: UI-Templates waren bereits vorhanden (nicht dokumentiert) --- **Ergänzt**: -- - Test-Daten Script (`test_data_resources.sql`) -- - Migration Script (`migrate_existing_licenses.sql`) --- **Status**: ✅ Vollständig implementiert -- --### 2. Database Migration Bug --- **Problem**: Admin Panel zeigte "Internal Server Error" --- **Ursache**: Resource Pool Tabellen fehlten in bestehender DB --- **Lösung**: Separates Script `create_resource_tables.sql` erstellt --- **Status**: ✅ Behoben -- --### 3. UI Cleanup --- **Änderung**: Navigation aus Navbar entfernt --- **Effekt**: Cleaner Look, Navigation nur über Dashboard --- **Status**: ✅ Implementiert -- --### 4. Resource Report Bug --- **Problem**: Einrückungsfehler in `resources_report()` Funktion --- **Lösung**: Korrekte Einrückung wiederhergestellt --- **Status**: ✅ Behoben -- --### Neue Dateien erstellt heute: --1. `v2_adminpanel/test_data_resources.sql` - 800 Test-Ressourcen -- --### 2025-06-09: Bugfix - Resource Quarantäne Modal -- --**Problem:** --- Quarantäne-Button funktionierte nicht --- Modal öffnete sich nicht beim Klick -- --**Ursache:** --- Bootstrap 5 vs Bootstrap 4 API-Inkompatibilität --- Modal wurde mit Bootstrap 4 Syntax (`modal.modal('show')`) aufgerufen --- jQuery wurde nach Bootstrap geladen -- --**Lösung:** --1. **JavaScript angepasst:** -- - Von jQuery Modal-API zu nativer Bootstrap 5 Modal-API gewechselt -- - `new bootstrap.Modal(element).show()` statt `$(element).modal('show')` -- --2. **HTML-Struktur aktualisiert:** -- - Modal-Close-Button: `data-bs-dismiss="modal"` statt `data-dismiss="modal"` -- - `btn-close` Klasse statt custom close button -- - Form-Klassen: `mb-3` statt `form-group`, `form-select` statt `form-control` für Select -- --3. **Script-Reihenfolge korrigiert:** -- - jQuery vor Bootstrap laden für korrekte Initialisierung -- --**Geänderte Dateien:** --- `v2_adminpanel/templates/resources.html` --- `v2_adminpanel/templates/base.html` -- --**Status:** ✅ Behoben -- --### 2025-06-09: Resource Pool UI Redesign -- --**Ziel:** --- Komplettes Redesign des Resource Pool Managements für bessere Benutzerfreundlichkeit --- Konsistentes Design mit dem Rest der Anwendung -- --**Durchgeführte Änderungen:** -- --1. **resources.html - Hauptübersicht:** -- - Moderne Statistik-Karten mit Hover-Effekten -- - Farbcodierte Progress-Bars mit Tooltips -- - Verbesserte Tabelle mit Icons und Status-Badges -- - Live-Filter mit sofortiger Suche -- - Überarbeitete Quarantäne-Modal für Bootstrap 5 -- - Responsive Design mit Grid-Layout -- --2. **add_resources.html - Ressourcen hinzufügen:** -- - 3-Schritt Wizard-ähnliches Interface -- - Visueller Ressourcentyp-Selector mit Icons -- - Live-Validierung mit Echtzeit-Feedback -- - Statistik-Anzeige (Gültig/Duplikate/Ungültig) -- - Formatierte Beispiele mit Erklärungen -- - Verbesserte Fehlerbehandlung -- --3. **resource_history.html - Historie:** -- - Zentrierte Resource-Anzeige mit großen Icons -- - Info-Grid Layout für Details -- - Modernisierte Timeline mit Hover-Effekten -- - Farbcodierte Action-Icons -- - Verbesserte Darstellung von Details -- --4. **resource_metrics.html - Metriken:** -- - Dashboard-Style Metrik-Karten mit Icon-Badges -- - Modernisierte Charts mit besseren Farben -- - Performance-Tabellen mit Progress-Bars -- - Trend-Indikatoren für Performance -- - Responsives Grid-Layout -- --**Design-Verbesserungen:** --- Konsistente Emoji-Icons für bessere visuelle Kommunikation --- Einheitliche Farbgebung (Blau/Lila/Grün für Ressourcentypen) --- Card-basiertes Layout mit Schatten und Hover-Effekten --- Bootstrap 5 kompatible Komponenten --- Verbesserte Typografie und Spacing -- --**Technische Details:** --- Bootstrap 5 Modal-API statt jQuery --- CSS Grid für responsive Layouts --- Moderne Chart.js Konfiguration --- Optimierte JavaScript-Validierung -- --**Geänderte Dateien:** --- `v2_adminpanel/templates/resources.html` --- `v2_adminpanel/templates/add_resources.html` --- `v2_adminpanel/templates/resource_history.html` --- `v2_adminpanel/templates/resource_metrics.html` -- --**Status:** ✅ Abgeschlossen -- --### 2025-06-09: Zusammenfassung der heutigen Arbeiten -- --**Durchgeführte Aufgaben:** -- --1. **Quarantäne-Funktion repariert:** -- - Bootstrap 5 Modal-API implementiert -- - data-bs-dismiss statt data-dismiss -- - jQuery vor Bootstrap laden -- --2. **Resource Pool UI komplett überarbeitet:** -- - Alle 4 Templates modernisiert (resources, add_resources, resource_history, resource_metrics) -- - Konsistentes Design mit Emoji-Icons -- - Einheitliche Farbgebung (Blau/Lila/Grün) -- - Bootstrap 5 kompatible Komponenten -- - Responsive Grid-Layouts -- --**Aktuelle Projekt-Status:** --- ✅ Admin Panel voll funktionsfähig --- ✅ Resource Pool Management mit modernem UI --- ✅ PostgreSQL mit allen Tabellen --- ✅ Nginx Reverse Proxy mit SSL --- ❌ Lizenzserver noch nicht implementiert (nur Platzhalter) -- --**Nächste Schritte:** --- Lizenzserver implementieren --- API-Endpunkte für Lizenzvalidierung --- Heartbeat-System für Sessions --- Versionsprüfung implementieren --1. `v2_adminpanel/templates/base.html` - Navigation entfernt --2. `v2_adminpanel/app.py` - Resource Report Einrückung korrigiert --3. `JOURNAL.md` - Alle Änderungen dokumentiert -- --### Offene Hauptaufgabe: --- **License Server API** - Noch komplett zu implementieren -- - `/api/version` - Versionscheck -- - `/api/validate` - Lizenzvalidierung -- - `/api/heartbeat` - Session-Management -- --### 2025-06-09: Resource Pool Internal Error behoben -- --**Problem:** --- Internal Server Error beim Zugriff auf `/resources` --- NameError: name 'datetime' is not defined in Template -- --**Ursache:** --- Fehlende `datetime` und `timedelta` Objekte im Template-Kontext --- Falsche Array-Indizes in resources.html für activity-Daten -- --**Lösung:** --1. **app.py (Zeile 2797-2798):** -- - `datetime=datetime` und `timedelta=timedelta` zu render_template hinzugefügt -- --2. **resources.html (Zeile 484-490):** -- - Array-Indizes korrigiert: -- - activity[0] = action -- - activity[1] = action_by -- - activity[2] = action_at -- - activity[3] = resource_type -- - activity[4] = resource_value -- - activity[5] = details -- --**Geänderte Dateien:** --- `v2_adminpanel/app.py` --- `v2_adminpanel/templates/resources.html` -- --**Status:** ✅ Behoben - Resource Pool funktioniert wieder einwandfrei -- --### 2025-06-09: Passwort-Änderung und 2FA implementiert -- --**Ziel:** --- Benutzer können ihr Passwort ändern --- Zwei-Faktor-Authentifizierung (2FA) mit TOTP --- Komplett kostenlose Lösung ohne externe Services -- --**Implementierte Features:** -- --1. **Datenbank-Erweiterung:** -- - Neue `users` Tabelle mit Passwort-Hash und 2FA-Feldern -- - Unterstützung für TOTP-Secrets und Backup-Codes -- - Migration von Environment-Variablen zu Datenbank -- --2. **Passwort-Management:** -- - Sichere Passwort-Hashes mit bcrypt -- - Passwort-Änderung mit Verifikation des alten Passworts -- - Passwort-Stärke-Indikator im Frontend -- --3. **2FA-Implementation:** -- - TOTP-basierte 2FA (Google Authenticator, Authy kompatibel) -- - QR-Code-Generierung für einfaches Setup -- - 8 Backup-Codes für Notfallzugriff -- - Backup-Codes als Textdatei downloadbar -- --4. **Neue Routen:** -- - `/profile` - Benutzerprofil mit Passwort und 2FA-Verwaltung -- - `/verify-2fa` - 2FA-Verifizierung beim Login -- - `/profile/setup-2fa` - 2FA-Einrichtung mit QR-Code -- - `/profile/enable-2fa` - 2FA-Aktivierung -- - `/profile/disable-2fa` - 2FA-Deaktivierung -- - `/profile/change-password` - Passwort ändern -- --5. **Sicherheits-Features:** -- - Fallback zu Environment-Variablen für Rückwärtskompatibilität -- - Session-Management für 2FA-Verifizierung -- - Fehlgeschlagene 2FA-Versuche werden protokolliert -- - Verwendete Backup-Codes werden entfernt -- --**Verwendete Libraries (alle kostenlos):** --- `bcrypt` - Passwort-Hashing --- `pyotp` - TOTP-Generierung und Verifizierung --- `qrcode[pil]` - QR-Code-Generierung -- --**Migration:** --- Script `migrate_users.py` erstellt für Migration existierender Benutzer --- Erhält bestehende Credentials aus Environment-Variablen --- Erstellt Datenbank-Einträge mit gehashten Passwörtern -- --**Geänderte Dateien:** --- `v2_adminpanel/init.sql` - Users-Tabelle hinzugefügt --- `v2_adminpanel/requirements.txt` - Neue Dependencies --- `v2_adminpanel/app.py` - Auth-Funktionen und neue Routen --- `v2_adminpanel/migrate_users.py` - Migrations-Script (neu) --- `v2_adminpanel/templates/base.html` - Profil-Link hinzugefügt --- `v2_adminpanel/templates/profile.html` - Profil-Seite (neu) --- `v2_adminpanel/templates/verify_2fa.html` - 2FA-Verifizierung (neu) --- `v2_adminpanel/templates/setup_2fa.html` - 2FA-Setup (neu) --- `v2_adminpanel/templates/backup_codes.html` - Backup-Codes Anzeige (neu) -- --**Status:** ✅ Vollständig implementiert -- --### 2025-06-09: Internal Server Error behoben und UI-Design angepasst -- --### 2025-06-09: Journal-Bereinigung und Projekt-Cleanup -- --**Durchgeführte Aufgaben:** -- --1. **Überflüssige SQL-Dateien gelöscht:** -- - `create_resource_tables.sql` - War nur für Migrations nötig -- - `migrate_existing_licenses.sql` - Keine alten Installationen vorhanden -- - `sample_data.sql` - Testdaten nicht mehr benötigt -- - `test_data_resources.sql` - Testdaten nicht mehr benötigt -- --2. **Journal aktualisiert:** -- - Veraltete Todo-Liste korrigiert (viele Features bereits implementiert) -- - Passwörter aus Zugangsdaten entfernt (Sicherheit) -- - "Bekannte Probleme" auf aktuellen Stand gebracht -- - Neuer Abschnitt "Best Practices für Produktiv-Migration" hinzugefügt -- --3. **Status-Klärungen:** -- - Alle Daten sind Testdaten (PoC-Phase) -- - 2FA ist implementiert und funktionsfähig -- - Resource Pool System ist vollständig implementiert -- - Port 8443 ist geschlossen (nur über Nginx erreichbar) -- --**Noch zu erledigen:** --- Nginx Config anpassen (proxy_pass von https:// auf http://) --- License Server API implementieren (Hauptaufgabe) -- --**Problem:** --- Internal Server Error nach Login wegen fehlender `users` Tabelle --- UI-Design der neuen 2FA-Seiten passte nicht zum Rest der Anwendung -- --**Lösung:** -- --1. **Datenbank-Fix:** -- - Users-Tabelle wurde nicht automatisch erstellt -- - Manuell mit SQL-Script nachgeholt -- - Migration erfolgreich durchgeführt -- - Beide Admin-User (rac00n, w@rh@mm3r) migriert -- --2. **UI-Design Überarbeitung:** -- - Profile-Seite im Dashboard-Stil mit Cards und Hover-Effekten -- - 2FA-Setup mit nummerierten Schritten und modernem Card-Design -- - Backup-Codes Seite mit Animation und verbessertem Layout -- - Konsistente Farbgebung und Icons -- - Verbesserte Benutzerführung mit visuellen Hinweisen -- --**Design-Features:** --- Card-basiertes Layout mit Schatten-Effekten --- Hover-Animationen für bessere Interaktivität --- Farbcodierte Sicherheitsstatus-Anzeigen --- Passwort-Stärke-Indikator mit visueller Rückmeldung --- Responsive Design für alle Bildschirmgrößen --- Print-optimiertes Layout für Backup-Codes -- --**Geänderte Dateien:** --- `v2_adminpanel/create_users_table.sql` - SQL für Users-Tabelle (temporär) -- --### 2025-06-09: Journal-Umstrukturierung -- --**Durchgeführte Änderungen:** -- --1. **Dokumentation aufgeteilt:** -- - `JOURNAL.md` - Enthält nur noch chronologische Änderungen (wie ein Tagebuch) -- - `THE_ROAD_SO_FAR.md` - Neues Dokument mit aktuellem Status und Roadmap -- --2. **THE_ROAD_SO_FAR.md erstellt mit:** -- - Aktueller Status (was läuft bereits) -- - Nächste Schritte (Priorität Hoch) -- - Offene Aufgaben (Priorität Mittel) -- - Nice-to-have Features -- - Bekannte Probleme -- - Deployment-Hinweise -- --3. **JOURNAL.md bereinigt:** -- - Todo-Listen entfernt (jetzt in THE_ROAD_SO_FAR.md) -- - Nur noch chronologische Einträge -- - Fokus auf "Was wurde gemacht" statt "Was muss gemacht werden" -- --**Vorteile der Aufteilung:** --- Journal bleibt übersichtlich und wächst linear --- Status und Todos sind immer aktuell an einem Ort --- Klare Trennung zwischen Historie und Planung --- Einfacher für neue Entwickler einzusteigen -- --### 2025-06-09: Nginx Config angepasst -- --**Änderung:** --- proxy_pass für License Server von `https://license-server:8443` auf `http://license-server:8443` geändert --- `proxy_ssl_verify off` entfernt (nicht mehr nötig bei HTTP) --- WebSocket-Support hinzugefügt (für zukünftige Features) -- --**Grund:** --- License Server läuft intern auf HTTP (wie Admin Panel) --- SSL-Termination erfolgt nur am Nginx --- Vereinfachte Konfiguration ohne doppelte SSL-Verschlüsselung -- --**Hinweis:** --Docker-Container müssen neu gestartet werden, damit die Änderung wirksam wird: --```bash --docker-compose down --docker-compose up -d --``` --- `v2_adminpanel/templates/profile.html` - Komplett überarbeitet --- `v2_adminpanel/templates/setup_2fa.html` - Neues Step-by-Step Design --- `v2_adminpanel/templates/backup_codes.html` - Modernisiertes Layout -- --**Status:** ✅ Abgeschlossen - Login funktioniert, UI im konsistenten Design -- --### 2025-06-09: Lizenzschlüssel-Format geändert -- --**Änderung:** --- Altes Format: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` (z.B. AF-202506F-V55Y-9DWE-GL5G) --- Neues Format: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` (z.B. AF-F-202506-V55Y-9DWE-GL5G) -- --**Vorteile:** --- Klarere Struktur mit separatem Typ-Indikator --- Einfacher zu lesen und zu verstehen --- Typ (F/T) sofort im zweiten Block erkennbar -- --**Geänderte Dateien:** --- `v2_adminpanel/app.py`: -- - `generate_license_key()` - Generiert Keys im neuen Format -- - `validate_license_key()` - Validiert Keys mit neuem Regex-Pattern --- `v2_adminpanel/templates/index.html`: -- - Placeholder und Pattern für Input-Feld angepasst -- - JavaScript charAt() Position für Typ-Prüfung korrigiert --- `v2_adminpanel/templates/batch_form.html`: -- - Vorschau-Format für Batch-Generierung angepasst -- --**Hinweis:** Alte Keys im bisherigen Format bleiben ungültig. Bei Bedarf könnte eine Migration oder Dual-Support implementiert werden. -- --**Status:** ✅ Implementiert -- --### 2025-06-09: Datenbank-Migration der Lizenzschlüssel -- --**Durchgeführt:** --- Alle bestehenden Lizenzschlüssel in der Datenbank auf das neue Format migriert --- 18 Lizenzschlüssel erfolgreich konvertiert (16 Full, 2 Test) -- --**Migration:** --- Von: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` --- Nach: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` -- --**Beispiele:** --- Alt: `AF-202506F-V55Y-9DWE-GL5G` --- Neu: `AF-F-202506-V55Y-9DWE-GL5G` -- --**Geänderte Dateien:** --- `v2_adminpanel/migrate_license_keys.sql` - Migrations-Script (temporär) --- `v2_adminpanel/fix_license_keys.sql` - Korrektur-Script (temporär) -- --**Status:** ✅ Alle Lizenzschlüssel erfolgreich migriert -- --### 2025-06-09: Kombinierte Kunden-Lizenz-Ansicht implementiert -- --**Problem:** --- Umständliche Navigation zwischen Kunden- und Lizenzseiten --- Viel Hin-und-Her-Springen bei der Verwaltung --- Kontext-Verlust beim Wechseln zwischen Ansichten -- --**Lösung:** --Master-Detail View mit 2-Spalten Layout implementiert -- --**Phase 1-3 abgeschlossen:** --1. **Backend-Implementierung:** -- - Neue Route `/customers-licenses` für kombinierte Ansicht -- - API-Endpoints für AJAX: `/api/customer//licenses`, `/api/customer//quick-stats` -- - API-Endpoint `/api/license//quick-edit` für Inline-Bearbeitung -- - Optimierte SQL-Queries mit JOIN für Performance -- --2. **Template-Erstellung:** -- - Neues Template `customers_licenses.html` mit Master-Detail Layout -- - Links: Kundenliste (30%) mit Suchfeld -- - Rechts: Lizenzen des ausgewählten Kunden (70%) -- - Responsive Design (Mobile: untereinander) -- - JavaScript für dynamisches Laden ohne Seitenreload -- - Keyboard-Navigation (↑↓ für Kundenwechsel) -- --3. **Integration:** -- - Dashboard: Neuer Button "Kunden & Lizenzen" -- - Customers-Seite: Link zur kombinierten Ansicht -- - Licenses-Seite: Link zur kombinierten Ansicht -- - Lizenz-Erstellung: Unterstützung für vorausgewählten Kunden -- - API /api/customers erweitert für Einzelabruf per ID -- --**Features:** --- Live-Suche in Kundenliste --- Quick-Actions: Copy License Key, Toggle Status --- Modal für neue Lizenz direkt aus Kundenansicht --- URL-Update ohne Reload für Bookmarking --- Loading-States während AJAX-Calls --- Visuelles Feedback (aktiver Kunde hervorgehoben) -- --**Noch ausstehend:** --- Phase 4: Inline-Edit für Lizenzdetails --- Phase 5: Erweiterte Error-Handling und Polish -- --**Geänderte Dateien:** --- `v2_adminpanel/app.py` - Neue Routen und API-Endpoints --- `v2_adminpanel/templates/customers_licenses.html` - Neues Template --- `v2_adminpanel/templates/dashboard.html` - Neuer Button --- `v2_adminpanel/templates/customers.html` - Link zur kombinierten Ansicht --- `v2_adminpanel/templates/licenses.html` - Link zur kombinierten Ansicht --- `v2_adminpanel/templates/index.html` - Unterstützung für preselected_customer_id -- --**Status:** ✅ Grundfunktionalität implementiert und funktionsfähig -- --### 2025-06-09: Kombinierte Ansicht - Fertigstellung und TODOs aktualisiert -- --**Abgeschlossen:** --- Phase 1-3 der kombinierten Kunden-Lizenz-Ansicht vollständig implementiert --- Master-Detail Layout funktioniert einwandfrei --- AJAX-basiertes Laden ohne Seitenreload --- Keyboard-Navigation mit Pfeiltasten --- Quick-Actions für Copy und Toggle Status --- Integration in alle relevanten Seiten -- --**THE_ROAD_SO_FAR.md aktualisiert:** --- Kombinierte Ansicht als "Erledigt" markiert --- Von "In Arbeit" zu "Abgeschlossen" verschoben --- Status dokumentiert -- --**Verbesserung gegenüber vorher:** --- Kein Hin-und-Her-Springen mehr zwischen Seiten --- Kontext bleibt erhalten beim Arbeiten mit Kunden --- Schnellere Navigation und bessere Übersicht --- Deutlich verbesserte User Experience -- --**Optional für später (Phase 4-5):** --- Inline-Edit für weitere Felder --- Erweiterte Quick-Actions --- Session-basierte Filter-Persistenz -- --Die Hauptproblematik der umständlichen Navigation ist damit gelöst! -- --### 2025-06-09: Test-Flag für Lizenzen implementiert -- --**Ziel:** --- Klare Trennung zwischen Testdaten und echten Produktivdaten --- Testdaten sollen von der Software ignoriert werden können --- Bessere Übersicht im Admin Panel -- --**Durchgeführte Änderungen:** -- --1. **Datenbank-Schema (init.sql):** -- - Neue Spalte `is_test BOOLEAN DEFAULT FALSE` zur `licenses` Tabelle hinzugefügt -- - Migration für bestehende Daten: Alle werden als `is_test = TRUE` markiert -- - Index `idx_licenses_is_test` für bessere Performance -- --2. **Backend (app.py):** -- - Dashboard-Queries filtern Testdaten mit `WHERE is_test = FALSE` aus -- - Lizenz-Erstellung: Neues Checkbox-Feld für Test-Markierung -- - Lizenz-Bearbeitung: Test-Status kann geändert werden -- - Export: Optional mit/ohne Testdaten (`?include_test=true`) -- - Bulk-Operationen: Nur auf Live-Daten anwendbar -- - Neue Filter in Lizenzliste: "🧪 Testdaten" und "🚀 Live-Daten" -- --3. **Frontend Templates:** -- - **index.html**: Checkbox "Als Testdaten markieren" bei Lizenzerstellung -- - **edit_license.html**: Checkbox zum Ändern des Test-Status -- - **licenses.html**: Badge 🧪 für Testdaten, neue Filteroptionen -- - **dashboard.html**: Info-Box zeigt Anzahl der Testdaten -- - **batch_form.html**: Option für Batch-Test-Lizenzen -- --4. **Audit-Log Integration:** -- - `is_test` Feld wird bei CREATE/UPDATE geloggt -- - Nachvollziehbarkeit von Test/Live-Status-Änderungen -- --**Technische Details:** --- Testdaten werden in allen Statistiken ausgefiltert --- License Server API wird Lizenzen mit `is_test = TRUE` ignorieren --- Resource Pool bleibt unverändert (kann Test- und Live-Ressourcen verwalten) -- --**Migration der bestehenden Daten:** --```sql --UPDATE licenses SET is_test = TRUE; -- Alle aktuellen Daten sind Testdaten --``` -- --**Status:** ✅ Implementiert -- --### 2025-06-09: Test-Flag für Kunden und Resource Pools erweitert -- --**Ziel:** --- Konsistentes Test-Daten-Management über alle Entitäten --- Kunden und Resource Pools können ebenfalls als Testdaten markiert werden --- Automatische Verknüpfung: Test-Kunde → Test-Lizenzen → Test-Ressourcen -- --**Durchgeführte Änderungen:** -- --1. **Datenbank-Schema erweitert:** -- - `customers.is_test BOOLEAN DEFAULT FALSE` hinzugefügt -- - `resource_pools.is_test BOOLEAN DEFAULT FALSE` hinzugefügt -- - Indizes für bessere Performance erstellt -- - Migrations in init.sql integriert -- --2. **Backend (app.py) - Erweiterte Logik:** -- - Dashboard: Separate Zähler für Test-Kunden und Test-Ressourcen -- - Kunde-Erstellung: Erbt Test-Status von Lizenz -- - Test-Kunde erzwingt Test-Lizenzen -- - Resource-Zuweisung: Test-Lizenzen bekommen nur Test-Ressourcen -- - Customer-Management mit is_test Filter -- --3. **Frontend Updates:** -- - **customers.html**: 🧪 Badge für Test-Kunden -- - **edit_customer.html**: Checkbox für Test-Status -- - **dashboard.html**: Erweiterte Test-Statistik (Lizenzen, Kunden, Ressourcen) -- --4. **Geschäftslogik:** -- - Wenn neuer Kunde bei Test-Lizenz erstellt wird → automatisch Test-Kunde -- - Wenn Test-Kunde gewählt wird → Lizenz automatisch als Test markiert -- - Resource Pool Allocation prüft Test-Status für korrekte Zuweisung -- --**Migration der bestehenden Daten:** --```sql --UPDATE customers SET is_test = TRUE; -- 5 Kunden --UPDATE resource_pools SET is_test = TRUE; -- 20 Ressourcen --``` -- --**Technische Details:** --- Konsistente Test/Live-Trennung über alle Ebenen --- Dashboard-Statistiken zeigen nur Live-Daten --- Test-Ressourcen werden nur Test-Lizenzen zugewiesen --- Alle bestehenden Daten sind jetzt als Test markiert -- --**Status:** ✅ Vollständig implementiert -- --### 2025-06-09 (17:20 - 18:13): Kunden-Lizenz-Verwaltung konsolidiert -- --**Problem:** --- Kombinierte Ansicht `/customers-licenses` hatte Formatierungs- und Funktionsprobleme --- Kunden wurden nicht angezeigt --- Bootstrap Icons fehlten --- JavaScript-Fehler beim Modal --- Inkonsistentes Design im Vergleich zu anderen Seiten --- Testkunden-Filter wurde beim Navigieren nicht beibehalten -- --**Durchgeführte Änderungen:** -- --1. **Frontend-Fixes (base.html):** -- - Bootstrap Icons CSS hinzugefügt: `bootstrap-icons@1.11.3/font/bootstrap-icons.min.css` -- - Bootstrap JavaScript Bundle bereits vorhanden, Reihenfolge optimiert -- --2. **customers_licenses.html komplett überarbeitet:** -- - Container-Klasse von `container-fluid` auf `container py-5` geändert -- - Emojis und Button-Styling vereinheitlicht (👥 Kunden & Lizenzen) -- - Export-Dropdown wie in anderen Ansichten implementiert -- - Card-Styling mit Schatten für einheitliches Design -- - Checkbox "Testkunden anzeigen" mit Status-Beibehaltung -- - JavaScript-Funktionen korrigiert: -- - copyToClipboard mit event.currentTarget -- - showNewLicenseModal mit Bootstrap Modal -- - Header-Update beim AJAX-Kundenwechsel -- - URL-Parameter `show_test` wird überall beibehalten -- --3. **Backend-Anpassungen (app.py):** -- - customers_licenses Route: Optional Testkunden anzeigen mit `show_test` Parameter -- - Redirects von `/customers` und `/licenses` auf `/customers-licenses` implementiert -- - Alte Route-Funktionen entfernt (kein toter Code mehr) -- - edit_license und edit_customer: Redirects behalten show_test Parameter bei -- - Dashboard-Links zeigen jetzt auf kombinierte Ansicht -- --4. **Navigation optimiert:** -- - Dashboard: Klick auf Kunden/Lizenzen-Statistik führt zur kombinierten Ansicht -- - Alle Edit-Links behalten den show_test Parameter bei -- - Konsistente User Experience beim Navigieren -- --**Technische Details:** --- AJAX-Loading für dynamisches Laden der Lizenzen --- Keyboard-Navigation (↑↓) für Kundenliste --- Responsive Design mit Bootstrap Grid --- Modal-Dialoge für Bestätigungen --- Live-Suche in der Kundenliste -- --**Resultat:** --- ✅ Einheitliches Design mit anderen Admin-Panel-Seiten --- ✅ Alle Funktionen arbeiten korrekt --- ✅ Testkunden-Filter bleibt erhalten --- ✅ Keine redundanten Views mehr --- ✅ Zentrale Verwaltung für Kunden und Lizenzen -- --**Status:** ✅ Vollständig implementiert -- --### 2025-06-09: Test-Daten Checkbox Persistenz implementiert -- --**Problem:** --- Die "Testkunden anzeigen" Checkbox in `/customers-licenses` verlor ihren Status beim Navigieren zwischen Seiten --- Wenn Benutzer zu anderen Seiten (Resources, Audit Log, etc.) wechselten und zurückkehrten, war die Checkbox wieder deaktiviert --- Benutzer mussten die Checkbox jedes Mal neu aktivieren, was umständlich war -- --**Lösung:** --- Globale JavaScript-Funktion `preserveShowTestParameter()` in base.html implementiert --- Die Funktion prüft beim Laden jeder Seite, ob `show_test=true` in der URL ist --- Wenn ja, wird dieser Parameter automatisch an alle internen Links angehängt --- Backend-Route `/create` wurde angepasst, um den Parameter bei Redirects beizubehalten -- --**Technische Details:** --1. **base.html** - JavaScript-Funktion hinzugefügt: -- - Läuft beim `DOMContentLoaded` Event -- - Findet alle Links die mit "/" beginnen -- - Fügt `show_test=true` Parameter hinzu wenn nicht bereits vorhanden -- - Überspringt Fragment-Links (#) und Links die bereits den Parameter haben -- --2. **app.py** - Route-Anpassung: -- - `/create` Route behält jetzt `show_test` Parameter bei Redirects bei -- - Andere Routen (edit_license, edit_customer) behalten Parameter bereits bei -- --**Vorteile:** --- ✅ Konsistente User Experience beim Navigieren --- ✅ Keine manuelle Anpassung aller Links nötig --- ✅ Funktioniert automatisch für alle zukünftigen Links --- ✅ Minimaler Code-Overhead -- --**Geänderte Dateien:** --- `v2_adminpanel/templates/base.html` --- `v2_adminpanel/app.py` -- --**Status:** ✅ Vollständig implementiert -- --### 2025-06-09: Bearbeiten-Button Fehler behoben -- --**Problem:** --- Der "Bearbeiten" Button neben dem Kundennamen in der `/customers-licenses` Ansicht verursachte einen Internal Server Error --- Die URL-Konstruktion war fehlerhaft wenn kein `show_test` Parameter vorhanden war --- Die edit_customer.html Template hatte falsche Array-Indizes und veraltete Links -- --**Ursache:** --1. Die href-Attribute wurden falsch konstruiert: -- - Alt: `/customer/edit/ID{% if show_test %}?ref=customers-licenses&show_test=true{% endif %}` -- - Problem: Ohne show_test fehlte das `?ref=customers-licenses` komplett -- --2. Die SQL-Abfrage in edit_customer() holte nur 4 Felder, aber das Template erwartete 5: -- - Query: `SELECT id, name, email, is_test` -- - Template erwartete: `customer[3]` = created_at und `customer[4]` = is_test -- --3. Veraltete Links zu `/customers` statt `/customers-licenses` -- --**Lösung:** --1. URL-Konstruktion korrigiert in beiden Fällen: -- - Neu: `/customer/edit/ID?ref=customers-licenses{% if show_test %}&show_test=true{% endif %}` -- --2. SQL-Query erweitert um created_at: -- - Neu: `SELECT id, name, email, created_at, is_test` -- --3. Template-Indizes korrigiert: -- - is_test Checkbox nutzt jetzt `customer[4]` -- --4. Navigation-Links aktualisiert: -- - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter -- --**Geänderte Dateien:** --- `v2_adminpanel/templates/customers_licenses.html` (Zeilen 103 und 295) --- `v2_adminpanel/app.py` (edit_customer Route) --- `v2_adminpanel/templates/edit_customer.html` -- --**Status:** ✅ Behoben -- --### 2025-06-09: Unnötigen Lizenz-Erstellungs-Popup entfernt -- --**Änderung:** --- Der Bestätigungs-Popup "Möchten Sie eine neue Lizenz für KUNDENNAME erstellen?" wurde entfernt --- Klick auf "Neue Lizenz" Button führt jetzt direkt zur Lizenzerstellung -- --**Technische Details:** --- Modal-HTML komplett entfernt --- `showNewLicenseModal()` Funktion vereinfacht - navigiert jetzt direkt zu `/create?customer_id=X` --- URL-Parameter (wie `show_test`) werden dabei beibehalten -- --**Vorteile:** --- ✅ Ein Klick weniger für Benutzer --- ✅ Schnellerer Workflow --- ✅ Weniger Code zu warten -- --**Geänderte Dateien:** --- `v2_adminpanel/templates/customers_licenses.html` -- --**Status:** ✅ Implementiert -- --### 2025-06-09: Testkunden-Checkbox bleibt jetzt bei Lizenz/Kunden-Bearbeitung erhalten -- --**Problem:** --- Bei "Lizenz bearbeiten" ging der "Testkunden anzeigen" Haken verloren beim Zurückkehren --- Die Navigation-Links in edit_license.html zeigten auf `/licenses` statt `/customers-licenses` --- Der show_test Parameter wurde nur über den unsicheren Referrer übertragen -- --**Lösung:** --1. **Navigation-Links korrigiert**: -- - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter -- - Betrifft: "Zurück zur Übersicht" und "Abbrechen" Buttons -- --2. **Hidden Form Field hinzugefügt**: -- - Sowohl in edit_license.html als auch edit_customer.html -- - Überträgt den show_test Parameter sicher beim POST -- --3. **Route-Logik verbessert**: -- - Parameter wird aus Form-Daten ODER GET-Parametern gelesen -- - Nicht mehr auf unsicheren Referrer angewiesen -- - Funktioniert sowohl bei Speichern als auch Abbrechen -- --**Technische Details:** --- Templates prüfen `request.args.get('show_test')` für Navigation --- Hidden Input: `` --- Routes: `show_test = request.form.get('show_test') or request.args.get('show_test')` -- --**Geänderte Dateien:** --- `v2_adminpanel/templates/edit_license.html` --- `v2_adminpanel/templates/edit_customer.html` --- `v2_adminpanel/app.py` (edit_license und edit_customer Routen) -- --**Status:** ✅ Vollständig implementiert -- --### 2025-06-09 22:02: Konsistente Sortierung bei Status-Toggle -- --**Problem:** --- Beim Klicken auf den An/Aus-Knopf (Status-Toggle) in der Kunden & Lizenzen Ansicht änderte sich die Reihenfolge der Lizenzen --- Dies war verwirrend für Benutzer, da die Position der gerade bearbeiteten Lizenz springen konnte -- --**Ursache:** --- Die Sortierung `ORDER BY l.created_at DESC` war nicht stabil genug --- Bei gleichem Erstellungszeitpunkt konnte die Datenbank die Reihenfolge inkonsistent zurückgeben -- --**Lösung:** --- Sekundäres Sortierkriterium hinzugefügt: `ORDER BY l.created_at DESC, l.id DESC` --- Dies stellt sicher, dass bei gleichem Erstellungsdatum nach ID sortiert wird --- Die Reihenfolge bleibt jetzt konsistent, auch nach Status-Änderungen -- --**Geänderte Dateien:** --- `v2_adminpanel/app.py`: -- - Zeile 2278: `/customers-licenses` Route -- - Zeile 2319: `/api/customer//licenses` API-Route -- --### 2025-06-10 00:01: Verbesserte Integration zwischen Kunden & Lizenzen und Resource Pool -- --**Problem:** --- Umständliche Navigation zwischen Kunden & Lizenzen und Resource Pool Bereichen --- Keine direkte Verbindung zwischen beiden Ansichten --- Benutzer mussten ständig zwischen verschiedenen Seiten hin- und herspringen -- --**Implementierte Lösung - 5 Phasen:** -- --1. **Phase 1: Ressourcen-Details in Kunden & Lizenzen Ansicht** -- - API `/api/customer/{id}/licenses` erweitert um konkrete Ressourcen-Informationen -- - Neue API `/api/license/{id}/resources` für detaillierte Ressourcen einer Lizenz -- - Anzeige der zugewiesenen Ressourcen mit Info-Buttons und Modal-Dialogen -- - Klickbare Links zu Ressourcen-Details im Resource Pool -- --2. **Phase 2: Quick-Actions für Ressourcenverwaltung** -- - "Ressourcen verwalten" Button (Zahnrad-Icon) bei jeder Lizenz -- - Modal mit Übersicht aller zugewiesenen Ressourcen -- - Vorbereitung für Quarantäne-Funktionen und Ressourcen-Austausch -- --3. **Phase 3: Ressourcen-Preview bei Lizenzerstellung** -- - Live-Anzeige verfügbarer Ressourcen beim Ändern der Anzahl -- - Erweiterte Verfügbarkeitsanzeige mit Badges (OK/Niedrig/Kritisch) -- - Warnungen bei niedrigem Bestand mit visuellen Hinweisen -- - Fortschrittsbalken zur Visualisierung der Verfügbarkeit -- --4. **Phase 4: Dashboard-Integration** -- - Resource Pool Widget mit erweiterten Links -- - Kritische Warnungen bei < 50 Ressourcen mit "Auffüllen" Button -- - Direkte Navigation zu gefilterten Ansichten (nach Typ/Status) -- - Verbesserte visuelle Darstellung mit Tooltips -- --5. **Phase 5: Bidirektionale Navigation** -- - Von Resource Pool: Links zu Kunden/Lizenzen bei zugewiesenen Ressourcen -- - "Zurück zu Kunden" Button wenn von Kunden & Lizenzen kommend -- - Navigation-Links im Dashboard für schnellen Zugriff -- - SQL-Query erweitert um customer_id für direkte Verlinkung -- --**Technische Details:** --- JavaScript-Funktionen für Modal-Dialoge und Ressourcen-Details --- Erweiterte SQL-Queries mit JOINs für Ressourcen-Informationen --- Bootstrap 5 Tooltips und Modals für bessere UX --- Globale Variable `currentLicenses` für Caching der Lizenzdaten -- --**Geänderte Dateien:** --- `v2_adminpanel/app.py` - Neue APIs und erweiterte Queries --- `v2_adminpanel/templates/customers_licenses.html` - Ressourcen-Details und Modals --- `v2_adminpanel/templates/index.html` - Erweiterte Verfügbarkeitsanzeige --- `v2_adminpanel/templates/dashboard.html` - Verbesserte Resource Pool Integration --- `v2_adminpanel/templates/resources.html` - Bidirektionale Navigation -- --**Status:** ✅ Alle 5 Phasen erfolgreich implementiert -- --### 2025-06-10 00:15: IP-Adressen-Erfassung hinter Reverse Proxy korrigiert -- --**Problem:** --- Flask-App erfasste nur die Docker-interne IP-Adresse von Nginx (172.19.0.5) --- Echte Client-IPs wurden nicht in Audit-Logs und Login-Attempts gespeichert --- Nginx setzte die Header korrekt, aber Flask las sie nicht aus -- --**Ursache:** --- Flask verwendet standardmäßig nur `request.remote_addr` --- Dies gibt bei einem Reverse Proxy nur die Proxy-IP zurück --- Die Header `X-Real-IP` und `X-Forwarded-For` wurden ignoriert -- --**Lösung:** --1. **ProxyFix Middleware** hinzugefügt für korrekte Header-Verarbeitung --2. **get_client_ip() Funktion** angepasst: -- - Prüft zuerst `X-Real-IP` Header -- - Dann `X-Forwarded-For` Header (nimmt erste IP bei mehreren) -- - Fallback auf `request.remote_addr` --3. **Debug-Logging** für IP-Erfassung hinzugefügt --4. **Alle `request.remote_addr` Aufrufe** durch `get_client_ip()` ersetzt -- --**Technische Details:** --```python --# ProxyFix für korrekte IP-Adressen --app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) -- --# Verbesserte IP-Erfassung --def get_client_ip(): -- if request.headers.get('X-Real-IP'): -- return request.headers.get('X-Real-IP') -- elif request.headers.get('X-Forwarded-For'): -- return request.headers.get('X-Forwarded-For').split(',')[0].strip() -- else: -- return request.remote_addr --``` -- --**Geänderte Dateien:** --- `v2_adminpanel/app.py` - ProxyFix und verbesserte IP-Erfassung -- --**Status:** ✅ Implementiert - Neue Aktionen erfassen jetzt echte Client-IPs -- --### 2025-06-10 00:30: Docker ENV Legacy-Format Warnungen behoben -- --**Problem:** --- Docker Build zeigte Warnungen: "LegacyKeyValueFormat: ENV key=value should be used" --- Veraltetes Format `ENV KEY VALUE` wurde in Dockerfiles verwendet -- --**Lösung:** --- Alle ENV-Anweisungen auf neues Format `ENV KEY=VALUE` umgestellt --- Betraf hauptsächlich v2_postgres/Dockerfile mit 3 ENV-Zeilen -- --**Geänderte Dateien:** --- `v2_postgres/Dockerfile` - ENV-Format modernisiert -- --**Beispiel der Änderung:** --```dockerfile --# Alt (Legacy): --ENV LANG de_DE.UTF-8 --ENV LANGUAGE de_DE:de -- --# Neu (Modern): --ENV LANG=de_DE.UTF-8 --ENV LANGUAGE=de_DE:de --``` -- --**Status:** ✅ Alle Dockerfiles verwenden jetzt das moderne ENV-Format -- -+# v2-Docker Projekt Journal -+ -+## Letzte Änderungen (06.01.2025) -+ -+### Gerätelimit-Feature implementiert -+- **Datenbank-Schema erweitert**: -+ - Neue Spalte `device_limit` in `licenses` Tabelle (Standard: 3, Range: 1-10) -+ - Neue Tabelle `device_registrations` für Hardware-ID Tracking -+ - Indizes für Performance-Optimierung hinzugefügt -+ -+- **UI-Anpassungen**: -+ - Einzellizenz-Formular: Dropdown für Gerätelimit (1-10 Geräte) -+ - Batch-Formular: Gerätelimit pro Lizenz auswählbar -+ - Lizenz-Bearbeitung: Gerätelimit änderbar -+ - Lizenz-Anzeige: Zeigt aktive Geräte (z.B. "💻 2/3") -+ -+- **Backend-Änderungen**: -+ - Lizenz-Erstellung speichert device_limit -+ - Batch-Erstellung berücksichtigt device_limit -+ - Lizenz-Update kann device_limit ändern -+ - API-Endpoints liefern Geräteinformationen -+ -+- **Migration**: -+ - Skript `migrate_device_limit.sql` erstellt -+ - Setzt device_limit = 3 für alle bestehenden Lizenzen -+ -+### Vollständig implementiert: -+✅ Device Management UI (Geräte pro Lizenz anzeigen/verwalten) -+✅ Device Validation Logic (Prüfung bei Geräte-Registrierung) -+✅ API-Endpoints für Geräte-Registrierung/Deregistrierung -+ -+### API-Endpoints: -+- `GET /api/license//devices` - Listet alle Geräte einer Lizenz -+- `POST /api/license//register-device` - Registriert ein neues Gerät -+- `POST /api/license//deactivate-device/` - Deaktiviert ein Gerät -+ -+### Features: -+- Geräte-Registrierung mit Hardware-ID Validierung -+- Automatische Prüfung des Gerätelimits -+- Reaktivierung deaktivierter Geräte möglich -+- Geräte-Verwaltung UI mit Modal-Dialog -+- Anzeige von Gerätename, OS, IP, Registrierungsdatum -+- Admin kann Geräte manuell deaktivieren -+ -+--- -+ -+## Projektübersicht -+Lizenzmanagement-System für Social Media Account-Erstellungssoftware mit Docker-basierter Architektur. -+ -+### Technische Anforderungen -+- **Lokaler Betrieb**: Docker mit 4GB RAM und 40GB Speicher -+- **Internet-Zugriff**: -+ - Admin Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com -+ - API Server: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com -+- **Datenbank**: PostgreSQL mit 2 Admin-Usern -+- **Ziel**: PoC für spätere VPS-Migration -+ -+--- -+ -+## Best Practices für Produktiv-Migration -+ -+### Passwort-Management -+Für die Migration auf Hetzner/VPS müssen die Credentials sicher verwaltet werden: -+ -+1. **Environment Variables erstellen:** -+ ```bash -+ # .env.example (ins Git Repository) -+ POSTGRES_USER=changeme -+ POSTGRES_PASSWORD=changeme -+ POSTGRES_DB=changeme -+ SECRET_KEY=generate-a-secure-key -+ ADMIN_USER_1=changeme -+ ADMIN_PASS_1=changeme -+ ADMIN_USER_2=changeme -+ ADMIN_PASS_2=changeme -+ -+ # .env (NICHT ins Git, auf Server erstellen) -+ POSTGRES_USER=produktiv_user -+ POSTGRES_PASSWORD=sicheres_passwort_min_20_zeichen -+ POSTGRES_DB=v2docker_prod -+ SECRET_KEY=generierter_64_zeichen_key -+ # etc. -+ ``` -+ -+2. **Sichere Passwörter generieren:** -+ - Mindestens 20 Zeichen -+ - Mix aus Groß-/Kleinbuchstaben, Zahlen, Sonderzeichen -+ - Verschiedene Passwörter für Dev/Staging/Prod -+ - Password-Generator verwenden (z.B. `openssl rand -base64 32`) -+ -+3. **Erweiterte Sicherheit (Optional):** -+ - HashiCorp Vault für zentrale Secret-Verwaltung -+ - Docker Secrets (für Docker Swarm) -+ - Cloud-Lösungen: AWS Secrets Manager, Azure Key Vault -+ -+4. **Wichtige Checkliste:** -+ - [ ] `.env` in `.gitignore` aufnehmen -+ - [ ] Neue Credentials für Produktion generieren -+ - [ ] Backup der Credentials an sicherem Ort -+ - [ ] Regelmäßige Passwort-Rotation planen -+ - [ ] Keine Default-Passwörter verwenden -+ -+--- -+ -+## Änderungsprotokoll -+ -+### 2025-06-06 - Journal erstellt -+- Initialer Projektstand dokumentiert -+- Aufgabenliste priorisiert -+- Technische Anforderungen festgehalten -+ -+### 2025-06-06 - UTF-8 Support implementiert -+- Flask App Konfiguration für UTF-8 hinzugefügt (JSON_AS_ASCII=False) -+- PostgreSQL Verbindung mit UTF-8 client_encoding -+- HTML Forms mit accept-charset="UTF-8" -+- Dockerfile mit deutschen Locale-Einstellungen (de_DE.UTF-8) -+- PostgreSQL Container mit UTF-8 Initialisierung -+- init.sql mit SET client_encoding = 'UTF8' -+ -+**Geänderte Dateien:** -+- v2_adminpanel/app.py -+- v2_adminpanel/templates/index.html -+- v2_adminpanel/init.sql -+- v2_adminpanel/Dockerfile -+- v2/docker-compose.yaml -+ -+**Nächster Test:** -+- Container neu bauen und starten -+- Kundennamen mit Umlauten testen (z.B. "Müller GmbH", "Björn Schäfer") -+- Email mit Umlauten testen -+ -+### 2025-06-06 - Lizenzübersicht implementiert -+- Neue Route `/licenses` für Lizenzübersicht -+- SQL-Query mit JOIN zwischen licenses und customers -+- Status-Berechnung (aktiv, läuft bald ab, abgelaufen) -+- Farbcodierung für verschiedene Status -+- Navigation zwischen Lizenz erstellen und Übersicht -+ -+**Neue Features:** -+- Anzeige aller Lizenzen mit Kundeninformationen -+- Status-Anzeige basierend auf Ablaufdatum -+- Unterscheidung zwischen Voll- und Testversion -+- Responsive Tabelle mit Bootstrap -+- Link von Dashboard zur Übersicht und zurück -+ -+**Geänderte/Neue Dateien:** -+- v2_adminpanel/app.py (neue Route hinzugefügt) -+- v2_adminpanel/templates/licenses.html (neu erstellt) -+- v2_adminpanel/templates/index.html (Navigation ergänzt) -+ -+**Nächster Test:** -+- Container neu starten -+- Mehrere Lizenzen mit verschiedenen Ablaufdaten erstellen -+- Lizenzübersicht unter /licenses aufrufen -+ -+### 2025-06-06 - Lizenz bearbeiten/löschen implementiert -+- Neue Routen für Bearbeiten und Löschen von Lizenzen -+- Bearbeitungsformular mit vorausgefüllten Werten -+- Aktiv/Inaktiv-Status kann geändert werden -+- Lösch-Bestätigung per JavaScript confirm() -+- Kunde kann nicht geändert werden (nur Lizenzdetails) -+ -+**Neue Features:** -+- `/license/edit/` - Bearbeitungsformular -+- `/license/delete/` - Lizenz löschen (POST) -+- Aktionen-Spalte in der Lizenzübersicht -+- Buttons für Bearbeiten und Löschen -+- Checkbox für Aktiv-Status -+ -+**Geänderte/Neue Dateien:** -+- v2_adminpanel/app.py (edit_license und delete_license Routen) -+- v2_adminpanel/templates/licenses.html (Aktionen-Spalte hinzugefügt) -+- v2_adminpanel/templates/edit_license.html (neu erstellt) -+ -+**Sicherheit:** -+- Login-Required für alle Aktionen -+- POST-only für Löschvorgänge -+- Bestätigungsdialog vor dem Löschen -+ -+### 2025-06-06 - Kundenverwaltung implementiert -+- Komplette CRUD-Funktionalität für Kunden -+- Übersicht zeigt Anzahl aktiver/gesamter Lizenzen pro Kunde -+- Kunden können nur gelöscht werden, wenn sie keine Lizenzen haben -+- Bearbeitungsseite zeigt alle Lizenzen des Kunden -+ -+**Neue Features:** -+- `/customers` - Kundenübersicht mit Statistiken -+- `/customer/edit/` - Kunde bearbeiten (Name, E-Mail) -+- `/customer/delete/` - Kunde löschen (nur ohne Lizenzen) -+- Navigation zwischen allen drei Hauptbereichen -+- Anzeige der Kundenlizenzen beim Bearbeiten -+ -+**Geänderte/Neue Dateien:** -+- v2_adminpanel/app.py (customers, edit_customer, delete_customer Routen) -+- v2_adminpanel/templates/customers.html (neu erstellt) -+- v2_adminpanel/templates/edit_customer.html (neu erstellt) -+- v2_adminpanel/templates/index.html (Navigation erweitert) -+- v2_adminpanel/templates/licenses.html (Navigation erweitert) -+ -+**Besonderheiten:** -+- Lösch-Button ist deaktiviert, wenn Kunde Lizenzen hat -+- Aktive Lizenzen werden separat gezählt (nicht abgelaufen + aktiv) -+- UTF-8 Support für Kundennamen mit Umlauten -+ -+### 2025-06-06 - Dashboard mit Statistiken implementiert -+- Übersichtliches Dashboard als neue Startseite -+- Statistik-Karten mit wichtigen Kennzahlen -+- Listen für bald ablaufende und zuletzt erstellte Lizenzen -+- Routing angepasst: Dashboard (/) und Lizenz erstellen (/create) -+ -+**Neue Features:** -+- Statistik-Karten: Kunden, Lizenzen gesamt, Aktive, Ablaufende -+- Aufteilung nach Lizenztypen (Vollversion/Testversion) -+- Aufteilung nach Status (Aktiv/Abgelaufen) -+- Top 10 bald ablaufende Lizenzen mit Restlaufzeit -+- Letzte 5 erstellte Lizenzen mit Status -+- Hover-Effekt auf Statistik-Karten -+- Einheitliche Navigation mit Dashboard-Link -+ -+**Geänderte/Neue Dateien:** -+- v2_adminpanel/app.py (dashboard() komplett überarbeitet, create_license() Route) -+- v2_adminpanel/templates/dashboard.html (neu erstellt) -+- v2_adminpanel/templates/index.html (Navigation erweitert) -+- v2_adminpanel/templates/licenses.html (Navigation angepasst) -+- v2_adminpanel/templates/customers.html (Navigation angepasst) -+ -+**Dashboard-Inhalte:** -+- 4 Hauptstatistiken als Karten -+- Lizenztyp-Verteilung -+- Status-Verteilung -+- Warnung für bald ablaufende Lizenzen -+- Übersicht der neuesten Aktivitäten -+ -+### 2025-06-06 - Suchfunktion implementiert -+- Volltextsuche für Lizenzen und Kunden -+- Case-insensitive Suche mit LIKE-Operator -+- Suchergebnisse mit Hervorhebung des Suchbegriffs -+- Suche zurücksetzen Button -+ -+**Neue Features:** -+- **Lizenzsuche**: Sucht in Lizenzschlüssel, Kundenname und E-Mail -+- **Kundensuche**: Sucht in Kundenname und E-Mail -+- Suchformular mit autofocus für schnelle Eingabe -+- Anzeige des aktiven Suchbegriffs -+- Unterschiedliche Meldungen für leere Ergebnisse -+ -+**Geänderte Dateien:** -+- v2_adminpanel/app.py (licenses() und customers() mit Suchlogik erweitert) -+- v2_adminpanel/templates/licenses.html (Suchformular hinzugefügt) -+- v2_adminpanel/templates/customers.html (Suchformular hinzugefügt) -+ -+**Technische Details:** -+- GET-Parameter für Suche -+- SQL LIKE mit LOWER() für Case-Insensitive Suche -+- Wildcards (%) für Teilstring-Suche -+- UTF-8 kompatibel für deutsche Umlaute -+ -+### 2025-06-06 - Filter und Pagination implementiert -+- Erweiterte Filteroptionen für Lizenzübersicht -+- Pagination für große Datenmengen (20 Einträge pro Seite) -+- Filter bleiben bei Seitenwechsel erhalten -+ -+**Neue Features für Lizenzen:** -+- **Filter nach Typ**: Alle, Vollversion, Testversion -+- **Filter nach Status**: Alle, Aktiv, Läuft bald ab, Abgelaufen, Deaktiviert -+- **Kombinierbar mit Suche**: Filter und Suche funktionieren zusammen -+- **Pagination**: Navigation durch mehrere Seiten -+- **Ergebnisanzeige**: Zeigt Anzahl gefilterter Ergebnisse -+ -+**Neue Features für Kunden:** -+- **Pagination**: 20 Kunden pro Seite -+- **Seitennavigation**: Erste, Letzte, Vor, Zurück -+- **Kombiniert mit Suche**: Suchparameter bleiben erhalten -+ -+**Geänderte Dateien:** -+- v2_adminpanel/app.py (licenses() und customers() mit Filter/Pagination erweitert) -+- v2_adminpanel/templates/licenses.html (Filter-Formular und Pagination hinzugefügt) -+- v2_adminpanel/templates/customers.html (Pagination hinzugefügt) -+ -+**Technische Details:** -+- SQL WHERE-Klauseln für Filter -+- LIMIT/OFFSET für Pagination -+- URL-Parameter bleiben bei Navigation erhalten -+- Responsive Bootstrap-Komponenten -+ -+### 2025-06-06 - Session-Tracking implementiert -+- Neue Tabelle für Session-Verwaltung -+- Anzeige aktiver und beendeter Sessions -+- Manuelles Beenden von Sessions möglich -+- Dashboard zeigt Anzahl aktiver Sessions -+ -+**Neue Features:** -+- **Sessions-Tabelle**: Speichert Session-ID, IP, User-Agent, Zeitstempel -+- **Aktive Sessions**: Zeigt alle laufenden Sessions mit Inaktivitätszeit -+- **Session-Historie**: Letzte 24 Stunden beendeter Sessions -+- **Session beenden**: Admins können Sessions manuell beenden -+- **Farbcodierung**: Grün (aktiv), Gelb (>5 Min inaktiv), Rot (lange inaktiv) -+ -+**Geänderte/Neue Dateien:** -+- v2_adminpanel/init.sql (sessions Tabelle hinzugefügt) -+- v2_adminpanel/app.py (sessions() und end_session() Routen) -+- v2_adminpanel/templates/sessions.html (neu erstellt) -+- v2_adminpanel/templates/dashboard.html (Session-Statistik) -+- Alle Templates (Session-Navigation hinzugefügt) -+ -+**Technische Details:** -+- Heartbeat-basiertes Tracking (last_heartbeat) -+- Automatische Inaktivitätsberechnung -+- Session-Dauer Berechnung -+- Responsive Tabellen mit Bootstrap -+ -+**Hinweis:** -+Die Session-Daten werden erst gefüllt, wenn der License Server API implementiert ist und Clients sich verbinden. -+ -+### 2025-06-06 - Export-Funktion implementiert -+- CSV und Excel Export für Lizenzen und Kunden -+- Formatierte Ausgabe mit deutschen Datumsformaten -+- UTF-8 Unterstützung für Sonderzeichen -+ -+**Neue Features:** -+- **Lizenz-Export**: Alle Lizenzen mit Kundeninformationen -+- **Kunden-Export**: Alle Kunden mit Lizenzstatistiken -+- **Format-Optionen**: Excel (.xlsx) und CSV (.csv) -+- **Deutsche Formatierung**: Datum als dd.mm.yyyy, Status auf Deutsch -+- **UTF-8 Export**: Korrekte Kodierung für Umlaute -+- **Export-Buttons**: Dropdown-Menüs in Lizenz- und Kundenübersicht -+ -+**Geänderte Dateien:** -+- v2_adminpanel/app.py (export_licenses() und export_customers() Routen) -+- v2_adminpanel/requirements.txt (pandas und openpyxl hinzugefügt) -+- v2_adminpanel/templates/licenses.html (Export-Dropdown hinzugefügt) -+- v2_adminpanel/templates/customers.html (Export-Dropdown hinzugefügt) -+ -+**Technische Details:** -+- Pandas für Datenverarbeitung -+- OpenPyXL für Excel-Export -+- CSV mit Semikolon-Trennung für deutsche Excel-Kompatibilität -+- Automatische Spaltenbreite in Excel -+- BOM für UTF-8 CSV (Excel-Kompatibilität) -+ -+### 2025-06-06 - Audit-Log implementiert -+- Vollständiges Änderungsprotokoll für alle Aktionen -+- Filterbare Übersicht mit Pagination -+- Detaillierte Anzeige von Änderungen -+ -+**Neue Features:** -+- **Audit-Log-Tabelle**: Speichert alle Änderungen mit Zeitstempel, Benutzer, IP -+- **Protokollierte Aktionen**: CREATE, UPDATE, DELETE, LOGIN, LOGOUT, EXPORT -+- **JSON-Speicherung**: Alte und neue Werte als JSONB für flexible Abfragen -+- **Filter-Optionen**: Nach Benutzer, Aktion und Entität -+- **Detail-Anzeige**: Aufklappbare Änderungsdetails -+- **Navigation**: Audit-Link in allen Templates -+ -+**Geänderte/Neue Dateien:** -+- v2_adminpanel/init.sql (audit_log Tabelle mit Indizes) -+- v2_adminpanel/app.py (log_audit() Funktion und audit_log() Route) -+- v2_adminpanel/templates/audit_log.html (neu erstellt) -+- Alle Templates (Audit-Navigation hinzugefügt) -+ -+**Technische Details:** -+- JSONB für strukturierte Datenspeicherung -+- Performance-Indizes auf timestamp, username und entity -+- Farbcodierung für verschiedene Aktionen -+- 50 Einträge pro Seite mit Pagination -+- IP-Adresse und User-Agent Tracking -+ -+### 2025-06-06 - PostgreSQL UTF-8 Locale konfiguriert -+- Eigenes PostgreSQL Dockerfile für deutsche Locale -+- Sicherstellung der UTF-8 Unterstützung auf Datenbankebene -+ -+**Neue Features:** -+- **PostgreSQL Dockerfile**: Installiert deutsche Locale (de_DE.UTF-8) -+- **Locale-Umgebungsvariablen**: LANG, LANGUAGE, LC_ALL gesetzt -+- **Docker Compose Update**: Verwendet jetzt eigenes PostgreSQL-Image -+ -+**Neue Dateien:** -+- v2_postgres/Dockerfile (neu erstellt) -+ -+**Geänderte Dateien:** -+- v2/docker-compose.yaml (postgres Service nutzt jetzt build statt image) -+ -+**Technische Details:** -+- Basis-Image: postgres:14 -+- Locale-Installation über apt-get -+- locale-gen für de_DE.UTF-8 -+- Vollständige UTF-8 Unterstützung für deutsche Sonderzeichen -+ -+### 2025-06-07 - Backup-Funktionalität implementiert -+- Verschlüsselte Backups mit manueller und automatischer Ausführung -+- Backup-Historie mit Download und Wiederherstellung -+- Dashboard-Integration für Backup-Status -+ -+**Neue Features:** -+- **Backup-Erstellung**: Manuell und automatisch (täglich 3:00 Uhr) -+- **Verschlüsselung**: AES-256 mit Fernet, Key aus ENV oder automatisch generiert -+- **Komprimierung**: GZIP-Komprimierung vor Verschlüsselung -+- **Backup-Historie**: Vollständige Übersicht aller Backups -+- **Wiederherstellung**: Mit optionalem Verschlüsselungs-Passwort -+- **Download-Funktion**: Backups können heruntergeladen werden -+- **Dashboard-Widget**: Zeigt letztes Backup-Status -+- **E-Mail-Vorbereitung**: Struktur für Benachrichtigungen (deaktiviert) -+ -+**Neue/Geänderte Dateien:** -+- v2_adminpanel/init.sql (backup_history Tabelle hinzugefügt) -+- v2_adminpanel/requirements.txt (cryptography, apscheduler hinzugefügt) -+- v2_adminpanel/app.py (Backup-Funktionen und Routen) -+- v2_adminpanel/templates/backups.html (neu erstellt) -+- v2_adminpanel/templates/dashboard.html (Backup-Status-Widget) -+- v2_adminpanel/Dockerfile (PostgreSQL-Client installiert) -+- v2/.env (EMAIL_ENABLED und BACKUP_ENCRYPTION_KEY) -+- Alle Templates (Backup-Navigation hinzugefügt) -+ -+**Technische Details:** -+- Speicherort: C:\Users\Administrator\Documents\GitHub\v2-Docker\backups\ -+- Dateiformat: backup_v2docker_YYYYMMDD_HHMMSS_encrypted.sql.gz.enc -+- APScheduler für automatische Backups -+- pg_dump/psql für Datenbank-Operationen -+- Audit-Log für alle Backup-Aktionen -+- Sicherheitsabfrage bei Wiederherstellung -+ -+### 2025-06-07 - HTTPS/SSL und Internet-Zugriff implementiert -+- Nginx Reverse Proxy für externe Erreichbarkeit eingerichtet -+- SSL-Zertifikate von IONOS mit vollständiger Certificate Chain integriert -+- Netzwerkkonfiguration für feste IP-Adresse -+- DynDNS und Port-Forwarding konfiguriert -+ -+**Neue Features:** -+- **Nginx Reverse Proxy**: Leitet HTTPS-Anfragen an Container weiter -+- **SSL-Zertifikate**: Wildcard-Zertifikat von IONOS für *.z5m7q9dk3ah2v1plx6ju.com -+- **Certificate Chain**: Server-, Intermediate- und Root-Zertifikate kombiniert -+- **Subdomain-Routing**: admin-panel-undso und api-software-undso -+- **Port-Forwarding**: FRITZ!Box 443 → 192.168.178.88 -+- **Feste IP**: Windows-PC auf 192.168.178.88 konfiguriert -+ -+**Neue/Geänderte Dateien:** -+- v2_nginx/nginx.conf (Reverse Proxy Konfiguration) -+- v2_nginx/Dockerfile (Nginx Container mit SSL) -+- v2_nginx/ssl/fullchain.pem (Certificate Chain) -+- v2_nginx/ssl/privkey.pem (Private Key) -+- v2/docker-compose.yaml (nginx Service hinzugefügt) -+- set-static-ip.ps1 (PowerShell Script für feste IP) -+- reset-to-dhcp.ps1 (PowerShell Script für DHCP) -+ -+**Technische Details:** -+- SSL-Termination am Nginx Reverse Proxy -+- Backend-Kommunikation über Docker-internes Netzwerk -+- Admin-Panel nur noch über Nginx erreichbar (Port 443 nicht mehr exposed) -+- License-Server behält externen Port 8443 für direkte API-Zugriffe -+- Intermediate Certificates aus ZIP extrahiert und korrekt verkettet -+ -+**Zugangsdaten:** -+- Admin-Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com -+- Benutzer 1: rac00n -+- Benutzer 2: w@rh@mm3r -+ -+**Status:** -+- ✅ Admin-Panel extern erreichbar ohne SSL-Warnungen -+- ✅ Reverse Proxy funktioniert -+- ✅ SSL-Zertifikate korrekt konfiguriert -+- ✅ Netzwerk-Setup abgeschlossen -+ -+### 2025-06-07 - Projekt-Cleanup durchgeführt -+- Redundante und überflüssige Dateien entfernt -+- Projektstruktur verbessert und organisiert -+ -+**Durchgeführte Änderungen:** -+1. **Entfernte Dateien:** -+ - v2_adminpanel/templates/.env (Duplikat der Haupt-.env) -+ - v2_postgreSQL/ (leeres Verzeichnis) -+ - SSL-Zertifikate aus Root-Verzeichnis (7 Dateien) -+ - Ungenutzer `json` Import aus app.py -+ -+2. **Organisatorische Verbesserungen:** -+ - PowerShell-Scripts in neuen `scripts/` Ordner verschoben -+ - SSL-Zertifikate nur noch in v2_nginx/ssl/ -+ - Keine Konfigurationsdateien mehr in Template-Verzeichnissen -+ -+**Technische Details:** -+- Docker-Container wurden gestoppt und nach Cleanup neu gestartet -+- Alle Services laufen wieder normal -+- Keine funktionalen Änderungen, nur Struktur-Verbesserungen -+ -+**Ergebnis:** -+- Verbesserte Projektstruktur -+- Erhöhte Sicherheit (keine SSL-Zertifikate im Root) -+- Klarere Dateiorganisation -+ -+### 2025-06-07 - SSL "Nicht sicher" Problem behoben -+- Chrome-Warnung trotz gültigem Zertifikat analysiert und behoben -+- Ursache: Selbstsigniertes Zertifikat in der Admin Panel Flask-App -+ -+**Durchgeführte Änderungen:** -+1. **Admin Panel Konfiguration (app.py):** -+ - Von HTTPS mit selbstsigniertem Zertifikat auf HTTP Port 5000 umgestellt -+ - `ssl_context='adhoc'` entfernt -+ - Flask läuft jetzt auf `0.0.0.0:5000` statt HTTPS -+ -+2. **Dockerfile Anpassung (v2_adminpanel/Dockerfile):** -+ - EXPOSE Port von 443 auf 5000 geändert -+ - Container exponiert jetzt HTTP statt HTTPS -+ -+3. **Nginx Konfiguration (nginx.conf):** -+ - proxy_pass von `https://admin-panel:443` auf `http://admin-panel:5000` geändert -+ - `proxy_ssl_verify off` entfernt (nicht mehr benötigt) -+ - Sicherheits-Header für beide Domains hinzugefügt: -+ - Strict-Transport-Security (HSTS) - erzwingt HTTPS für 1 Jahr -+ - X-Content-Type-Options - verhindert MIME-Type Sniffing -+ - X-Frame-Options - Schutz vor Clickjacking -+ - X-XSS-Protection - aktiviert XSS-Filter -+ - Referrer-Policy - kontrolliert Referrer-Informationen -+ -+**Technische Details:** -+- Externer Traffic nutzt weiterhin HTTPS mit gültigen IONOS-Zertifikaten -+- Interne Kommunikation zwischen Nginx und Admin Panel läuft über HTTP (sicher im Docker-Netzwerk) -+- Kein selbstsigniertes Zertifikat mehr in der Zertifikatskette -+- SSL-Termination erfolgt ausschließlich am Nginx Reverse Proxy -+ -+**Docker Neustart:** -+- Container gestoppt (`docker-compose down`) -+- Images neu gebaut (`docker-compose build`) -+- Container neu gestartet (`docker-compose up -d`) -+- Alle Services laufen normal -+ -+**Ergebnis:** -+- ✅ "Nicht sicher" Warnung in Chrome behoben -+- ✅ Saubere SSL-Konfiguration ohne Mixed Content -+- ✅ Verbesserte Sicherheits-Header implementiert -+- ✅ Admin Panel zeigt jetzt grünes Schloss-Symbol -+ -+### 2025-06-07 - Sicherheitslücke geschlossen: License Server Port -+- Direkter Zugriff auf License Server Port 8443 entfernt -+- Sicherheitsanalyse der exponierten Ports durchgeführt -+ -+**Identifiziertes Problem:** -+- License Server war direkt auf Port 8443 von außen erreichbar -+- Umging damit die Nginx-Sicherheitsschicht und Security Headers -+- Besonders kritisch, da nur Platzhalter ohne echte Sicherheit -+ -+**Durchgeführte Änderung:** -+- Port-Mapping für License Server in docker-compose.yaml entfernt -+- Service ist jetzt nur noch über Nginx Reverse Proxy erreichbar -+- Gleiche Sicherheitskonfiguration wie Admin Panel -+ -+**Aktuelle Port-Exposition:** -+- ✅ Nginx: Port 80/443 (benötigt für externen Zugriff) -+- ✅ PostgreSQL: Keine Ports exponiert (gut) -+- ✅ Admin Panel: Nur über Nginx erreichbar -+- ✅ License Server: Nur über Nginx erreichbar (vorher direkt auf 8443) -+ -+**Weitere identifizierte Sicherheitsthemen:** -+1. Credentials im Klartext in .env Datei -+2. SSL-Zertifikate im Repository gespeichert -+3. License Server noch nicht implementiert -+ -+**Empfehlung:** Docker-Container neu starten für Änderungsübernahme -+ -+### 2025-06-07 - License Server Port 8443 wieder aktiviert -+- Port 8443 für direkten Zugriff auf License Server wieder geöffnet -+- Notwendig für Client-Software Lizenzprüfung -+ -+**Begründung:** -+- Client-Software benötigt direkten Zugriff für Lizenzprüfung -+- Umgehung von möglichen Firewall-Blockaden auf Port 443 -+- Weniger Latenz ohne Nginx-Proxy -+- Flexibilität für verschiedene Client-Implementierungen -+ -+**Konfiguration:** -+- License Server erreichbar über: -+ - Direkt: Port 8443 (für Client-Software) -+ - Via Nginx: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com (für Browser/Tests) -+ -+**Sicherheitshinweis:** -+- Port 8443 ist wieder direkt exponiert -+- License Server muss vor Produktivbetrieb implementiert werden mit: -+ - Eigener SSL-Konfiguration -+ - API-Key Authentifizierung -+ - Rate Limiting -+ - Input-Validierung -+ -+**Status:** -+- Port-Mapping in docker-compose.yaml wiederhergestellt -+- Änderung erfordert Docker-Neustart -+ -+### 2025-06-07 - Rate-Limiting und Brute-Force-Schutz implementiert -+- Umfassender Schutz vor Login-Angriffen mit IP-Sperre -+- Dashboard-Integration für Sicherheitsüberwachung -+ -+**Implementierte Features:** -+1. **Rate-Limiting System:** -+ - 5 Login-Versuche erlaubt, danach 24h IP-Sperre -+ - Progressive Fehlermeldungen (zufällig aus 5 lustigen Varianten) -+ - CAPTCHA nach 2 Fehlversuchen (Google reCAPTCHA v2 vorbereitet) -+ - E-Mail-Benachrichtigung bei Sperrung (vorbereitet, deaktiviert für PoC) -+ -+2. **Timing-Attack Schutz:** -+ - Mindestens 1 Sekunde Antwortzeit bei allen Login-Versuchen -+ - Gleiche Antwortzeit bei richtigem/falschem Username -+ - Verhindert Username-Enumeration -+ -+3. **Lustige Fehlermeldungen (zufällig):** -+ - "NOPE!" -+ - "ACCESS DENIED, TRY HARDER" -+ - "WRONG! 🚫" -+ - "COMPUTER SAYS NO" -+ - "YOU FAILED" -+ -+4. **Dashboard-Sicherheitswidget:** -+ - Sicherheitslevel-Anzeige (NORMAL/ERHÖHT/KRITISCH) -+ - Anzahl gesperrter IPs -+ - Fehlversuche heute -+ - Letzte 5 Sicherheitsereignisse mit Details -+ -+5. **IP-Verwaltung:** -+ - Übersicht aller gesperrten IPs -+ - Manuelles Entsperren möglich -+ - Login-Versuche zurücksetzen -+ - Detaillierte Informationen pro IP -+ -+6. **Audit-Log Erweiterungen:** -+ - LOGIN_SUCCESS - Erfolgreiche Anmeldung -+ - LOGIN_FAILED - Fehlgeschlagener Versuch -+ - LOGIN_BLOCKED - IP wurde gesperrt -+ - UNBLOCK_IP - IP manuell entsperrt -+ - CLEAR_ATTEMPTS - Versuche zurückgesetzt -+ -+**Neue/Geänderte Dateien:** -+- v2_adminpanel/init.sql (login_attempts Tabelle) -+- v2_adminpanel/app.py (Rate-Limiting Logik, neue Routen) -+- v2_adminpanel/templates/login.html (Fehlermeldungs-Styling, CAPTCHA) -+- v2_adminpanel/templates/dashboard.html (Sicherheitswidget) -+- v2_adminpanel/templates/blocked_ips.html (neu - IP-Verwaltung) -+ -+**Technische Details:** -+- IP-Ermittlung berücksichtigt Proxy-Header (X-Forwarded-For) -+- Fehlermeldungen mit Animation (shake-effect) -+- Farbcodierung: Rot für Fehler, Lila für Sperre, Orange für CAPTCHA -+- Automatische Bereinigung alter Einträge möglich -+ -+**Sicherheitsverbesserungen:** -+- Schutz vor Brute-Force-Angriffen -+- Timing-Attack-Schutz implementiert -+- IP-basierte Sperrung für 24 Stunden -+- Audit-Trail für alle Sicherheitsereignisse -+ -+**Hinweis für Produktion:** -+- CAPTCHA-Keys müssen in .env konfiguriert werden -+- E-Mail-Server für Benachrichtigungen einrichten -+- Rate-Limits können über Konstanten angepasst werden -+ -+### 2025-06-07 - Session-Timeout mit Live-Timer implementiert -+- 5 Minuten Inaktivitäts-Timeout mit visueller Countdown-Anzeige -+- Automatische Session-Verlängerung bei Benutzeraktivität -+ -+**Implementierte Features:** -+1. **Session-Timeout Backend:** -+ - Flask Session-Timeout auf 5 Minuten konfiguriert -+ - Heartbeat-Endpoint für Keep-Alive -+ - Automatisches Session-Update bei jeder Aktion -+ -+2. **Live-Timer in der Navbar:** -+ - Countdown von 5:00 bis 0:00 -+ - Position: Zwischen Logo und Username -+ - Farbwechsel nach verbleibender Zeit: -+ - Grün: > 2 Minuten -+ - Gelb: 1-2 Minuten -+ - Rot: < 1 Minute -+ - Blinkend: < 30 Sekunden -+ -+3. **Benutzerinteraktion:** -+ - Timer wird bei jeder Aktivität zurückgesetzt -+ - Tracking von: Klicks, Tastatureingaben, Mausbewegungen -+ - Automatischer Heartbeat bei Aktivität -+ - Warnung bei < 1 Minute mit "Session verlängern" Button -+ -+4. **Base-Template System:** -+ - Neue base.html als Basis für alle Admin-Seiten -+ - Alle Templates (außer login.html) nutzen jetzt base.html -+ - Einheitliches Layout und Timer auf allen Seiten -+ -+**Neue/Geänderte Dateien:** -+- v2_adminpanel/app.py (Session-Konfiguration, Heartbeat-Endpoint) -+- v2_adminpanel/templates/base.html (neu - Base-Template mit Timer) -+- Alle anderen Templates aktualisiert für Template-Vererbung -+ -+**Technische Details:** -+- JavaScript-basierter Countdown-Timer -+- AJAX-Heartbeat alle 5 Sekunden bei Aktivität -+- LocalStorage für Tab-Synchronisation möglich -+- Automatischer Logout bei 0:00 -+- Fetch-Interceptor für automatische Session-Verlängerung -+ -+**Sicherheitsverbesserung:** -+- Automatischer Logout nach 5 Minuten Inaktivität -+- Verhindert vergessene Sessions -+- Visuelles Feedback für Session-Status -+ -+### 2025-06-07 - Session-Timeout Bug behoben -+- Problem: Session-Timeout funktionierte nicht korrekt - Session blieb länger als 5 Minuten aktiv -+- Ursache: login_required Decorator aktualisierte last_activity bei JEDEM Request -+ -+**Durchgeführte Änderungen:** -+1. **login_required Decorator (app.py):** -+ - Prüft jetzt ob Session abgelaufen ist (5 Minuten seit last_activity) -+ - Aktualisiert last_activity NICHT mehr automatisch -+ - Führt AUTO_LOGOUT mit Audit-Log bei Timeout durch -+ - Speichert Username vor session.clear() für korrektes Logging -+ -+2. **Heartbeat-Endpoint (app.py):** -+ - Geändert zu POST-only Endpoint -+ - Aktualisiert explizit last_activity wenn aufgerufen -+ - Wird nur bei aktiver Benutzerinteraktion aufgerufen -+ -+3. **Frontend Timer (base.html):** -+ - Heartbeat wird als POST Request gesendet -+ - trackActivity() ruft extendSession() ohne vorheriges resetTimer() auf -+ - Timer wird erst nach erfolgreichem Heartbeat zurückgesetzt -+ - AJAX Interceptor ignoriert Heartbeat-Requests -+ -+4. **Audit-Log Erweiterung:** -+ - Neue Aktion AUTO_LOGOUT hinzugefügt -+ - Orange Farbcodierung (#fd7e14) -+ - Zeigt Grund des Timeouts im Audit-Log -+ -+**Ergebnis:** -+- ✅ Session läuft nach exakt 5 Minuten Inaktivität ab -+- ✅ Benutzeraktivität verlängert Session korrekt -+- ✅ AUTO_LOGOUT wird im Audit-Log protokolliert -+- ✅ Visueller Timer zeigt verbleibende Zeit -+ -+### 2025-06-07 - Session-Timeout weitere Verbesserungen -+- Zusätzliche Fixes nach Test-Feedback implementiert -+ -+**Weitere durchgeführte Änderungen:** -+1. **Fehlender Import behoben:** -+ - `flash` zu Flask-Imports hinzugefügt für Timeout-Warnmeldungen -+ -+2. **Session-Cookie-Konfiguration erweitert (app.py):** -+ - SESSION_COOKIE_HTTPONLY = True (Sicherheit gegen XSS) -+ - SESSION_COOKIE_SECURE = False (intern HTTP, extern HTTPS via Nginx) -+ - SESSION_COOKIE_SAMESITE = 'Lax' (CSRF-Schutz) -+ - SESSION_COOKIE_NAME = 'admin_session' (eindeutiger Name) -+ - SESSION_REFRESH_EACH_REQUEST = False (verhindert automatische Verlängerung) -+ -+3. **Session-Handling verbessert:** -+ - Entfernt: session.permanent = True aus login_required decorator -+ - Hinzugefügt: session.modified = True im Heartbeat für explizites Speichern -+ - Debug-Logging für Session-Timeout-Prüfung hinzugefügt -+ -+4. **Nginx-Konfiguration:** -+ - Bereits korrekt konfiguriert für Heartbeat-Weiterleitung -+ - Proxy-Headers für korrekte IP-Weitergabe -+ -+**Technische Details:** -+- Flask-Session mit Filesystem-Backend nutzt jetzt korrekte Cookie-Einstellungen -+- Session-Cookie wird nicht mehr automatisch bei jedem Request verlängert -+- Explizite Session-Modifikation nur bei Heartbeat-Requests -+- Debug-Logs zeigen Zeit seit letzter Aktivität für Troubleshooting -+ -+**Status:** -+- ✅ Session-Timeout-Mechanismus vollständig implementiert -+- ✅ Debug-Logging für Session-Überwachung aktiv -+- ✅ Cookie-Sicherheitseinstellungen optimiert -+ -+### 2025-06-07 - CAPTCHA Backend-Validierung implementiert -+- Google reCAPTCHA v2 Backend-Verifizierung hinzugefügt -+ -+**Implementierte Features:** -+1. **verify_recaptcha() Funktion (app.py):** -+ - Validiert CAPTCHA-Response mit Google API -+ - Fallback: Wenn RECAPTCHA_SECRET_KEY nicht konfiguriert, wird CAPTCHA übersprungen (für PoC) -+ - Timeout von 5 Sekunden für API-Request -+ - Error-Handling für Netzwerkfehler -+ - Logging für Debugging und Fehleranalyse -+ -+2. **Login-Route Erweiterungen:** -+ - CAPTCHA wird nach 2 Fehlversuchen angezeigt -+ - Prüfung ob CAPTCHA-Response vorhanden -+ - Validierung der CAPTCHA-Response gegen Google API -+ - Unterschiedliche Fehlermeldungen für fehlende/ungültige CAPTCHA -+ - Site Key wird aus Environment-Variable an Template übergeben -+ -+3. **Environment-Konfiguration (.env):** -+ - RECAPTCHA_SITE_KEY (für Frontend) -+ - RECAPTCHA_SECRET_KEY (für Backend-Validierung) -+ - Beide auskommentiert für PoC-Phase -+ -+4. **Dependencies:** -+ - requests Library zu requirements.txt hinzugefügt -+ -+**Sicherheitsaspekte:** -+- CAPTCHA verhindert automatisierte Brute-Force-Angriffe -+- Timing-Attack-Schutz bleibt auch bei CAPTCHA-Prüfung aktiv -+- Bei Netzwerkfehlern wird CAPTCHA als bestanden gewertet (Verfügbarkeit vor Sicherheit) -+- Secret Key wird niemals im Frontend exponiert -+ -+**Verwendung:** -+1. Google reCAPTCHA v2 Keys erstellen: https://www.google.com/recaptcha/admin -+2. Keys in .env eintragen: -+ ``` -+ RECAPTCHA_SITE_KEY=your-site-key -+ RECAPTCHA_SECRET_KEY=your-secret-key -+ ``` -+3. Container neu starten -+ -+**Status:** -+- ✅ CAPTCHA-Frontend bereits vorhanden (login.html) -+- ✅ Backend-Validierung vollständig implementiert -+- ✅ Fallback für PoC-Betrieb ohne Google-Keys -+- ✅ Integration in Rate-Limiting-System -+- ⚠️ CAPTCHA-Keys noch nicht konfiguriert (für PoC deaktiviert) -+ -+**Anleitung für Google reCAPTCHA Keys:** -+ -+1. **Registrierung bei Google reCAPTCHA:** -+ - Gehe zu: https://www.google.com/recaptcha/admin/create -+ - Melde dich mit Google-Konto an -+ - Label eingeben: "v2-Docker Admin Panel" -+ - Typ wählen: "reCAPTCHA v2" → "Ich bin kein Roboter"-Kästchen -+ - Domains hinzufügen: -+ ``` -+ admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com -+ localhost -+ ``` -+ - Nutzungsbedingungen akzeptieren -+ - Senden klicken -+ -+2. **Keys erhalten:** -+ - Site Key (öffentlich für Frontend) -+ - Secret Key (geheim für Backend-Validierung) -+ -+3. **Keys in .env eintragen:** -+ ```bash -+ RECAPTCHA_SITE_KEY=6Ld... -+ RECAPTCHA_SECRET_KEY=6Ld... -+ ``` -+ -+4. **Container neu starten:** -+ ```bash -+ docker-compose down -+ docker-compose up -d -+ ``` -+ -+**Kosten:** -+- Kostenlos bis 1 Million Anfragen pro Monat -+- Danach: $1.00 pro 1000 zusätzliche Anfragen -+- Für dieses Projekt reicht die kostenlose Version vollkommen aus -+ -+**Test-Keys für Entwicklung:** -+- Site Key: `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI` -+- Secret Key: `6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe` -+- ⚠️ Diese Keys nur für lokale Tests verwenden, niemals produktiv! -+ -+**Aktueller Status:** -+- Code ist vollständig implementiert und getestet -+- CAPTCHA wird nach 2 Fehlversuchen angezeigt -+- Ohne konfigurierte Keys wird CAPTCHA-Prüfung übersprungen -+- Für Produktion müssen nur die Keys in .env eingetragen werden -+ -+### 2025-06-07 - License Key Generator implementiert -+- Automatische Generierung von Lizenzschlüsseln mit definiertem Format -+ -+**Implementiertes Format:** -+`AF-YYYYMMFT-XXXX-YYYY-ZZZZ` -+- **AF** = Account Factory (feste Produktkennung) -+- **YYYY** = Jahr (z.B. 2025) -+- **MM** = Monat (z.B. 06) -+- **FT** = Lizenztyp (F=Fullversion, T=Testversion) -+- **XXXX-YYYY-ZZZZ** = Zufällige alphanumerische Zeichen (ohne verwirrende wie 0/O, 1/I/l) -+ -+**Beispiele:** -+- Vollversion: `AF-202506F-A7K9-M3P2-X8R4` -+- Testversion: `AF-202512T-B2N5-K8L3-Q9W7` -+ -+**Implementierte Features:** -+ -+1. **Backend-Funktionen (app.py):** -+ - `generate_license_key()` - Generiert Keys mit kryptografisch sicherem Zufallsgenerator -+ - `validate_license_key()` - Validiert das Key-Format mit Regex -+ - Verwendet `secrets` statt `random` für Sicherheit -+ - Erlaubte Zeichen: ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (ohne verwirrende) -+ -+2. **API-Endpoint:** -+ - POST `/api/generate-license-key` - JSON API für Key-Generierung -+ - Prüft auf Duplikate in der Datenbank (max. 10 Versuche) -+ - Audit-Log-Eintrag bei jeder Generierung -+ - Login-Required geschützt -+ -+3. **Frontend-Verbesserungen (index.html):** -+ - Generate-Button neben License Key Input -+ - Placeholder und Pattern-Attribut für Format-Hinweis -+ - Auto-Uppercase bei manueller Eingabe -+ - Visuelles Feedback bei erfolgreicher Generierung -+ - Format-Hinweis unter dem Eingabefeld -+ -+4. **JavaScript-Features:** -+ - AJAX-basierte Key-Generierung ohne Seiten-Reload -+ - Automatische Prüfung bei Lizenztyp-Änderung -+ - Ladeindikator während der Generierung -+ - Fehlerbehandlung mit Benutzer-Feedback -+ - Standard-Datum-Einstellungen (heute + 1 Jahr) -+ -+5. **Validierung:** -+ - Server-seitige Format-Validierung beim Speichern -+ - Flash-Message bei ungültigem Format -+ - Automatische Großschreibung des Keys -+ - Pattern-Validierung im HTML-Formular -+ -+6. **Weitere Fixes:** -+ - Form Action von "/" auf "/create" korrigiert -+ - Flash-Messages mit Bootstrap Toasts implementiert -+ - GENERATE_KEY Aktion zum Audit-Log hinzugefügt (Farbe: #20c997) -+ -+**Technische Details:** -+- Keine vorhersagbaren Muster durch `secrets.choice()` -+- Datum im Key zeigt Erstellungszeitpunkt -+- Lizenztyp direkt im Key erkennbar -+- Kollisionsprüfung gegen Datenbank -+ -+**Status:** -+- ✅ Backend-Generierung vollständig implementiert -+- ✅ Frontend mit Generate-Button und JavaScript -+- ✅ Validierung und Fehlerbehandlung -+- ✅ Audit-Log-Integration -+- ✅ Form-Action-Bug behoben -+ -+### 2025-06-07 - Batch-Lizenzgenerierung implementiert -+- Mehrere Lizenzen auf einmal für einen Kunden erstellen -+ -+**Implementierte Features:** -+ -+1. **Batch-Formular (/batch):** -+ - Kunde und E-Mail eingeben -+ - Anzahl der Lizenzen (1-100) -+ - Lizenztyp (Vollversion/Testversion) -+ - Gültigkeitszeitraum für alle Lizenzen -+ - Vorschau-Modal zeigt Key-Format -+ - Standard-Datum-Einstellungen (heute + 1 Jahr) -+ -+2. **Backend-Verarbeitung:** -+ - Route `/batch` für GET (Formular) und POST (Generierung) -+ - Generiert die angegebene Anzahl eindeutiger Keys -+ - Speichert alle in einer Transaktion -+ - Kunde wird automatisch angelegt (falls nicht vorhanden) -+ - ON CONFLICT für existierende Kunden -+ - Audit-Log-Eintrag mit CREATE_BATCH Aktion -+ -+3. **Ergebnis-Seite:** -+ - Zeigt alle generierten Lizenzen in Tabellenform -+ - Kundeninformationen und Gültigkeitszeitraum -+ - Einzelne Keys können kopiert werden (📋 Button) -+ - Alle Keys auf einmal kopieren -+ - Druckfunktion für physische Ausgabe -+ - Link zur Lizenzübersicht mit Kundenfilter -+ -+4. **Export-Funktionalität:** -+ - Route `/batch/export` für CSV-Download -+ - Speichert Batch-Daten in Session für Export -+ - CSV mit UTF-8 BOM für Excel-Kompatibilität -+ - Enthält Kundeninfo, Generierungsdatum und alle Keys -+ - Format: Nr;Lizenzschlüssel;Typ -+ - Dateiname: batch_licenses_KUNDE_TIMESTAMP.csv -+ -+5. **Integration:** -+ - Batch-Button in Navigation (Dashboard, Einzellizenz-Seite) -+ - CREATE_BATCH Aktion im Audit-Log (Farbe: #6610f2) -+ - Session-basierte Export-Daten -+ - Flash-Messages für Feedback -+ -+**Sicherheit:** -+- Limit von 100 Lizenzen pro Batch -+- Login-Required für alle Routen -+- Transaktionale Datenbank-Operationen -+- Validierung der Eingaben -+ -+**Beispiel-Workflow:** -+1. Admin geht zu `/batch` -+2. Gibt Kunde "Firma GmbH", Anzahl "25", Typ "Vollversion" ein -+3. System generiert 25 eindeutige Keys -+4. Ergebnis-Seite zeigt alle Keys -+5. Admin kann CSV exportieren oder Keys kopieren -+6. Kunde erhält die Lizenzen -+ -+**Status:** -+- ✅ Batch-Formular vollständig implementiert -+- ✅ Backend-Generierung mit Transaktionen -+- ✅ Export als CSV -+- ✅ Copy-to-Clipboard Funktionalität -+- ✅ Audit-Log-Integration -+- ✅ Navigation aktualisiert -+ -+## 2025-06-06: Implementierung Searchable Dropdown für Kundenauswahl -+ -+**Problem:** -+- Bei der Lizenzerstellung wurde immer ein neuer Kunde angelegt -+- Keine Möglichkeit, Lizenzen für bestehende Kunden zu erstellen -+- Bei vielen Kunden wäre ein normales Dropdown unübersichtlich -+ -+**Lösung:** -+1. **Select2 Library** für searchable Dropdown integriert -+2. **API-Endpoint `/api/customers`** für die Kundensuche erstellt -+3. **Frontend angepasst:** -+ - Searchable Dropdown mit Live-Suche -+ - Option "Neuer Kunde" im Dropdown -+ - Eingabefelder erscheinen nur bei "Neuer Kunde" -+4. **Backend-Logik verbessert:** -+ - Prüfung ob neuer oder bestehender Kunde -+ - E-Mail-Duplikatsprüfung vor Kundenerstellung -+ - Separate Audit-Logs für Kunde und Lizenz -+5. **Datenbank:** -+ - UNIQUE Constraint auf E-Mail-Spalte hinzugefügt -+ -+**Änderungen:** -+- `app.py`: Neuer API-Endpoint `/api/customers`, angepasste Routes `/create` und `/batch` -+- `base.html`: Select2 CSS und JS eingebunden -+- `index.html`: Kundenauswahl mit Select2 implementiert -+- `batch_form.html`: Kundenauswahl mit Select2 implementiert -+- `init.sql`: UNIQUE Constraint für E-Mail -+ -+**Status:** -+- ✅ API-Endpoint funktioniert mit Pagination -+- ✅ Select2 Dropdown mit Suchfunktion -+- ✅ Neue/bestehende Kunden können ausgewählt werden -+- ✅ E-Mail-Duplikate werden verhindert -+- ✅ Sowohl Einzellizenz als auch Batch unterstützt -+ -+## 2025-06-06: Automatische Ablaufdatum-Berechnung -+ -+**Problem:** -+- Manuelles Eingeben von Start- und Enddatum war umständlich -+- Fehleranfällig bei der Datumseingabe -+- Nicht intuitiv für Standard-Laufzeiten -+ -+**Lösung:** -+1. **Frontend-Änderungen:** -+ - Startdatum + Laufzeit (Zahl) + Einheit (Tage/Monate/Jahre) -+ - Ablaufdatum wird automatisch berechnet und angezeigt (read-only) -+ - Standard: 1 Jahr Laufzeit voreingestellt -+2. **Backend-Validierung:** -+ - Server-seitige Berechnung zur Sicherheit -+ - Verwendung von `python-dateutil` für korrekte Monats-/Jahresberechnungen -+3. **Benutzerfreundlichkeit:** -+ - Sofortige Neuberechnung bei Änderungen -+ - Visuelle Hervorhebung des berechneten Datums -+ -+**Änderungen:** -+- `index.html`: Laufzeit-Eingabe statt Ablaufdatum -+- `batch_form.html`: Laufzeit-Eingabe statt Ablaufdatum -+- `app.py`: Datum-Berechnung in `/create` und `/batch` Routes -+- `requirements.txt`: `python-dateutil` hinzugefügt -+ -+**Status:** -+- ✅ Automatische Berechnung funktioniert -+- ✅ Frontend zeigt berechnetes Datum sofort an -+- ✅ Backend validiert die Berechnung -+- ✅ Standardwert (1 Jahr) voreingestellt -+ -+## 2025-06-06: Bugfix - created_at für licenses Tabelle -+ -+**Problem:** -+- Batch-Generierung schlug fehl mit "Fehler bei der Batch-Generierung!" -+- INSERT Statement versuchte `created_at` zu setzen, aber Spalte existierte nicht -+- Inkonsistenz: Einzellizenzen hatten kein created_at, Batch-Lizenzen versuchten es zu setzen -+ -+**Lösung:** -+1. **Datenbank-Schema erweitert:** -+ - `created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` zur licenses Tabelle hinzugefügt -+ - Migration für bestehende Datenbanken implementiert -+ - Konsistent mit customers Tabelle -+2. **Code bereinigt:** -+ - Explizites `created_at` aus Batch-INSERT entfernt -+ - Datenbank setzt nun automatisch den Zeitstempel bei ALLEN Lizenzen -+ -+**Änderungen:** -+- `init.sql`: created_at Spalte zur licenses Tabelle mit DEFAULT-Wert -+- `init.sql`: Migration für bestehende Datenbanken -+- `app.py`: Entfernt explizites created_at aus batch_licenses() -+ -+**Status:** -+- ✅ Alle Lizenzen haben nun automatisch einen Erstellungszeitstempel -+- ✅ Batch-Generierung funktioniert wieder -+- ✅ Konsistente Zeitstempel für Audit-Zwecke -+ -+## 2025-06-06: Status "Deaktiviert" für manuell abgeschaltete Lizenzen -+ -+**Problem:** -+- Dashboard zeigte nur "aktiv" und "abgelaufen" als Status -+- Manuell deaktivierte Lizenzen (is_active = FALSE) wurden nicht korrekt angezeigt -+- Filter für "inactive" existierte, aber Status wurde nicht richtig berechnet -+ -+**Lösung:** -+1. **Status-Berechnung erweitert:** -+ - CASE-Statement prüft zuerst `is_active = FALSE` -+ - Status "deaktiviert" wird vor anderen Status geprüft -+ - Reihenfolge: deaktiviert → abgelaufen → läuft bald ab → aktiv -+2. **Dashboard-Statistik erweitert:** -+ - Neue Zählung für deaktivierte Lizenzen -+ - Variable `inactive_licenses` im stats Dictionary -+ -+**Änderungen:** -+- `app.py`: Dashboard - Status-Berechnung für letzte 5 Lizenzen -+- `app.py`: Lizenzübersicht - Status-Berechnung in der Hauptabfrage -+- `app.py`: Export - Status-Berechnung für CSV/Excel Export -+- `app.py`: Dashboard - Neue Statistik für deaktivierte Lizenzen -+ -+**Status:** -+- ✅ "Deaktiviert" wird korrekt als Status angezeigt -+- ✅ Dashboard zeigt Anzahl deaktivierter Lizenzen -+- ✅ Export enthält korrekten Status -+- ✅ Konsistente Status-Anzeige überall -+ -+## 2025-06-08: SSL-Sicherheit verbessert - Chrome Warnung behoben -+ -+**Problem:** -+- Chrome zeigte Warnung "Die Verbindung zu dieser Website ist nicht sicher" -+- Nginx erlaubte schwache Cipher Suites (WEAK) ohne Perfect Forward Secrecy -+- Veraltete SSL-Konfiguration mit `ssl_ciphers HIGH:!aNULL:!MD5;` -+ -+**Lösung:** -+1. **Moderne Cipher Suite Konfiguration:** -+ - Nur sichere ECDHE und DHE Cipher Suites -+ - Entfernung aller RSA-only Cipher Suites -+ - Perfect Forward Secrecy für alle Verbindungen -+2. **SSL-Optimierungen:** -+ - Session Cache aktiviert (1 Tag Timeout) -+ - OCSP Stapling für bessere Performance -+ - DH Parameters (2048 bit) für zusätzliche Sicherheit -+3. **Resolver-Konfiguration:** -+ - Google DNS Server für OCSP Stapling -+ -+**Änderungen:** -+- `v2_nginx/nginx.conf`: Komplett überarbeitete SSL-Konfiguration -+- `v2_nginx/ssl/dhparam.pem`: Neue 2048-bit DH Parameters generiert -+- `v2_nginx/Dockerfile`: COPY Befehl für dhparam.pem hinzugefügt -+ -+**Status:** -+- ✅ Nur noch sichere Cipher Suites aktiv -+- ✅ Perfect Forward Secrecy gewährleistet -+- ✅ OCSP Stapling aktiviert -+- ✅ Chrome Sicherheitswarnung behoben -+ -+**Hinweis:** Nach dem Rebuild des nginx Containers wird die Verbindung als sicher angezeigt. -+ -+## 2025-06-08: CAPTCHA-Login-Bug behoben -+ -+**Problem:** -+- Nach 2 fehlgeschlagenen Login-Versuchen wurde CAPTCHA angezeigt -+- Da keine CAPTCHA-Keys konfiguriert waren (für PoC), konnte man sich nicht mehr einloggen -+- Selbst mit korrektem Passwort war Login blockiert -+- Fehlermeldung "CAPTCHA ERFORDERLICH!" erschien immer -+ -+**Lösung:** -+1. **CAPTCHA-Prüfung nur wenn Keys vorhanden:** -+ - `recaptcha_site_key` wird vor CAPTCHA-Prüfung geprüft -+ - Wenn keine Keys konfiguriert → kein CAPTCHA-Check -+ - CAPTCHA wird nur angezeigt wenn Keys existieren -+2. **Template-Anpassungen:** -+ - login.html zeigt CAPTCHA nur wenn `recaptcha_site_key` vorhanden -+ - Kein Test-Key mehr als Fallback -+3. **Konsistente Logik:** -+ - show_captcha prüft jetzt auch ob Keys vorhanden sind -+ - Bei GET und POST Requests gleiche Logik -+ -+**Änderungen:** -+- `v2_adminpanel/app.py`: CAPTCHA-Check nur wenn `RECAPTCHA_SITE_KEY` existiert -+- `v2_adminpanel/templates/login.html`: CAPTCHA nur anzeigen wenn Keys vorhanden -+ -+**Status:** -+- ✅ Login funktioniert wieder nach 2+ Fehlversuchen -+- ✅ CAPTCHA wird nur angezeigt wenn Keys konfiguriert sind -+- ✅ Für PoC-Phase ohne CAPTCHA nutzbar -+- ✅ Produktiv-ready wenn CAPTCHA-Keys eingetragen werden -+ -+### 2025-06-08: Zeitzone auf Europe/Berlin umgestellt -+ -+**Problem:** -+- Alle Zeitstempel wurden in UTC gespeichert und angezeigt -+- Backup-Dateinamen zeigten UTC-Zeit statt deutsche Zeit -+- Verwirrung bei Zeitangaben im Admin Panel und Logs -+ -+**Lösung:** -+1. **Docker Container Zeitzone konfiguriert:** -+ - Alle Dockerfiles mit `TZ=Europe/Berlin` und tzdata Installation -+ - PostgreSQL mit `PGTZ=Europe/Berlin` für Datenbank-Zeitzone -+ - Explizite Zeitzone-Dateien in /etc/localtime und /etc/timezone -+ -+2. **Python Code angepasst:** -+ - Import von `zoneinfo.ZoneInfo` für Zeitzonenunterstützung -+ - Alle `datetime.now()` Aufrufe mit `ZoneInfo("Europe/Berlin")` -+ - `.replace(tzinfo=None)` für Kompatibilität mit timezone-unaware Timestamps -+ -+3. **PostgreSQL Konfiguration:** -+ - `SET timezone = 'Europe/Berlin';` in init.sql -+ - Umgebungsvariablen TZ und PGTZ in docker-compose.yaml -+ -+4. **docker-compose.yaml erweitert:** -+ - `TZ: Europe/Berlin` für alle Services -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/Dockerfile`: Zeitzone und tzdata hinzugefügt -+- `v2_postgres/Dockerfile`: Zeitzone und tzdata hinzugefügt -+- `v2_nginx/Dockerfile`: Zeitzone und tzdata hinzugefügt -+- `v2_lizenzserver/Dockerfile`: Zeitzone und tzdata hinzugefügt -+- `v2_adminpanel/app.py`: 14 datetime.now() Aufrufe mit Zeitzone versehen -+- `v2_adminpanel/init.sql`: PostgreSQL Zeitzone gesetzt -+- `v2/docker-compose.yaml`: TZ Environment-Variable für alle Services -+ -+**Ergebnis:** -+- ✅ Alle neuen Zeitstempel werden in deutscher Zeit (Europe/Berlin) gespeichert -+- ✅ Backup-Dateinamen zeigen korrekte deutsche Zeit -+- ✅ Admin Panel zeigt alle Zeiten in deutscher Zeitzone -+- ✅ Automatische Anpassung bei Sommer-/Winterzeit -+- ✅ Konsistente Zeitangaben über alle Komponenten -+ -+**Hinweis:** Nach diesen Änderungen müssen die Docker Container neu gebaut werden: -+```bash -+docker-compose down -+docker-compose build -+docker-compose up -d -+``` -+ -+### 2025-06-08: Zeitzone-Fix - PostgreSQL Timestamps -+ -+**Problem nach erster Implementierung:** -+- Trotz Zeitzoneneinstellung wurden Zeiten immer noch in UTC angezeigt -+- Grund: PostgreSQL Tabellen verwendeten `TIMESTAMP WITHOUT TIME ZONE` -+ -+**Zusätzliche Lösung:** -+1. **Datenbankschema angepasst:** -+ - Alle `TIMESTAMP` Spalten auf `TIMESTAMP WITH TIME ZONE` geändert -+ - Betrifft: created_at, timestamp, started_at, ended_at, last_heartbeat, etc. -+ - Migration für bestehende Datenbanken berücksichtigt -+ -+2. **SQL-Abfragen vereinfacht:** -+ - `AT TIME ZONE 'Europe/Berlin'` nicht mehr nötig -+ - PostgreSQL handhabt Zeitzonenkonvertierung automatisch -+ -+**Geänderte Datei:** -+- `v2_adminpanel/init.sql`: Alle TIMESTAMP Felder mit WITH TIME ZONE -+ -+**Wichtig:** Bei bestehenden Installationen muss die Datenbank neu initialisiert oder manuell migriert werden: -+```sql -+ALTER TABLE customers ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; -+ALTER TABLE licenses ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; -+ALTER TABLE sessions ALTER COLUMN started_at TYPE TIMESTAMP WITH TIME ZONE; -+ALTER TABLE sessions ALTER COLUMN last_heartbeat TYPE TIMESTAMP WITH TIME ZONE; -+ALTER TABLE sessions ALTER COLUMN ended_at TYPE TIMESTAMP WITH TIME ZONE; -+ALTER TABLE audit_log ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE; -+ALTER TABLE backup_history ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE; -+ALTER TABLE login_attempts ALTER COLUMN first_attempt TYPE TIMESTAMP WITH TIME ZONE; -+ALTER TABLE login_attempts ALTER COLUMN last_attempt TYPE TIMESTAMP WITH TIME ZONE; -+ALTER TABLE login_attempts ALTER COLUMN blocked_until TYPE TIMESTAMP WITH TIME ZONE; -+``` -+ -+### 2025-06-08: UI/UX Überarbeitung - Phase 1 (Navigation) -+ -+**Problem:** -+- Inkonsistente Navigation zwischen verschiedenen Seiten -+- Zu viele Navigationspunkte im Dashboard -+- Verwirrende Benutzerführung -+ -+**Lösung:** -+1. **Dashboard vereinfacht:** -+ - Nur noch 3 Buttons: Neue Lizenz, Batch-Lizenzen, Log -+ - Statistik-Karten wurden klickbar gemacht (verlinken zu jeweiligen Seiten) -+ - "Audit" wurde zu "Log" umbenannt -+ -+2. **Navigation konsistent gemacht:** -+ - Navbar-Brand "AccountForger - Admin Panel" ist jetzt klickbar und führt zum Dashboard -+ - Keine Log-Links mehr in Unterseiten -+ - Konsistente "Dashboard" Buttons in allen Unterseiten -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/templates/base.html`: Navbar-Brand klickbar gemacht -+- `v2_adminpanel/templates/dashboard.html`: Navigation reduziert, Karten klickbar -+- `v2_adminpanel/templates/*.html`: Konsistente Dashboard-Links -+ -+### 2025-06-08: UI/UX Überarbeitung - Phase 2 (Visuelle Verbesserungen) -+ -+**Implementierte Verbesserungen:** -+1. **Größere Icons in Statistik-Karten:** -+ - Icon-Größe auf 3rem erhöht -+ - Bessere visuelle Hierarchie -+ -+2. **Donut-Chart für Lizenzen:** -+ - Chart.js Integration für Lizenzstatistik -+ - Zeigt Verhältnis Aktiv/Abgelaufen -+ - UPDATE: Später wieder entfernt auf Benutzerwunsch -+ -+3. **Pulse-Effekt für aktive Sessions:** -+ - CSS-Animation für aktive Sessions -+ - Visueller Indikator für Live-Aktivität -+ -+4. **Progress-Bar für Backup-Status:** -+ - Zeigt visuell den Erfolg des letzten Backups -+ - Inkl. Dateigröße und Dauer -+ -+5. **Konsistente Farbcodierung:** -+ - CSS-Variablen für Statusfarben -+ - Globale Klassen für konsistente Darstellung -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/templates/base.html`: Globale CSS-Variablen und Statusklassen -+- `v2_adminpanel/templates/dashboard.html`: Visuelle Verbesserungen implementiert -+ -+### 2025-06-08: UI/UX Überarbeitung - Phase 3 (Tabellen-Optimierungen) -+ -+**Problem:** -+- Tabellen waren schwer zu navigieren bei vielen Einträgen -+- Keine Möglichkeit für Bulk-Operationen -+- Umständliches Kopieren von Lizenzschlüsseln -+ -+**Lösung:** -+1. **Sticky Headers:** -+ - Tabellenköpfe bleiben beim Scrollen sichtbar -+ - CSS-Klasse `.table-sticky` mit `position: sticky` -+ -+2. **Inline-Actions:** -+ - Copy-Button direkt neben Lizenzschlüsseln -+ - Toggle-Switches für Aktiv/Inaktiv-Status -+ - Visuelles Feedback bei Aktionen -+ -+3. **Bulk-Actions:** -+ - Checkboxen für Mehrfachauswahl -+ - "Select All" Funktionalität -+ - Bulk-Actions Bar mit Aktivieren/Deaktivieren/Löschen -+ - JavaScript für dynamische Anzeige -+ -+4. **API-Endpoints hinzugefügt:** -+ - `/api/license//toggle` - Toggle einzelner Lizenzstatus -+ - `/api/licenses/bulk-activate` - Mehrere Lizenzen aktivieren -+ - `/api/licenses/bulk-deactivate` - Mehrere Lizenzen deaktivieren -+ - `/api/licenses/bulk-delete` - Mehrere Lizenzen löschen -+ -+5. **Beispieldaten eingefügt:** -+ - 15 Testkunden -+ - 18 Lizenzen (verschiedene Status) -+ - Sessions, Audit-Logs, Login-Attempts -+ - Backup-Historie -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/templates/base.html`: CSS für Sticky-Tables und Bulk-Actions -+- `v2_adminpanel/templates/licenses.html`: Komplette Tabellen-Überarbeitung -+- `v2_adminpanel/app.py`: 4 neue API-Endpoints für Toggle und Bulk-Operationen -+- `v2_adminpanel/sample_data.sql`: Umfangreiche Testdaten erstellt -+ -+**Bugfix:** -+- API-Endpoints versuchten `updated_at` zu setzen, obwohl die Spalte nicht existiert -+- Entfernt aus allen 3 betroffenen Endpoints -+ -+**Status:** -+- ✅ Sticky Headers funktionieren -+- ✅ Copy-Buttons mit Clipboard-API -+- ✅ Toggle-Switches ändern Lizenzstatus -+- ✅ Bulk-Operationen vollständig implementiert -+- ✅ Testdaten erfolgreich eingefügt -+ -+### 2025-06-08: UI/UX Überarbeitung - Phase 4 (Sortierbare Tabellen) -+ -+**Problem:** -+- Keine Möglichkeit, Tabellen nach verschiedenen Spalten zu sortieren -+- Besonders bei großen Datenmengen schwer zu navigieren -+ -+**Lösung - Hybrid-Ansatz:** -+1. **Client-seitige Sortierung für kleine Tabellen:** -+ - Dashboard (3 kleine Übersichtstabellen) -+ - Blocked IPs (typischerweise wenige Einträge) -+ - Backups (begrenzte Anzahl) -+ - JavaScript-basierte Sortierung ohne Reload -+ -+2. **Server-seitige Sortierung für große Tabellen:** -+ - Licenses (potenziell tausende Einträge) -+ - Customers (viele Kunden möglich) -+ - Audit Log (wächst kontinuierlich) -+ - Sessions (viele aktive/beendete Sessions) -+ - URL-Parameter für Sortierung mit SQL ORDER BY -+ -+**Implementierung:** -+1. **Client-seitige Sortierung:** -+ - Generische JavaScript-Funktion in base.html -+ - CSS-Klasse `.sortable-table` für betroffene Tabellen -+ - Sortier-Indikatoren (↑↓↕) bei Hover/Active -+ - Unterstützung für Text, Zahlen und deutsche Datumsformate -+ -+2. **Server-seitige Sortierung:** -+ - Query-Parameter `sort` und `order` in Routes -+ - Whitelist für erlaubte Sortierfelder (SQL-Injection-Schutz) -+ - Makro-Funktionen für sortierbare Header -+ - Sortier-Parameter in Pagination-Links erhalten -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/templates/base.html`: CSS und JavaScript für Sortierung -+- `v2_adminpanel/templates/dashboard.html`: Client-seitige Sortierung -+- `v2_adminpanel/templates/blocked_ips.html`: Client-seitige Sortierung -+- `v2_adminpanel/templates/backups.html`: Client-seitige Sortierung -+- `v2_adminpanel/templates/licenses.html`: Server-seitige Sortierung -+- `v2_adminpanel/templates/customers.html`: Server-seitige Sortierung -+- `v2_adminpanel/templates/audit_log.html`: Server-seitige Sortierung -+- `v2_adminpanel/templates/sessions.html`: Server-seitige Sortierung (2 Tabellen) -+- `v2_adminpanel/app.py`: 4 Routes erweitert für Sortierung -+ -+**Besonderheiten:** -+- Sessions-Seite hat zwei unabhängige Tabellen mit eigenen Sortierparametern -+- Intelligente Datentyp-Erkennung (numeric, date) für korrekte Sortierung -+- Visuelle Sortier-Indikatoren zeigen aktuelle Sortierung -+- Alle anderen Filter und Suchparameter bleiben bei Sortierung erhalten -+ -+**Status:** -+- ✅ Client-seitige Sortierung für kleine Tabellen -+- ✅ Server-seitige Sortierung für große Tabellen -+- ✅ Sortier-Indikatoren und visuelle Rückmeldung -+- ✅ SQL-Injection-Schutz durch Whitelisting -+- ✅ Vollständige Integration mit bestehenden Features -+ -+### 2025-06-08: Bugfix - Sortierlogik korrigiert -+ -+**Problem:** -+- Sortierung funktionierte nicht korrekt -+- Beim Klick auf Spaltenköpfe wurde immer absteigend sortiert -+- Toggle zwischen ASC/DESC funktionierte nicht -+ -+**Ursachen:** -+1. **Falsche Bedingungslogik**: Die ursprüngliche Implementierung verwendete eine fehlerhafte Ternär-Bedingung -+2. **Berechnete Felder**: Das 'status' Feld in der Lizenztabelle konnte nicht direkt sortiert werden -+ -+**Lösung:** -+1. **Sortierlogik korrigiert:** -+ - Bei neuer Spalte: Immer aufsteigend (ASC) beginnen -+ - Bei gleicher Spalte: Toggle zwischen ASC und DESC -+ - Implementiert durch bedingte Links in den Makros -+ -+2. **Spezialbehandlung für berechnete Felder:** -+ - Status-Feld verwendet CASE-Statement in ORDER BY -+ - Wiederholt die gleiche Logik wie im SELECT -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/templates/licenses.html`: Sortierlogik korrigiert -+- `v2_adminpanel/templates/customers.html`: Sortierlogik korrigiert -+- `v2_adminpanel/templates/audit_log.html`: Sortierlogik korrigiert -+- `v2_adminpanel/templates/sessions.html`: Sortierlogik für beide Tabellen korrigiert -+- `v2_adminpanel/app.py`: Spezialbehandlung für Status-Feld in licenses Route -+ -+**Verhalten nach Fix:** -+- ✅ Erster Klick auf Spalte: Aufsteigend sortieren -+- ✅ Zweiter Klick: Absteigend sortieren -+- ✅ Weitere Klicks: Toggle zwischen ASC/DESC -+- ✅ Sortierung funktioniert korrekt mit Pagination und Filtern -+ -+### 2025-06-09: Port 8443 geschlossen - API nur noch über Nginx -+ -+**Problem:** -+- Doppelte Verfügbarkeit des License Servers (Port 8443 + Nginx) machte keinen Sinn -+- Inkonsistente Sicherheitskonfiguration (Nginx hatte Security Headers, Port 8443 nicht) -+- Doppelte SSL-Konfiguration nötig -+- Verwirrung welcher Zugangsweg genutzt werden soll -+ -+**Lösung:** -+- Port-Mapping für License Server in docker-compose.yaml entfernt -+- API nur noch über Nginx erreichbar: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com -+- Interne Kommunikation zwischen Nginx und License Server bleibt bestehen -+ -+**Vorteile:** -+- ✅ Einheitliche Sicherheitskonfiguration (Security Headers, HSTS) -+- ✅ Zentrale SSL-Verwaltung nur in Nginx -+- ✅ Möglichkeit für Rate Limiting und zentrales Logging -+- ✅ Keine zusätzlichen offenen Ports (nur 80/443) -+- ✅ Professionellere API-URL ohne Port-Angabe -+ -+**Geänderte Dateien:** -+- `v2/docker-compose.yaml`: Port-Mapping "8443:8443" entfernt -+ -+**Hinweis für Client-Software:** -+- API-Endpunkte sind weiterhin unter https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com erreichbar -+- Keine Änderung der API-URLs nötig, nur Port 8443 ist nicht mehr direkt zugänglich -+ -+**Status:** -+- ✅ Port 8443 geschlossen -+- ✅ API nur noch über Nginx Reverse Proxy erreichbar -+- ✅ Sicherheit erhöht durch zentrale Verwaltung -+ -+### 2025-06-09: Live-Filtering implementiert -+ -+**Problem:** -+- Benutzer mussten immer auf "Filter anwenden" klicken -+- Umständliche Bedienung, besonders bei mehreren Filterkriterien -+- Nicht zeitgemäße User Experience -+ -+**Lösung:** -+- JavaScript Event-Listener für automatisches Filtern -+- Text-Eingaben: 300ms Debouncing (verzögerte Suche nach Tipp-Pause) -+- Dropdowns: Sofortiges Filtern bei Änderung -+- "Filter anwenden" Button entfernt, nur "Zurücksetzen" bleibt -+ -+**Implementierte Live-Filter:** -+1. **Lizenzübersicht** (licenses.html): -+ - Suchfeld mit Debouncing -+ - Typ-Dropdown (Vollversion/Testversion) -+ - Status-Dropdown (Aktiv/Ablaufend/Abgelaufen/Deaktiviert) -+ -+2. **Kundenübersicht** (customers.html): -+ - Suchfeld mit Debouncing -+ - "Suchen" Button entfernt -+ -+3. **Audit-Log** (audit_log.html): -+ - Benutzer-Textfeld mit Debouncing -+ - Aktion-Dropdown -+ - Entität-Dropdown -+ -+**Technische Details:** -+- `addEventListener('input')` für Textfelder -+- `addEventListener('change')` für Select-Elemente -+- `setTimeout()` mit 300ms für Debouncing -+- Automatisches `form.submit()` bei Änderungen -+ -+**Vorteile:** -+- ✅ Schnellere und intuitivere Bedienung -+- ✅ Weniger Klicks erforderlich -+- ✅ Moderne User Experience -+- ✅ Besonders hilfreich bei komplexen Filterkriterien -+ -+**Status:** -+- ✅ Live-Filtering auf allen Hauptseiten implementiert -+- ✅ Debouncing verhindert zu viele Server-Requests -+- ✅ Zurücksetzen-Button bleibt für schnelles Löschen aller Filter -+ -+### 2025-06-09: Resource Pool System implementiert (Phase 1 & 2) -+ -+**Ziel:** -+Ein Pool-System für Domains, IPv4-Adressen und Telefonnummern, wobei bei jeder Lizenzerstellung 1-10 Ressourcen pro Typ zugewiesen werden. Ressourcen haben 3 Status: available, allocated, quarantine. -+ -+**Phase 1 - Datenbank-Schema (✅ Abgeschlossen):** -+1. **Neue Tabellen erstellt:** -+ - `resource_pools` - Haupttabelle für alle Ressourcen -+ - `resource_history` - Vollständige Historie aller Aktionen -+ - `resource_metrics` - Performance-Tracking und ROI-Berechnung -+ - `license_resources` - Zuordnung zwischen Lizenzen und Ressourcen -+ -+2. **Erweiterte licenses Tabelle:** -+ - `domain_count`, `ipv4_count`, `phone_count` Spalten hinzugefügt -+ - Constraints: 0-10 pro Resource-Typ -+ -+3. **Indizes für Performance:** -+ - Status, Type, Allocated License, Quarantine Date -+ -+**Phase 2 - Backend-Implementierung (✅ Abgeschlossen):** -+1. **Resource Management Routes:** -+ - `/resources` - Hauptübersicht mit Statistiken -+ - `/resources/add` - Bulk-Import von Ressourcen -+ - `/resources/quarantine/` - Ressourcen sperren -+ - `/resources/release` - Quarantäne aufheben -+ - `/resources/history/` - Komplette Historie -+ - `/resources/metrics` - Performance Dashboard -+ - `/resources/report` - Report-Generator -+ -+2. **API-Endpunkte:** -+ - `/api/resources/allocate` - Ressourcen-Zuweisung -+ - `/api/resources/check-availability` - Verfügbarkeit prüfen -+ -+3. **Integration in Lizenzerstellung:** -+ - `create_license()` erweitert um Resource-Allocation -+ - `batch_licenses()` mit Ressourcen-Prüfung für gesamten Batch -+ - Transaktionale Sicherheit bei Zuweisung -+ -+4. **Dashboard-Integration:** -+ - Resource-Statistiken in Dashboard eingebaut -+ - Warning-Level basierend auf Verfügbarkeit -+ -+5. **Navigation erweitert:** -+ - Resources-Link in Navbar hinzugefügt -+ -+**Was noch zu tun ist:** -+ -+### Phase 3 - UI-Komponenten (🔄 Ausstehend): -+1. **Templates erstellen:** -+ - `resources.html` - Hauptübersicht mit Drag&Drop -+ - `add_resources.html` - Formular für Bulk-Import -+ - `resource_history.html` - Historie-Anzeige -+ - `resource_metrics.html` - Performance Dashboard -+ -+2. **Formulare erweitern:** -+ - `index.html` - Resource-Dropdowns hinzufügen -+ - `batch_form.html` - Resource-Dropdowns hinzufügen -+ -+3. **Dashboard-Widget:** -+ - Resource Pool Statistik mit Ampelsystem -+ - Warnung bei niedrigem Bestand -+ -+### Phase 4 - Erweiterte Features (🔄 Ausstehend): -+1. **Quarantäne-Workflow:** -+ - Gründe: abuse, defect, maintenance, blacklisted, expired -+ - Automatische Tests vor Freigabe -+ - Genehmigungsprozess -+ -+2. **Performance-Metrics:** -+ - Täglicher Cronjob für Metriken -+ - ROI-Berechnung -+ - Issue-Tracking -+ -+3. **Report-Generator:** -+ - Auslastungsreport -+ - Performance-Report -+ - Compliance-Report -+ -+### Phase 5 - Backup erweitern (🔄 Ausstehend): -+- Neue Tabellen in Backup einbeziehen: -+ - resource_pools -+ - resource_history -+ - resource_metrics -+ - license_resources -+ -+### Phase 6 - Testing & Migration (🔄 Ausstehend): -+1. **Test-Daten generieren:** -+ - 500 Test-Domains -+ - 200 Test-IPs -+ - 100 Test-Telefonnummern -+ -+2. **Migrations-Script:** -+ - Bestehende Lizenzen auf default resource_count setzen -+ -+### Phase 7 - Dokumentation (🔄 Ausstehend): -+- API-Dokumentation für License Server -+- Admin-Handbuch für Resource Management -+ -+**Technische Details:** -+- 3-Status-System: available/allocated/quarantine -+- Transaktionale Ressourcen-Zuweisung mit FOR UPDATE Lock -+- Vollständige Historie mit IP-Tracking -+- Drag&Drop UI für Resource-Management geplant -+- Automatische Warnung bei < 50 verfügbaren Ressourcen -+ -+**Status:** -+- ✅ Datenbank-Schema komplett -+- ✅ Backend-Routen implementiert -+- ✅ Integration in Lizenzerstellung -+- ❌ UI-Templates fehlen noch -+- ❌ Erweiterte Features ausstehend -+- ❌ Testing und Migration offen -+ -+### 2025-06-09: Resource Pool System UI-Implementierung (Phase 3 & 4) -+ -+**Phase 3 - UI-Komponenten (✅ Abgeschlossen):** -+ -+1. **Neue Templates erstellt:** -+ - `resources.html` - Hauptübersicht mit Statistiken, Filter, Live-Suche, Pagination -+ - `add_resources.html` - Bulk-Import Formular mit Validierung -+ - `resource_history.html` - Timeline-Ansicht der Historie mit Details -+ - `resource_metrics.html` - Performance Dashboard mit Charts -+ - `resource_report.html` - Report-Generator UI -+ -+2. **Erweiterte Formulare:** -+ - `index.html` - Resource-Count Dropdowns (0-10) mit Live-Verfügbarkeitsprüfung -+ - `batch_form.html` - Resource-Count mit Batch-Berechnung (zeigt Gesamtbedarf) -+ -+3. **Dashboard-Widget:** -+ - Resource Pool Statistik mit Ampelsystem implementiert -+ - Zeigt verfügbare/zugeteilte/quarantäne Ressourcen -+ - Warnung bei niedrigem Bestand (<50) -+ - Fortschrittsbalken für visuelle Darstellung -+ -+4. **Backend-Anpassungen:** -+ - `resource_history` Route korrigiert für Object-Style Template-Zugriff -+ - `resources_metrics` Route vollständig implementiert mit Charts-Daten -+ - `resources_report` Route erweitert für Template-Anzeige und Downloads -+ - Dashboard erweitert um Resource-Statistiken -+ -+**Phase 4 - Erweiterte Features (✅ Teilweise):** -+1. **Report-Generator:** -+ - Template für Report-Auswahl erstellt -+ - 4 Report-Typen: Usage, Performance, Compliance, Inventory -+ - Export als Excel, CSV oder PDF-Vorschau -+ - Zeitraum-Auswahl mit Validierung -+ -+**Technische Details der Implementierung:** -+- Live-Filtering ohne Reload durch JavaScript -+- AJAX-basierte Verfügbarkeitsprüfung -+- Bootstrap 5 für konsistentes Design -+- Chart.js für Metriken-Visualisierung -+- Responsives Design für alle Templates -+- Copy-to-Clipboard für Resource-Werte -+- Modal-Dialoge für Quarantäne-Aktionen -+ -+**Was noch fehlt:** -+ -+### Phase 5 - Backup erweitern (🔄 Ausstehend): -+- Resource-Tabellen in pg_dump einbeziehen: -+ - resource_pools -+ - resource_history -+ - resource_metrics -+ - license_resources -+ -+### Phase 6 - Testing & Migration (🔄 Ausstehend): -+1. **Test-Daten generieren:** -+ - Script für 500 Test-Domains -+ - 200 Test-IPv4-Adressen -+ - 100 Test-Telefonnummern -+ - Realistische Verteilung über Status -+ -+2. **Migrations-Script:** -+ - Bestehende Lizenzen auf Default resource_count setzen -+ - UPDATE licenses SET domain_count=1, ipv4_count=1, phone_count=1 WHERE ... -+ -+### Phase 7 - Dokumentation (🔄 Ausstehend): -+- API-Dokumentation für Resource-Endpunkte -+- Admin-Handbuch für Resource Management -+- Troubleshooting-Guide -+ -+**Offene Punkte für Produktion:** -+1. Drag&Drop für Resource-Verwaltung (Nice-to-have) -+2. Automatische Quarantäne-Aufhebung nach Zeitablauf -+3. E-Mail-Benachrichtigungen bei niedrigem Bestand -+4. API für externe Resource-Prüfung -+5. Bulk-Delete für Ressourcen -+6. Resource-Import aus CSV/Excel -+ -+### 2025-06-09: Resource Pool System finalisiert -+ -+**Problem:** -+- Resource Pool System war nur teilweise implementiert -+- UI-Templates waren vorhanden, aber nicht dokumentiert -+- Test-Daten und Migration fehlten -+- Backup-Integration unklar -+ -+**Analyse und Lösung:** -+1. **Status-Überprüfung durchgeführt:** -+ - Alle 5 UI-Templates existierten bereits (resources.html, add_resources.html, etc.) -+ - Resource-Dropdowns waren bereits in index.html und batch_form.html integriert -+ - Dashboard-Widget war bereits implementiert -+ - Backup-System inkludiert bereits alle Tabellen (pg_dump ohne -t Parameter) -+ -+2. **Fehlende Komponenten erstellt:** -+ - Test-Daten Script: `test_data_resources.sql` -+ - 500 Test-Domains (400 verfügbar, 50 zugeteilt, 50 in Quarantäne) -+ - 200 Test-IPv4-Adressen (150 verfügbar, 30 zugeteilt, 20 in Quarantäne) -+ - 100 Test-Telefonnummern (70 verfügbar, 20 zugeteilt, 10 in Quarantäne) -+ - Resource History und Metrics für realistische Daten -+ -+ - Migration Script: `migrate_existing_licenses.sql` -+ - Setzt Default Resource Counts (Vollversion: 2, Testversion: 1, Inaktiv: 0) -+ - Weist automatisch verfügbare Ressourcen zu -+ - Erstellt Audit-Log Einträge -+ - Gibt detaillierten Migrationsbericht aus -+ -+**Neue Dateien:** -+- `v2_adminpanel/test_data_resources.sql` - Testdaten für Resource Pool -+- `v2_adminpanel/migrate_existing_licenses.sql` - Migration für bestehende Lizenzen -+ -+**Status:** -+- ✅ Resource Pool System vollständig implementiert und dokumentiert -+- ✅ Alle UI-Komponenten vorhanden und funktionsfähig -+- ✅ Integration in Lizenz-Formulare abgeschlossen -+- ✅ Dashboard-Widget zeigt Resource-Statistiken -+- ✅ Backup-System inkludiert Resource-Tabellen -+- ✅ Test-Daten und Migration bereitgestellt -+ -+**Nächste Schritte:** -+1. Test-Daten einspielen: `psql -U adminuser -d meinedatenbank -f test_data_resources.sql` -+2. Migration ausführen: `psql -U adminuser -d meinedatenbank -f migrate_existing_licenses.sql` -+3. License Server API implementieren (Hauptaufgabe) -+ -+### 2025-06-09: Bugfix - Resource Pool Tabellen fehlten -+ -+**Problem:** -+- Admin Panel zeigte "Internal Server Error" -+- Dashboard Route versuchte auf `resource_pools` Tabelle zuzugreifen -+- Tabelle existierte nicht in der Datenbank -+ -+**Ursache:** -+- Bei bereits existierender Datenbank wird init.sql nicht erneut ausgeführt -+- Resource Pool Tabellen wurden erst später zum init.sql hinzugefügt -+- Docker Container verwendeten noch die alte Datenbankstruktur -+ -+**Lösung:** -+1. Separates Script `create_resource_tables.sql` erstellt -+2. Script manuell in der Datenbank ausgeführt -+3. Alle 4 Resource-Tabellen erfolgreich erstellt: -+ - resource_pools -+ - resource_history -+ - resource_metrics -+ - license_resources -+ -+**Status:** -+- ✅ Admin Panel funktioniert wieder -+- ✅ Dashboard zeigt Resource Pool Statistiken -+- ✅ Alle Resource-Funktionen verfügbar -+ -+**Empfehlung für Neuinstallationen:** -+- Bei frischer Installation funktioniert alles automatisch -+- Bei bestehenden Installationen: `create_resource_tables.sql` ausführen -+ -+### 2025-06-09: Navigation vereinfacht -+ -+**Änderung:** -+- Navigationspunkte aus der schwarzen Navbar entfernt -+- Links zu Lizenzen, Kunden, Ressourcen, Sessions, Backups und Log entfernt -+ -+**Grund:** -+- Cleaner Look mit nur Logo, Timer und Logout -+- Alle Funktionen sind weiterhin über das Dashboard erreichbar -+- Bessere Übersichtlichkeit und weniger Ablenkung -+ -+**Geänderte Datei:** -+- `v2_adminpanel/templates/base.html` - Navbar-Links auskommentiert -+ -+**Status:** -+- ✅ Navbar zeigt nur noch Logo, Session-Timer und Logout -+- ✅ Navigation erfolgt über Dashboard und Buttons auf den jeweiligen Seiten -+- ✅ Alle Funktionen bleiben erreichbar -+ -+### 2025-06-09: Bugfix - Resource Report Einrückungsfehler -+ -+**Problem:** -+- Resource Report Route zeigte "Internal Server Error" -+- UnboundLocalError: `report_type` wurde verwendet bevor es definiert war -+ -+**Ursache:** -+- Fehlerhafte Einrückung in der `resources_report()` Funktion -+- `elif` und `else` Blöcke waren falsch eingerückt -+- Variablen wurden außerhalb ihres Gültigkeitsbereichs verwendet -+ -+**Lösung:** -+- Korrekte Einrückung für alle Conditional-Blöcke wiederhergestellt -+- Alle Report-Typen (usage, performance, compliance, inventory) richtig strukturiert -+- Excel und CSV Export-Code korrekt eingerückt -+ -+**Geänderte Datei:** -+- `v2_adminpanel/app.py` - resources_report() Funktion korrigiert -+ -+**Status:** -+- ✅ Resource Report funktioniert wieder -+- ✅ Alle 4 Report-Typen verfügbar -+- ✅ Export als Excel und CSV möglich -+ -+--- -+ -+## Zusammenfassung der heutigen Arbeiten (2025-06-09) -+ -+### 1. Resource Pool System Finalisierung -+- **Ausgangslage**: Resource Pool war nur teilweise dokumentiert -+- **Überraschung**: UI-Templates waren bereits vorhanden (nicht dokumentiert) -+- **Ergänzt**: -+ - Test-Daten Script (`test_data_resources.sql`) -+ - Migration Script (`migrate_existing_licenses.sql`) -+- **Status**: ✅ Vollständig implementiert -+ -+### 2. Database Migration Bug -+- **Problem**: Admin Panel zeigte "Internal Server Error" -+- **Ursache**: Resource Pool Tabellen fehlten in bestehender DB -+- **Lösung**: Separates Script `create_resource_tables.sql` erstellt -+- **Status**: ✅ Behoben -+ -+### 3. UI Cleanup -+- **Änderung**: Navigation aus Navbar entfernt -+- **Effekt**: Cleaner Look, Navigation nur über Dashboard -+- **Status**: ✅ Implementiert -+ -+### 4. Resource Report Bug -+- **Problem**: Einrückungsfehler in `resources_report()` Funktion -+- **Lösung**: Korrekte Einrückung wiederhergestellt -+- **Status**: ✅ Behoben -+ -+### Neue Dateien erstellt heute: -+1. `v2_adminpanel/test_data_resources.sql` - 800 Test-Ressourcen -+ -+### 2025-06-09: Bugfix - Resource Quarantäne Modal -+ -+**Problem:** -+- Quarantäne-Button funktionierte nicht -+- Modal öffnete sich nicht beim Klick -+ -+**Ursache:** -+- Bootstrap 5 vs Bootstrap 4 API-Inkompatibilität -+- Modal wurde mit Bootstrap 4 Syntax (`modal.modal('show')`) aufgerufen -+- jQuery wurde nach Bootstrap geladen -+ -+**Lösung:** -+1. **JavaScript angepasst:** -+ - Von jQuery Modal-API zu nativer Bootstrap 5 Modal-API gewechselt -+ - `new bootstrap.Modal(element).show()` statt `$(element).modal('show')` -+ -+2. **HTML-Struktur aktualisiert:** -+ - Modal-Close-Button: `data-bs-dismiss="modal"` statt `data-dismiss="modal"` -+ - `btn-close` Klasse statt custom close button -+ - Form-Klassen: `mb-3` statt `form-group`, `form-select` statt `form-control` für Select -+ -+3. **Script-Reihenfolge korrigiert:** -+ - jQuery vor Bootstrap laden für korrekte Initialisierung -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/templates/resources.html` -+- `v2_adminpanel/templates/base.html` -+ -+**Status:** ✅ Behoben -+ -+### 2025-06-09: Resource Pool UI Redesign -+ -+**Ziel:** -+- Komplettes Redesign des Resource Pool Managements für bessere Benutzerfreundlichkeit -+- Konsistentes Design mit dem Rest der Anwendung -+ -+**Durchgeführte Änderungen:** -+ -+1. **resources.html - Hauptübersicht:** -+ - Moderne Statistik-Karten mit Hover-Effekten -+ - Farbcodierte Progress-Bars mit Tooltips -+ - Verbesserte Tabelle mit Icons und Status-Badges -+ - Live-Filter mit sofortiger Suche -+ - Überarbeitete Quarantäne-Modal für Bootstrap 5 -+ - Responsive Design mit Grid-Layout -+ -+2. **add_resources.html - Ressourcen hinzufügen:** -+ - 3-Schritt Wizard-ähnliches Interface -+ - Visueller Ressourcentyp-Selector mit Icons -+ - Live-Validierung mit Echtzeit-Feedback -+ - Statistik-Anzeige (Gültig/Duplikate/Ungültig) -+ - Formatierte Beispiele mit Erklärungen -+ - Verbesserte Fehlerbehandlung -+ -+3. **resource_history.html - Historie:** -+ - Zentrierte Resource-Anzeige mit großen Icons -+ - Info-Grid Layout für Details -+ - Modernisierte Timeline mit Hover-Effekten -+ - Farbcodierte Action-Icons -+ - Verbesserte Darstellung von Details -+ -+4. **resource_metrics.html - Metriken:** -+ - Dashboard-Style Metrik-Karten mit Icon-Badges -+ - Modernisierte Charts mit besseren Farben -+ - Performance-Tabellen mit Progress-Bars -+ - Trend-Indikatoren für Performance -+ - Responsives Grid-Layout -+ -+**Design-Verbesserungen:** -+- Konsistente Emoji-Icons für bessere visuelle Kommunikation -+- Einheitliche Farbgebung (Blau/Lila/Grün für Ressourcentypen) -+- Card-basiertes Layout mit Schatten und Hover-Effekten -+- Bootstrap 5 kompatible Komponenten -+- Verbesserte Typografie und Spacing -+ -+**Technische Details:** -+- Bootstrap 5 Modal-API statt jQuery -+- CSS Grid für responsive Layouts -+- Moderne Chart.js Konfiguration -+- Optimierte JavaScript-Validierung -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/templates/resources.html` -+- `v2_adminpanel/templates/add_resources.html` -+- `v2_adminpanel/templates/resource_history.html` -+- `v2_adminpanel/templates/resource_metrics.html` -+ -+**Status:** ✅ Abgeschlossen -+ -+### 2025-06-09: Zusammenfassung der heutigen Arbeiten -+ -+**Durchgeführte Aufgaben:** -+ -+1. **Quarantäne-Funktion repariert:** -+ - Bootstrap 5 Modal-API implementiert -+ - data-bs-dismiss statt data-dismiss -+ - jQuery vor Bootstrap laden -+ -+2. **Resource Pool UI komplett überarbeitet:** -+ - Alle 4 Templates modernisiert (resources, add_resources, resource_history, resource_metrics) -+ - Konsistentes Design mit Emoji-Icons -+ - Einheitliche Farbgebung (Blau/Lila/Grün) -+ - Bootstrap 5 kompatible Komponenten -+ - Responsive Grid-Layouts -+ -+**Aktuelle Projekt-Status:** -+- ✅ Admin Panel voll funktionsfähig -+- ✅ Resource Pool Management mit modernem UI -+- ✅ PostgreSQL mit allen Tabellen -+- ✅ Nginx Reverse Proxy mit SSL -+- ❌ Lizenzserver noch nicht implementiert (nur Platzhalter) -+ -+**Nächste Schritte:** -+- Lizenzserver implementieren -+- API-Endpunkte für Lizenzvalidierung -+- Heartbeat-System für Sessions -+- Versionsprüfung implementieren -+1. `v2_adminpanel/templates/base.html` - Navigation entfernt -+2. `v2_adminpanel/app.py` - Resource Report Einrückung korrigiert -+3. `JOURNAL.md` - Alle Änderungen dokumentiert -+ -+### Offene Hauptaufgabe: -+- **License Server API** - Noch komplett zu implementieren -+ - `/api/version` - Versionscheck -+ - `/api/validate` - Lizenzvalidierung -+ - `/api/heartbeat` - Session-Management -+ -+### 2025-06-09: Resource Pool Internal Error behoben -+ -+**Problem:** -+- Internal Server Error beim Zugriff auf `/resources` -+- NameError: name 'datetime' is not defined in Template -+ -+**Ursache:** -+- Fehlende `datetime` und `timedelta` Objekte im Template-Kontext -+- Falsche Array-Indizes in resources.html für activity-Daten -+ -+**Lösung:** -+1. **app.py (Zeile 2797-2798):** -+ - `datetime=datetime` und `timedelta=timedelta` zu render_template hinzugefügt -+ -+2. **resources.html (Zeile 484-490):** -+ - Array-Indizes korrigiert: -+ - activity[0] = action -+ - activity[1] = action_by -+ - activity[2] = action_at -+ - activity[3] = resource_type -+ - activity[4] = resource_value -+ - activity[5] = details -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/app.py` -+- `v2_adminpanel/templates/resources.html` -+ -+**Status:** ✅ Behoben - Resource Pool funktioniert wieder einwandfrei -+ -+### 2025-06-09: Passwort-Änderung und 2FA implementiert -+ -+**Ziel:** -+- Benutzer können ihr Passwort ändern -+- Zwei-Faktor-Authentifizierung (2FA) mit TOTP -+- Komplett kostenlose Lösung ohne externe Services -+ -+**Implementierte Features:** -+ -+1. **Datenbank-Erweiterung:** -+ - Neue `users` Tabelle mit Passwort-Hash und 2FA-Feldern -+ - Unterstützung für TOTP-Secrets und Backup-Codes -+ - Migration von Environment-Variablen zu Datenbank -+ -+2. **Passwort-Management:** -+ - Sichere Passwort-Hashes mit bcrypt -+ - Passwort-Änderung mit Verifikation des alten Passworts -+ - Passwort-Stärke-Indikator im Frontend -+ -+3. **2FA-Implementation:** -+ - TOTP-basierte 2FA (Google Authenticator, Authy kompatibel) -+ - QR-Code-Generierung für einfaches Setup -+ - 8 Backup-Codes für Notfallzugriff -+ - Backup-Codes als Textdatei downloadbar -+ -+4. **Neue Routen:** -+ - `/profile` - Benutzerprofil mit Passwort und 2FA-Verwaltung -+ - `/verify-2fa` - 2FA-Verifizierung beim Login -+ - `/profile/setup-2fa` - 2FA-Einrichtung mit QR-Code -+ - `/profile/enable-2fa` - 2FA-Aktivierung -+ - `/profile/disable-2fa` - 2FA-Deaktivierung -+ - `/profile/change-password` - Passwort ändern -+ -+5. **Sicherheits-Features:** -+ - Fallback zu Environment-Variablen für Rückwärtskompatibilität -+ - Session-Management für 2FA-Verifizierung -+ - Fehlgeschlagene 2FA-Versuche werden protokolliert -+ - Verwendete Backup-Codes werden entfernt -+ -+**Verwendete Libraries (alle kostenlos):** -+- `bcrypt` - Passwort-Hashing -+- `pyotp` - TOTP-Generierung und Verifizierung -+- `qrcode[pil]` - QR-Code-Generierung -+ -+**Migration:** -+- Script `migrate_users.py` erstellt für Migration existierender Benutzer -+- Erhält bestehende Credentials aus Environment-Variablen -+- Erstellt Datenbank-Einträge mit gehashten Passwörtern -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/init.sql` - Users-Tabelle hinzugefügt -+- `v2_adminpanel/requirements.txt` - Neue Dependencies -+- `v2_adminpanel/app.py` - Auth-Funktionen und neue Routen -+- `v2_adminpanel/migrate_users.py` - Migrations-Script (neu) -+- `v2_adminpanel/templates/base.html` - Profil-Link hinzugefügt -+- `v2_adminpanel/templates/profile.html` - Profil-Seite (neu) -+- `v2_adminpanel/templates/verify_2fa.html` - 2FA-Verifizierung (neu) -+- `v2_adminpanel/templates/setup_2fa.html` - 2FA-Setup (neu) -+- `v2_adminpanel/templates/backup_codes.html` - Backup-Codes Anzeige (neu) -+ -+**Status:** ✅ Vollständig implementiert -+ -+### 2025-06-09: Internal Server Error behoben und UI-Design angepasst -+ -+### 2025-06-09: Journal-Bereinigung und Projekt-Cleanup -+ -+**Durchgeführte Aufgaben:** -+ -+1. **Überflüssige SQL-Dateien gelöscht:** -+ - `create_resource_tables.sql` - War nur für Migrations nötig -+ - `migrate_existing_licenses.sql` - Keine alten Installationen vorhanden -+ - `sample_data.sql` - Testdaten nicht mehr benötigt -+ - `test_data_resources.sql` - Testdaten nicht mehr benötigt -+ -+2. **Journal aktualisiert:** -+ - Veraltete Todo-Liste korrigiert (viele Features bereits implementiert) -+ - Passwörter aus Zugangsdaten entfernt (Sicherheit) -+ - "Bekannte Probleme" auf aktuellen Stand gebracht -+ - Neuer Abschnitt "Best Practices für Produktiv-Migration" hinzugefügt -+ -+3. **Status-Klärungen:** -+ - Alle Daten sind Testdaten (PoC-Phase) -+ - 2FA ist implementiert und funktionsfähig -+ - Resource Pool System ist vollständig implementiert -+ - Port 8443 ist geschlossen (nur über Nginx erreichbar) -+ -+**Noch zu erledigen:** -+- Nginx Config anpassen (proxy_pass von https:// auf http://) -+- License Server API implementieren (Hauptaufgabe) -+ -+**Problem:** -+- Internal Server Error nach Login wegen fehlender `users` Tabelle -+- UI-Design der neuen 2FA-Seiten passte nicht zum Rest der Anwendung -+ -+**Lösung:** -+ -+1. **Datenbank-Fix:** -+ - Users-Tabelle wurde nicht automatisch erstellt -+ - Manuell mit SQL-Script nachgeholt -+ - Migration erfolgreich durchgeführt -+ - Beide Admin-User (rac00n, w@rh@mm3r) migriert -+ -+2. **UI-Design Überarbeitung:** -+ - Profile-Seite im Dashboard-Stil mit Cards und Hover-Effekten -+ - 2FA-Setup mit nummerierten Schritten und modernem Card-Design -+ - Backup-Codes Seite mit Animation und verbessertem Layout -+ - Konsistente Farbgebung und Icons -+ - Verbesserte Benutzerführung mit visuellen Hinweisen -+ -+**Design-Features:** -+- Card-basiertes Layout mit Schatten-Effekten -+- Hover-Animationen für bessere Interaktivität -+- Farbcodierte Sicherheitsstatus-Anzeigen -+- Passwort-Stärke-Indikator mit visueller Rückmeldung -+- Responsive Design für alle Bildschirmgrößen -+- Print-optimiertes Layout für Backup-Codes -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/create_users_table.sql` - SQL für Users-Tabelle (temporär) -+ -+### 2025-06-09: Journal-Umstrukturierung -+ -+**Durchgeführte Änderungen:** -+ -+1. **Dokumentation aufgeteilt:** -+ - `JOURNAL.md` - Enthält nur noch chronologische Änderungen (wie ein Tagebuch) -+ - `THE_ROAD_SO_FAR.md` - Neues Dokument mit aktuellem Status und Roadmap -+ -+2. **THE_ROAD_SO_FAR.md erstellt mit:** -+ - Aktueller Status (was läuft bereits) -+ - Nächste Schritte (Priorität Hoch) -+ - Offene Aufgaben (Priorität Mittel) -+ - Nice-to-have Features -+ - Bekannte Probleme -+ - Deployment-Hinweise -+ -+3. **JOURNAL.md bereinigt:** -+ - Todo-Listen entfernt (jetzt in THE_ROAD_SO_FAR.md) -+ - Nur noch chronologische Einträge -+ - Fokus auf "Was wurde gemacht" statt "Was muss gemacht werden" -+ -+**Vorteile der Aufteilung:** -+- Journal bleibt übersichtlich und wächst linear -+- Status und Todos sind immer aktuell an einem Ort -+- Klare Trennung zwischen Historie und Planung -+- Einfacher für neue Entwickler einzusteigen -+ -+### 2025-06-09: Nginx Config angepasst -+ -+**Änderung:** -+- proxy_pass für License Server von `https://license-server:8443` auf `http://license-server:8443` geändert -+- `proxy_ssl_verify off` entfernt (nicht mehr nötig bei HTTP) -+- WebSocket-Support hinzugefügt (für zukünftige Features) -+ -+**Grund:** -+- License Server läuft intern auf HTTP (wie Admin Panel) -+- SSL-Termination erfolgt nur am Nginx -+- Vereinfachte Konfiguration ohne doppelte SSL-Verschlüsselung -+ -+**Hinweis:** -+Docker-Container müssen neu gestartet werden, damit die Änderung wirksam wird: -+```bash -+docker-compose down -+docker-compose up -d -+``` -+- `v2_adminpanel/templates/profile.html` - Komplett überarbeitet -+- `v2_adminpanel/templates/setup_2fa.html` - Neues Step-by-Step Design -+- `v2_adminpanel/templates/backup_codes.html` - Modernisiertes Layout -+ -+**Status:** ✅ Abgeschlossen - Login funktioniert, UI im konsistenten Design -+ -+### 2025-06-09: Lizenzschlüssel-Format geändert -+ -+**Änderung:** -+- Altes Format: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` (z.B. AF-202506F-V55Y-9DWE-GL5G) -+- Neues Format: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` (z.B. AF-F-202506-V55Y-9DWE-GL5G) -+ -+**Vorteile:** -+- Klarere Struktur mit separatem Typ-Indikator -+- Einfacher zu lesen und zu verstehen -+- Typ (F/T) sofort im zweiten Block erkennbar -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/app.py`: -+ - `generate_license_key()` - Generiert Keys im neuen Format -+ - `validate_license_key()` - Validiert Keys mit neuem Regex-Pattern -+- `v2_adminpanel/templates/index.html`: -+ - Placeholder und Pattern für Input-Feld angepasst -+ - JavaScript charAt() Position für Typ-Prüfung korrigiert -+- `v2_adminpanel/templates/batch_form.html`: -+ - Vorschau-Format für Batch-Generierung angepasst -+ -+**Hinweis:** Alte Keys im bisherigen Format bleiben ungültig. Bei Bedarf könnte eine Migration oder Dual-Support implementiert werden. -+ -+**Status:** ✅ Implementiert -+ -+### 2025-06-09: Datenbank-Migration der Lizenzschlüssel -+ -+**Durchgeführt:** -+- Alle bestehenden Lizenzschlüssel in der Datenbank auf das neue Format migriert -+- 18 Lizenzschlüssel erfolgreich konvertiert (16 Full, 2 Test) -+ -+**Migration:** -+- Von: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` -+- Nach: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` -+ -+**Beispiele:** -+- Alt: `AF-202506F-V55Y-9DWE-GL5G` -+- Neu: `AF-F-202506-V55Y-9DWE-GL5G` -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/migrate_license_keys.sql` - Migrations-Script (temporär) -+- `v2_adminpanel/fix_license_keys.sql` - Korrektur-Script (temporär) -+ -+**Status:** ✅ Alle Lizenzschlüssel erfolgreich migriert -+ -+### 2025-06-09: Kombinierte Kunden-Lizenz-Ansicht implementiert -+ -+**Problem:** -+- Umständliche Navigation zwischen Kunden- und Lizenzseiten -+- Viel Hin-und-Her-Springen bei der Verwaltung -+- Kontext-Verlust beim Wechseln zwischen Ansichten -+ -+**Lösung:** -+Master-Detail View mit 2-Spalten Layout implementiert -+ -+**Phase 1-3 abgeschlossen:** -+1. **Backend-Implementierung:** -+ - Neue Route `/customers-licenses` für kombinierte Ansicht -+ - API-Endpoints für AJAX: `/api/customer//licenses`, `/api/customer//quick-stats` -+ - API-Endpoint `/api/license//quick-edit` für Inline-Bearbeitung -+ - Optimierte SQL-Queries mit JOIN für Performance -+ -+2. **Template-Erstellung:** -+ - Neues Template `customers_licenses.html` mit Master-Detail Layout -+ - Links: Kundenliste (30%) mit Suchfeld -+ - Rechts: Lizenzen des ausgewählten Kunden (70%) -+ - Responsive Design (Mobile: untereinander) -+ - JavaScript für dynamisches Laden ohne Seitenreload -+ - Keyboard-Navigation (↑↓ für Kundenwechsel) -+ -+3. **Integration:** -+ - Dashboard: Neuer Button "Kunden & Lizenzen" -+ - Customers-Seite: Link zur kombinierten Ansicht -+ - Licenses-Seite: Link zur kombinierten Ansicht -+ - Lizenz-Erstellung: Unterstützung für vorausgewählten Kunden -+ - API /api/customers erweitert für Einzelabruf per ID -+ -+**Features:** -+- Live-Suche in Kundenliste -+- Quick-Actions: Copy License Key, Toggle Status -+- Modal für neue Lizenz direkt aus Kundenansicht -+- URL-Update ohne Reload für Bookmarking -+- Loading-States während AJAX-Calls -+- Visuelles Feedback (aktiver Kunde hervorgehoben) -+ -+**Noch ausstehend:** -+- Phase 4: Inline-Edit für Lizenzdetails -+- Phase 5: Erweiterte Error-Handling und Polish -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/app.py` - Neue Routen und API-Endpoints -+- `v2_adminpanel/templates/customers_licenses.html` - Neues Template -+- `v2_adminpanel/templates/dashboard.html` - Neuer Button -+- `v2_adminpanel/templates/customers.html` - Link zur kombinierten Ansicht -+- `v2_adminpanel/templates/licenses.html` - Link zur kombinierten Ansicht -+- `v2_adminpanel/templates/index.html` - Unterstützung für preselected_customer_id -+ -+**Status:** ✅ Grundfunktionalität implementiert und funktionsfähig -+ -+### 2025-06-09: Kombinierte Ansicht - Fertigstellung und TODOs aktualisiert -+ -+**Abgeschlossen:** -+- Phase 1-3 der kombinierten Kunden-Lizenz-Ansicht vollständig implementiert -+- Master-Detail Layout funktioniert einwandfrei -+- AJAX-basiertes Laden ohne Seitenreload -+- Keyboard-Navigation mit Pfeiltasten -+- Quick-Actions für Copy und Toggle Status -+- Integration in alle relevanten Seiten -+ -+**THE_ROAD_SO_FAR.md aktualisiert:** -+- Kombinierte Ansicht als "Erledigt" markiert -+- Von "In Arbeit" zu "Abgeschlossen" verschoben -+- Status dokumentiert -+ -+**Verbesserung gegenüber vorher:** -+- Kein Hin-und-Her-Springen mehr zwischen Seiten -+- Kontext bleibt erhalten beim Arbeiten mit Kunden -+- Schnellere Navigation und bessere Übersicht -+- Deutlich verbesserte User Experience -+ -+**Optional für später (Phase 4-5):** -+- Inline-Edit für weitere Felder -+- Erweiterte Quick-Actions -+- Session-basierte Filter-Persistenz -+ -+Die Hauptproblematik der umständlichen Navigation ist damit gelöst! -+ -+### 2025-06-09: Test-Flag für Lizenzen implementiert -+ -+**Ziel:** -+- Klare Trennung zwischen Testdaten und echten Produktivdaten -+- Testdaten sollen von der Software ignoriert werden können -+- Bessere Übersicht im Admin Panel -+ -+**Durchgeführte Änderungen:** -+ -+1. **Datenbank-Schema (init.sql):** -+ - Neue Spalte `is_test BOOLEAN DEFAULT FALSE` zur `licenses` Tabelle hinzugefügt -+ - Migration für bestehende Daten: Alle werden als `is_test = TRUE` markiert -+ - Index `idx_licenses_is_test` für bessere Performance -+ -+2. **Backend (app.py):** -+ - Dashboard-Queries filtern Testdaten mit `WHERE is_test = FALSE` aus -+ - Lizenz-Erstellung: Neues Checkbox-Feld für Test-Markierung -+ - Lizenz-Bearbeitung: Test-Status kann geändert werden -+ - Export: Optional mit/ohne Testdaten (`?include_test=true`) -+ - Bulk-Operationen: Nur auf Live-Daten anwendbar -+ - Neue Filter in Lizenzliste: "🧪 Testdaten" und "🚀 Live-Daten" -+ -+3. **Frontend Templates:** -+ - **index.html**: Checkbox "Als Testdaten markieren" bei Lizenzerstellung -+ - **edit_license.html**: Checkbox zum Ändern des Test-Status -+ - **licenses.html**: Badge 🧪 für Testdaten, neue Filteroptionen -+ - **dashboard.html**: Info-Box zeigt Anzahl der Testdaten -+ - **batch_form.html**: Option für Batch-Test-Lizenzen -+ -+4. **Audit-Log Integration:** -+ - `is_test` Feld wird bei CREATE/UPDATE geloggt -+ - Nachvollziehbarkeit von Test/Live-Status-Änderungen -+ -+**Technische Details:** -+- Testdaten werden in allen Statistiken ausgefiltert -+- License Server API wird Lizenzen mit `is_test = TRUE` ignorieren -+- Resource Pool bleibt unverändert (kann Test- und Live-Ressourcen verwalten) -+ -+**Migration der bestehenden Daten:** -+```sql -+UPDATE licenses SET is_test = TRUE; -- Alle aktuellen Daten sind Testdaten -+``` -+ -+**Status:** ✅ Implementiert -+ -+### 2025-06-09: Test-Flag für Kunden und Resource Pools erweitert -+ -+**Ziel:** -+- Konsistentes Test-Daten-Management über alle Entitäten -+- Kunden und Resource Pools können ebenfalls als Testdaten markiert werden -+- Automatische Verknüpfung: Test-Kunde → Test-Lizenzen → Test-Ressourcen -+ -+**Durchgeführte Änderungen:** -+ -+1. **Datenbank-Schema erweitert:** -+ - `customers.is_test BOOLEAN DEFAULT FALSE` hinzugefügt -+ - `resource_pools.is_test BOOLEAN DEFAULT FALSE` hinzugefügt -+ - Indizes für bessere Performance erstellt -+ - Migrations in init.sql integriert -+ -+2. **Backend (app.py) - Erweiterte Logik:** -+ - Dashboard: Separate Zähler für Test-Kunden und Test-Ressourcen -+ - Kunde-Erstellung: Erbt Test-Status von Lizenz -+ - Test-Kunde erzwingt Test-Lizenzen -+ - Resource-Zuweisung: Test-Lizenzen bekommen nur Test-Ressourcen -+ - Customer-Management mit is_test Filter -+ -+3. **Frontend Updates:** -+ - **customers.html**: 🧪 Badge für Test-Kunden -+ - **edit_customer.html**: Checkbox für Test-Status -+ - **dashboard.html**: Erweiterte Test-Statistik (Lizenzen, Kunden, Ressourcen) -+ -+4. **Geschäftslogik:** -+ - Wenn neuer Kunde bei Test-Lizenz erstellt wird → automatisch Test-Kunde -+ - Wenn Test-Kunde gewählt wird → Lizenz automatisch als Test markiert -+ - Resource Pool Allocation prüft Test-Status für korrekte Zuweisung -+ -+**Migration der bestehenden Daten:** -+```sql -+UPDATE customers SET is_test = TRUE; -- 5 Kunden -+UPDATE resource_pools SET is_test = TRUE; -- 20 Ressourcen -+``` -+ -+**Technische Details:** -+- Konsistente Test/Live-Trennung über alle Ebenen -+- Dashboard-Statistiken zeigen nur Live-Daten -+- Test-Ressourcen werden nur Test-Lizenzen zugewiesen -+- Alle bestehenden Daten sind jetzt als Test markiert -+ -+**Status:** ✅ Vollständig implementiert -+ -+### 2025-06-09 (17:20 - 18:13): Kunden-Lizenz-Verwaltung konsolidiert -+ -+**Problem:** -+- Kombinierte Ansicht `/customers-licenses` hatte Formatierungs- und Funktionsprobleme -+- Kunden wurden nicht angezeigt -+- Bootstrap Icons fehlten -+- JavaScript-Fehler beim Modal -+- Inkonsistentes Design im Vergleich zu anderen Seiten -+- Testkunden-Filter wurde beim Navigieren nicht beibehalten -+ -+**Durchgeführte Änderungen:** -+ -+1. **Frontend-Fixes (base.html):** -+ - Bootstrap Icons CSS hinzugefügt: `bootstrap-icons@1.11.3/font/bootstrap-icons.min.css` -+ - Bootstrap JavaScript Bundle bereits vorhanden, Reihenfolge optimiert -+ -+2. **customers_licenses.html komplett überarbeitet:** -+ - Container-Klasse von `container-fluid` auf `container py-5` geändert -+ - Emojis und Button-Styling vereinheitlicht (👥 Kunden & Lizenzen) -+ - Export-Dropdown wie in anderen Ansichten implementiert -+ - Card-Styling mit Schatten für einheitliches Design -+ - Checkbox "Testkunden anzeigen" mit Status-Beibehaltung -+ - JavaScript-Funktionen korrigiert: -+ - copyToClipboard mit event.currentTarget -+ - showNewLicenseModal mit Bootstrap Modal -+ - Header-Update beim AJAX-Kundenwechsel -+ - URL-Parameter `show_test` wird überall beibehalten -+ -+3. **Backend-Anpassungen (app.py):** -+ - customers_licenses Route: Optional Testkunden anzeigen mit `show_test` Parameter -+ - Redirects von `/customers` und `/licenses` auf `/customers-licenses` implementiert -+ - Alte Route-Funktionen entfernt (kein toter Code mehr) -+ - edit_license und edit_customer: Redirects behalten show_test Parameter bei -+ - Dashboard-Links zeigen jetzt auf kombinierte Ansicht -+ -+4. **Navigation optimiert:** -+ - Dashboard: Klick auf Kunden/Lizenzen-Statistik führt zur kombinierten Ansicht -+ - Alle Edit-Links behalten den show_test Parameter bei -+ - Konsistente User Experience beim Navigieren -+ -+**Technische Details:** -+- AJAX-Loading für dynamisches Laden der Lizenzen -+- Keyboard-Navigation (↑↓) für Kundenliste -+- Responsive Design mit Bootstrap Grid -+- Modal-Dialoge für Bestätigungen -+- Live-Suche in der Kundenliste -+ -+**Resultat:** -+- ✅ Einheitliches Design mit anderen Admin-Panel-Seiten -+- ✅ Alle Funktionen arbeiten korrekt -+- ✅ Testkunden-Filter bleibt erhalten -+- ✅ Keine redundanten Views mehr -+- ✅ Zentrale Verwaltung für Kunden und Lizenzen -+ -+**Status:** ✅ Vollständig implementiert -+ -+### 2025-06-09: Test-Daten Checkbox Persistenz implementiert -+ -+**Problem:** -+- Die "Testkunden anzeigen" Checkbox in `/customers-licenses` verlor ihren Status beim Navigieren zwischen Seiten -+- Wenn Benutzer zu anderen Seiten (Resources, Audit Log, etc.) wechselten und zurückkehrten, war die Checkbox wieder deaktiviert -+- Benutzer mussten die Checkbox jedes Mal neu aktivieren, was umständlich war -+ -+**Lösung:** -+- Globale JavaScript-Funktion `preserveShowTestParameter()` in base.html implementiert -+- Die Funktion prüft beim Laden jeder Seite, ob `show_test=true` in der URL ist -+- Wenn ja, wird dieser Parameter automatisch an alle internen Links angehängt -+- Backend-Route `/create` wurde angepasst, um den Parameter bei Redirects beizubehalten -+ -+**Technische Details:** -+1. **base.html** - JavaScript-Funktion hinzugefügt: -+ - Läuft beim `DOMContentLoaded` Event -+ - Findet alle Links die mit "/" beginnen -+ - Fügt `show_test=true` Parameter hinzu wenn nicht bereits vorhanden -+ - Überspringt Fragment-Links (#) und Links die bereits den Parameter haben -+ -+2. **app.py** - Route-Anpassung: -+ - `/create` Route behält jetzt `show_test` Parameter bei Redirects bei -+ - Andere Routen (edit_license, edit_customer) behalten Parameter bereits bei -+ -+**Vorteile:** -+- ✅ Konsistente User Experience beim Navigieren -+- ✅ Keine manuelle Anpassung aller Links nötig -+- ✅ Funktioniert automatisch für alle zukünftigen Links -+- ✅ Minimaler Code-Overhead -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/templates/base.html` -+- `v2_adminpanel/app.py` -+ -+**Status:** ✅ Vollständig implementiert -+ -+### 2025-06-09: Bearbeiten-Button Fehler behoben -+ -+**Problem:** -+- Der "Bearbeiten" Button neben dem Kundennamen in der `/customers-licenses` Ansicht verursachte einen Internal Server Error -+- Die URL-Konstruktion war fehlerhaft wenn kein `show_test` Parameter vorhanden war -+- Die edit_customer.html Template hatte falsche Array-Indizes und veraltete Links -+ -+**Ursache:** -+1. Die href-Attribute wurden falsch konstruiert: -+ - Alt: `/customer/edit/ID{% if show_test %}?ref=customers-licenses&show_test=true{% endif %}` -+ - Problem: Ohne show_test fehlte das `?ref=customers-licenses` komplett -+ -+2. Die SQL-Abfrage in edit_customer() holte nur 4 Felder, aber das Template erwartete 5: -+ - Query: `SELECT id, name, email, is_test` -+ - Template erwartete: `customer[3]` = created_at und `customer[4]` = is_test -+ -+3. Veraltete Links zu `/customers` statt `/customers-licenses` -+ -+**Lösung:** -+1. URL-Konstruktion korrigiert in beiden Fällen: -+ - Neu: `/customer/edit/ID?ref=customers-licenses{% if show_test %}&show_test=true{% endif %}` -+ -+2. SQL-Query erweitert um created_at: -+ - Neu: `SELECT id, name, email, created_at, is_test` -+ -+3. Template-Indizes korrigiert: -+ - is_test Checkbox nutzt jetzt `customer[4]` -+ -+4. Navigation-Links aktualisiert: -+ - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/templates/customers_licenses.html` (Zeilen 103 und 295) -+- `v2_adminpanel/app.py` (edit_customer Route) -+- `v2_adminpanel/templates/edit_customer.html` -+ -+**Status:** ✅ Behoben -+ -+### 2025-06-09: Unnötigen Lizenz-Erstellungs-Popup entfernt -+ -+**Änderung:** -+- Der Bestätigungs-Popup "Möchten Sie eine neue Lizenz für KUNDENNAME erstellen?" wurde entfernt -+- Klick auf "Neue Lizenz" Button führt jetzt direkt zur Lizenzerstellung -+ -+**Technische Details:** -+- Modal-HTML komplett entfernt -+- `showNewLicenseModal()` Funktion vereinfacht - navigiert jetzt direkt zu `/create?customer_id=X` -+- URL-Parameter (wie `show_test`) werden dabei beibehalten -+ -+**Vorteile:** -+- ✅ Ein Klick weniger für Benutzer -+- ✅ Schnellerer Workflow -+- ✅ Weniger Code zu warten -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/templates/customers_licenses.html` -+ -+**Status:** ✅ Implementiert -+ -+### 2025-06-09: Testkunden-Checkbox bleibt jetzt bei Lizenz/Kunden-Bearbeitung erhalten -+ -+**Problem:** -+- Bei "Lizenz bearbeiten" ging der "Testkunden anzeigen" Haken verloren beim Zurückkehren -+- Die Navigation-Links in edit_license.html zeigten auf `/licenses` statt `/customers-licenses` -+- Der show_test Parameter wurde nur über den unsicheren Referrer übertragen -+ -+**Lösung:** -+1. **Navigation-Links korrigiert**: -+ - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter -+ - Betrifft: "Zurück zur Übersicht" und "Abbrechen" Buttons -+ -+2. **Hidden Form Field hinzugefügt**: -+ - Sowohl in edit_license.html als auch edit_customer.html -+ - Überträgt den show_test Parameter sicher beim POST -+ -+3. **Route-Logik verbessert**: -+ - Parameter wird aus Form-Daten ODER GET-Parametern gelesen -+ - Nicht mehr auf unsicheren Referrer angewiesen -+ - Funktioniert sowohl bei Speichern als auch Abbrechen -+ -+**Technische Details:** -+- Templates prüfen `request.args.get('show_test')` für Navigation -+- Hidden Input: `` -+- Routes: `show_test = request.form.get('show_test') or request.args.get('show_test')` -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/templates/edit_license.html` -+- `v2_adminpanel/templates/edit_customer.html` -+- `v2_adminpanel/app.py` (edit_license und edit_customer Routen) -+ -+**Status:** ✅ Vollständig implementiert -+ -+### 2025-06-09 22:02: Konsistente Sortierung bei Status-Toggle -+ -+**Problem:** -+- Beim Klicken auf den An/Aus-Knopf (Status-Toggle) in der Kunden & Lizenzen Ansicht änderte sich die Reihenfolge der Lizenzen -+- Dies war verwirrend für Benutzer, da die Position der gerade bearbeiteten Lizenz springen konnte -+ -+**Ursache:** -+- Die Sortierung `ORDER BY l.created_at DESC` war nicht stabil genug -+- Bei gleichem Erstellungszeitpunkt konnte die Datenbank die Reihenfolge inkonsistent zurückgeben -+ -+**Lösung:** -+- Sekundäres Sortierkriterium hinzugefügt: `ORDER BY l.created_at DESC, l.id DESC` -+- Dies stellt sicher, dass bei gleichem Erstellungsdatum nach ID sortiert wird -+- Die Reihenfolge bleibt jetzt konsistent, auch nach Status-Änderungen -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/app.py`: -+ - Zeile 2278: `/customers-licenses` Route -+ - Zeile 2319: `/api/customer//licenses` API-Route -+ -+### 2025-06-10 00:01: Verbesserte Integration zwischen Kunden & Lizenzen und Resource Pool -+ -+**Problem:** -+- Umständliche Navigation zwischen Kunden & Lizenzen und Resource Pool Bereichen -+- Keine direkte Verbindung zwischen beiden Ansichten -+- Benutzer mussten ständig zwischen verschiedenen Seiten hin- und herspringen -+ -+**Implementierte Lösung - 5 Phasen:** -+ -+1. **Phase 1: Ressourcen-Details in Kunden & Lizenzen Ansicht** -+ - API `/api/customer/{id}/licenses` erweitert um konkrete Ressourcen-Informationen -+ - Neue API `/api/license/{id}/resources` für detaillierte Ressourcen einer Lizenz -+ - Anzeige der zugewiesenen Ressourcen mit Info-Buttons und Modal-Dialogen -+ - Klickbare Links zu Ressourcen-Details im Resource Pool -+ -+2. **Phase 2: Quick-Actions für Ressourcenverwaltung** -+ - "Ressourcen verwalten" Button (Zahnrad-Icon) bei jeder Lizenz -+ - Modal mit Übersicht aller zugewiesenen Ressourcen -+ - Vorbereitung für Quarantäne-Funktionen und Ressourcen-Austausch -+ -+3. **Phase 3: Ressourcen-Preview bei Lizenzerstellung** -+ - Live-Anzeige verfügbarer Ressourcen beim Ändern der Anzahl -+ - Erweiterte Verfügbarkeitsanzeige mit Badges (OK/Niedrig/Kritisch) -+ - Warnungen bei niedrigem Bestand mit visuellen Hinweisen -+ - Fortschrittsbalken zur Visualisierung der Verfügbarkeit -+ -+4. **Phase 4: Dashboard-Integration** -+ - Resource Pool Widget mit erweiterten Links -+ - Kritische Warnungen bei < 50 Ressourcen mit "Auffüllen" Button -+ - Direkte Navigation zu gefilterten Ansichten (nach Typ/Status) -+ - Verbesserte visuelle Darstellung mit Tooltips -+ -+5. **Phase 5: Bidirektionale Navigation** -+ - Von Resource Pool: Links zu Kunden/Lizenzen bei zugewiesenen Ressourcen -+ - "Zurück zu Kunden" Button wenn von Kunden & Lizenzen kommend -+ - Navigation-Links im Dashboard für schnellen Zugriff -+ - SQL-Query erweitert um customer_id für direkte Verlinkung -+ -+**Technische Details:** -+- JavaScript-Funktionen für Modal-Dialoge und Ressourcen-Details -+- Erweiterte SQL-Queries mit JOINs für Ressourcen-Informationen -+- Bootstrap 5 Tooltips und Modals für bessere UX -+- Globale Variable `currentLicenses` für Caching der Lizenzdaten -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/app.py` - Neue APIs und erweiterte Queries -+- `v2_adminpanel/templates/customers_licenses.html` - Ressourcen-Details und Modals -+- `v2_adminpanel/templates/index.html` - Erweiterte Verfügbarkeitsanzeige -+- `v2_adminpanel/templates/dashboard.html` - Verbesserte Resource Pool Integration -+- `v2_adminpanel/templates/resources.html` - Bidirektionale Navigation -+ -+**Status:** ✅ Alle 5 Phasen erfolgreich implementiert -+ -+### 2025-06-10 00:15: IP-Adressen-Erfassung hinter Reverse Proxy korrigiert -+ -+**Problem:** -+- Flask-App erfasste nur die Docker-interne IP-Adresse von Nginx (172.19.0.5) -+- Echte Client-IPs wurden nicht in Audit-Logs und Login-Attempts gespeichert -+- Nginx setzte die Header korrekt, aber Flask las sie nicht aus -+ -+**Ursache:** -+- Flask verwendet standardmäßig nur `request.remote_addr` -+- Dies gibt bei einem Reverse Proxy nur die Proxy-IP zurück -+- Die Header `X-Real-IP` und `X-Forwarded-For` wurden ignoriert -+ -+**Lösung:** -+1. **ProxyFix Middleware** hinzugefügt für korrekte Header-Verarbeitung -+2. **get_client_ip() Funktion** angepasst: -+ - Prüft zuerst `X-Real-IP` Header -+ - Dann `X-Forwarded-For` Header (nimmt erste IP bei mehreren) -+ - Fallback auf `request.remote_addr` -+3. **Debug-Logging** für IP-Erfassung hinzugefügt -+4. **Alle `request.remote_addr` Aufrufe** durch `get_client_ip()` ersetzt -+ -+**Technische Details:** -+```python -+# ProxyFix für korrekte IP-Adressen -+app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) -+ -+# Verbesserte IP-Erfassung -+def get_client_ip(): -+ if request.headers.get('X-Real-IP'): -+ return request.headers.get('X-Real-IP') -+ elif request.headers.get('X-Forwarded-For'): -+ return request.headers.get('X-Forwarded-For').split(',')[0].strip() -+ else: -+ return request.remote_addr -+``` -+ -+**Geänderte Dateien:** -+- `v2_adminpanel/app.py` - ProxyFix und verbesserte IP-Erfassung -+ -+**Status:** ✅ Implementiert - Neue Aktionen erfassen jetzt echte Client-IPs -+ -+### 2025-06-10 00:30: Docker ENV Legacy-Format Warnungen behoben -+ -+**Problem:** -+- Docker Build zeigte Warnungen: "LegacyKeyValueFormat: ENV key=value should be used" -+- Veraltetes Format `ENV KEY VALUE` wurde in Dockerfiles verwendet -+ -+**Lösung:** -+- Alle ENV-Anweisungen auf neues Format `ENV KEY=VALUE` umgestellt -+- Betraf hauptsächlich v2_postgres/Dockerfile mit 3 ENV-Zeilen -+ -+**Geänderte Dateien:** -+- `v2_postgres/Dockerfile` - ENV-Format modernisiert -+ -+**Beispiel der Änderung:** -+```dockerfile -+# Alt (Legacy): -+ENV LANG de_DE.UTF-8 -+ENV LANGUAGE de_DE:de -+ -+# Neu (Modern): -+ENV LANG=de_DE.UTF-8 -+ENV LANGUAGE=de_DE:de -+``` -+ -+**Status:** ✅ Alle Dockerfiles verwenden jetzt das moderne ENV-Format -+ - **Status:** ✅ Behoben -\ No newline at end of file -diff --git a/v2/.env b/v2/.env -index 3a27e3f..e834125 100644 ---- a/v2/.env -+++ b/v2/.env -@@ -1,56 +1,56 @@ --# PostgreSQL-Datenbank --POSTGRES_DB=meinedatenbank --POSTGRES_USER=adminuser --POSTGRES_PASSWORD=supergeheimespasswort -- --# Admin-Panel Zugangsdaten --ADMIN1_USERNAME=rac00n --ADMIN1_PASSWORD=1248163264 --ADMIN2_USERNAME=w@rh@mm3r --ADMIN2_PASSWORD=Warhammer123! -- --# Lizenzserver API Key für Authentifizierung -- -- --# Domains (können von der App ausgewertet werden, z. B. für Links oder CORS) --API_DOMAIN=api-software-undso.z5m7q9dk3ah2v1plx6ju.com --ADMIN_PANEL_DOMAIN=admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com -- --# ===================== OPTIONALE VARIABLEN ===================== -- --# JWT für API-Auth --# JWT_SECRET=geheimer_token_schlüssel -- --# E-Mail Konfiguration (z. B. bei Ablaufwarnungen) --# MAIL_SERVER=smtp.meinedomain.de --# MAIL_PORT=587 --# MAIL_USERNAME=deinemail --# MAIL_PASSWORD=geheim --# MAIL_FROM=no-reply@meinedomain.de -- --# Logging --# LOG_LEVEL=info -- --# Erlaubte CORS-Domains (für Web-Frontend) --# ALLOWED_ORIGINS=https://admin.meinedomain.de -- --# ===================== VERSION ===================== -- --# Serverseitig gepflegte aktuelle Software-Version --# Diese wird vom Lizenzserver genutzt, um die Kundenversion zu vergleichen --LATEST_CLIENT_VERSION=1.0.0 -- --# ===================== BACKUP KONFIGURATION ===================== -- --# E-Mail für Backup-Benachrichtigungen --EMAIL_ENABLED=false -- --# Backup-Verschlüsselung (optional, wird automatisch generiert wenn leer) --# BACKUP_ENCRYPTION_KEY= -- --# ===================== CAPTCHA KONFIGURATION ===================== -- --# Google reCAPTCHA v2 Keys (https://www.google.com/recaptcha/admin) --# Für PoC-Phase auskommentiert - CAPTCHA wird übersprungen wenn Keys fehlen --# RECAPTCHA_SITE_KEY=your-site-key-here --# RECAPTCHA_SECRET_KEY=your-secret-key-here -+# PostgreSQL-Datenbank -+POSTGRES_DB=meinedatenbank -+POSTGRES_USER=adminuser -+POSTGRES_PASSWORD=supergeheimespasswort -+ -+# Admin-Panel Zugangsdaten -+ADMIN1_USERNAME=rac00n -+ADMIN1_PASSWORD=1248163264 -+ADMIN2_USERNAME=w@rh@mm3r -+ADMIN2_PASSWORD=Warhammer123! -+ -+# Lizenzserver API Key für Authentifizierung -+ -+ -+# Domains (können von der App ausgewertet werden, z. B. für Links oder CORS) -+API_DOMAIN=api-software-undso.z5m7q9dk3ah2v1plx6ju.com -+ADMIN_PANEL_DOMAIN=admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com -+ -+# ===================== OPTIONALE VARIABLEN ===================== -+ -+# JWT für API-Auth -+# JWT_SECRET=geheimer_token_schlüssel -+ -+# E-Mail Konfiguration (z. B. bei Ablaufwarnungen) -+# MAIL_SERVER=smtp.meinedomain.de -+# MAIL_PORT=587 -+# MAIL_USERNAME=deinemail -+# MAIL_PASSWORD=geheim -+# MAIL_FROM=no-reply@meinedomain.de -+ -+# Logging -+# LOG_LEVEL=info -+ -+# Erlaubte CORS-Domains (für Web-Frontend) -+# ALLOWED_ORIGINS=https://admin.meinedomain.de -+ -+# ===================== VERSION ===================== -+ -+# Serverseitig gepflegte aktuelle Software-Version -+# Diese wird vom Lizenzserver genutzt, um die Kundenversion zu vergleichen -+LATEST_CLIENT_VERSION=1.0.0 -+ -+# ===================== BACKUP KONFIGURATION ===================== -+ -+# E-Mail für Backup-Benachrichtigungen -+EMAIL_ENABLED=false -+ -+# Backup-Verschlüsselung (optional, wird automatisch generiert wenn leer) -+# BACKUP_ENCRYPTION_KEY= -+ -+# ===================== CAPTCHA KONFIGURATION ===================== -+ -+# Google reCAPTCHA v2 Keys (https://www.google.com/recaptcha/admin) -+# Für PoC-Phase auskommentiert - CAPTCHA wird übersprungen wenn Keys fehlen -+# RECAPTCHA_SITE_KEY=your-site-key-here -+# RECAPTCHA_SECRET_KEY=your-secret-key-here -diff --git a/v2/docker-compose.yaml b/v2/docker-compose.yaml -index 5ab8942..fe2acee 100644 ---- a/v2/docker-compose.yaml -+++ b/v2/docker-compose.yaml -@@ -1,90 +1,90 @@ --services: -- postgres: -- build: -- context: ../v2_postgres -- container_name: db -- restart: always -- env_file: .env -- environment: -- POSTGRES_HOST: postgres -- POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=de_DE.UTF-8' -- POSTGRES_COLLATE: 'de_DE.UTF-8' -- POSTGRES_CTYPE: 'de_DE.UTF-8' -- TZ: Europe/Berlin -- PGTZ: Europe/Berlin -- volumes: -- # Persistente Speicherung der Datenbank auf dem Windows-Host -- - postgres_data:/var/lib/postgresql/data -- # Init-Skript für Tabellen -- - ../v2_adminpanel/init.sql:/docker-entrypoint-initdb.d/init.sql -- networks: -- - internal_net -- deploy: -- resources: -- limits: -- cpus: '2' -- memory: 4g -- -- license-server: -- build: -- context: ../v2_lizenzserver -- container_name: license-server -- restart: always -- # Port-Mapping entfernt - nur noch über Nginx erreichbar -- env_file: .env -- environment: -- TZ: Europe/Berlin -- depends_on: -- - postgres -- networks: -- - internal_net -- deploy: -- resources: -- limits: -- cpus: '2' -- memory: 4g -- -- admin-panel: -- build: -- context: ../v2_adminpanel -- container_name: admin-panel -- restart: always -- # Port-Mapping entfernt - nur über nginx erreichbar -- env_file: .env -- environment: -- TZ: Europe/Berlin -- depends_on: -- - postgres -- networks: -- - internal_net -- volumes: -- # Backup-Verzeichnis -- - ../backups:/app/backups -- deploy: -- resources: -- limits: -- cpus: '2' -- memory: 4g -- -- nginx: -- build: -- context: ../v2_nginx -- container_name: nginx-proxy -- restart: always -- ports: -- - "80:80" -- - "443:443" -- environment: -- TZ: Europe/Berlin -- depends_on: -- - admin-panel -- - license-server -- networks: -- - internal_net -- --networks: -- internal_net: -- driver: bridge -- --volumes: -- postgres_data: -+services: -+ postgres: -+ build: -+ context: ../v2_postgres -+ container_name: db -+ restart: always -+ env_file: .env -+ environment: -+ POSTGRES_HOST: postgres -+ POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=de_DE.UTF-8' -+ POSTGRES_COLLATE: 'de_DE.UTF-8' -+ POSTGRES_CTYPE: 'de_DE.UTF-8' -+ TZ: Europe/Berlin -+ PGTZ: Europe/Berlin -+ volumes: -+ # Persistente Speicherung der Datenbank auf dem Windows-Host -+ - postgres_data:/var/lib/postgresql/data -+ # Init-Skript für Tabellen -+ - ../v2_adminpanel/init.sql:/docker-entrypoint-initdb.d/init.sql -+ networks: -+ - internal_net -+ deploy: -+ resources: -+ limits: -+ cpus: '2' -+ memory: 4g -+ -+ license-server: -+ build: -+ context: ../v2_lizenzserver -+ container_name: license-server -+ restart: always -+ # Port-Mapping entfernt - nur noch über Nginx erreichbar -+ env_file: .env -+ environment: -+ TZ: Europe/Berlin -+ depends_on: -+ - postgres -+ networks: -+ - internal_net -+ deploy: -+ resources: -+ limits: -+ cpus: '2' -+ memory: 4g -+ -+ admin-panel: -+ build: -+ context: ../v2_adminpanel -+ container_name: admin-panel -+ restart: always -+ # Port-Mapping entfernt - nur über nginx erreichbar -+ env_file: .env -+ environment: -+ TZ: Europe/Berlin -+ depends_on: -+ - postgres -+ networks: -+ - internal_net -+ volumes: -+ # Backup-Verzeichnis -+ - ../backups:/app/backups -+ deploy: -+ resources: -+ limits: -+ cpus: '2' -+ memory: 4g -+ -+ nginx: -+ build: -+ context: ../v2_nginx -+ container_name: nginx-proxy -+ restart: always -+ ports: -+ - "80:80" -+ - "443:443" -+ environment: -+ TZ: Europe/Berlin -+ depends_on: -+ - admin-panel -+ - license-server -+ networks: -+ - internal_net -+ -+networks: -+ internal_net: -+ driver: bridge -+ -+volumes: -+ postgres_data: -diff --git a/v2_adminpanel/Dockerfile b/v2_adminpanel/Dockerfile -index cee53bf..dde0146 100644 ---- a/v2_adminpanel/Dockerfile -+++ b/v2_adminpanel/Dockerfile -@@ -1,33 +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"] -+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"] -diff --git a/v2_adminpanel/__pycache__/app.cpython-312.pyc b/v2_adminpanel/__pycache__/app.cpython-312.pyc -index ae11b74..99427c4 100644 -Binary files a/v2_adminpanel/__pycache__/app.cpython-312.pyc and b/v2_adminpanel/__pycache__/app.cpython-312.pyc differ -diff --git a/v2_adminpanel/app.py b/v2_adminpanel/app.py -index 0622714..4e6204a 100644 ---- a/v2_adminpanel/app.py -+++ b/v2_adminpanel/app.py -@@ -77,10 +77,24 @@ logging.basicConfig(level=logging.INFO) - # Import and register blueprints - from routes.auth_routes import auth_bp - from routes.admin_routes import admin_bp -+from routes.license_routes import license_bp -+from routes.customer_routes import customer_bp -+from routes.resource_routes import resource_bp -+from routes.session_routes import session_bp -+from routes.batch_routes import batch_bp -+from routes.api_routes import api_bp -+from routes.export_routes import export_bp - --# Temporarily comment out blueprints to avoid conflicts --# app.register_blueprint(auth_bp) --# app.register_blueprint(admin_bp) -+# Register blueprints -+app.register_blueprint(auth_bp) -+app.register_blueprint(admin_bp) -+app.register_blueprint(license_bp) -+app.register_blueprint(customer_bp) -+app.register_blueprint(resource_bp) -+app.register_blueprint(session_bp) -+app.register_blueprint(batch_bp) -+app.register_blueprint(api_bp) -+app.register_blueprint(export_bp) - - - # Scheduled Backup Job -@@ -136,213 +150,213 @@ def verify_recaptcha(response): - return False - - --@app.route("/login", methods=["GET", "POST"]) --def login(): -+# @app.route("/login", methods=["GET", "POST"]) -+# def login(): - # Timing-Attack Schutz - Start Zeit merken -- start_time = time.time() -+ # start_time = time.time() - - # IP-Adresse ermitteln -- ip_address = get_client_ip() -+ # 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") -+ # 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) -+ # 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") -+ # 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: -+ # 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) -+ # 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): -+ # 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) -+ # 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 -+ # user = get_user_by_username(username) -+ # login_success = False -+ # needs_2fa = False - -- if user: -+ # if user: - # Database user authentication -- if verify_password(password, user['password_hash']): -- login_success = True -- needs_2fa = user['totp_enabled'] -- else: -+ # 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 -+ # 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) -+ # elapsed = time.time() - start_time -+ # if elapsed < 1.0: -+ # time.sleep(1.0 - elapsed) - -- if login_success: -+ # if login_success: - # Erfolgreicher Login -- if needs_2fa: -+ # if needs_2fa: - # Store temporary session for 2FA verification -- session['temp_username'] = username -- session['temp_user_id'] = user['id'] -- session['awaiting_2fa'] = True -- return redirect(url_for('verify_2fa')) -- else: -+ # session['temp_username'] = username -+ # session['temp_user_id'] = user['id'] -+ # session['awaiting_2fa'] = True -+ # return redirect(url_for('verify_2fa')) -+ # else: - # Complete login without 2FA -- session.permanent = True # Aktiviert das Timeout -- session['logged_in'] = True -- session['username'] = username -- session['user_id'] = user['id'] if user else None -- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- reset_login_attempts(ip_address) -- log_audit('LOGIN_SUCCESS', 'user', -- additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") -- return redirect(url_for('dashboard')) -- else: -+ # session.permanent = True # Aktiviert das Timeout -+ # session['logged_in'] = True -+ # session['username'] = username -+ # session['user_id'] = user['id'] if user else None -+ # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ # reset_login_attempts(ip_address) -+ # log_audit('LOGIN_SUCCESS', 'user', -+ # additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") -+ # return redirect(url_for('dashboard')) -+ # else: - # Fehlgeschlagener Login -- error_message = record_failed_attempt(ip_address, username) -- new_attempt_count = get_login_attempts(ip_address) -+ # 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") -+ # 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) -+ # 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) -+ # return render_template("login.html", -+ # show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), -+ # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), -+ # recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - --@app.route("/logout") --def logout(): -- username = session.get('username', 'unknown') -- log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") -- session.pop('logged_in', None) -- session.pop('username', None) -- session.pop('user_id', None) -- session.pop('temp_username', None) -- session.pop('temp_user_id', None) -- session.pop('awaiting_2fa', None) -- return redirect(url_for('login')) -+# @app.route("/logout") -+# def logout(): -+ # username = session.get('username', 'unknown') -+ # log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") -+ # session.pop('logged_in', None) -+ # session.pop('username', None) -+ # session.pop('user_id', None) -+ # session.pop('temp_username', None) -+ # session.pop('temp_user_id', None) -+ # session.pop('awaiting_2fa', None) -+ # return redirect(url_for('login')) - --@app.route("/verify-2fa", methods=["GET", "POST"]) --def verify_2fa(): -- if not session.get('awaiting_2fa'): -- return redirect(url_for('login')) -+# @app.route("/verify-2fa", methods=["GET", "POST"]) -+# def verify_2fa(): -+ # if not session.get('awaiting_2fa'): -+ # return redirect(url_for('login')) - -- if request.method == "POST": -- token = request.form.get('token', '').replace(' ', '') -- username = session.get('temp_username') -- user_id = session.get('temp_user_id') -+ # if request.method == "POST": -+ # token = request.form.get('token', '').replace(' ', '') -+ # username = session.get('temp_username') -+ # user_id = session.get('temp_user_id') - -- if not username or not user_id: -- flash('Session expired. Please login again.', 'error') -- return redirect(url_for('login')) -+ # if not username or not user_id: -+ # flash('Session expired. Please login again.', 'error') -+ # return redirect(url_for('login')) - -- user = get_user_by_username(username) -- if not user: -- flash('User not found.', 'error') -- return redirect(url_for('login')) -+ # user = get_user_by_username(username) -+ # if not user: -+ # flash('User not found.', 'error') -+ # return redirect(url_for('login')) - - # Check if it's a backup code -- if len(token) == 8 and token.isupper(): -+ # 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): -+ # 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) -+ # code_hash = hash_backup_code(token) -+ # backup_codes.remove(code_hash) - -- conn = get_connection() -- cur = conn.cursor() -- cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", -- (json.dumps(backup_codes), user_id)) -- conn.commit() -- cur.close() -- conn.close() -+ # conn = get_connection() -+ # cur = conn.cursor() -+ # cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", -+ # (json.dumps(backup_codes), user_id)) -+ # conn.commit() -+ # cur.close() -+ # conn.close() - - # Complete login -- session.permanent = True -- session['logged_in'] = True -- session['username'] = username -- session['user_id'] = user_id -- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- session.pop('temp_username', None) -- session.pop('temp_user_id', None) -- session.pop('awaiting_2fa', None) -+ # session.permanent = True -+ # session['logged_in'] = True -+ # session['username'] = username -+ # session['user_id'] = user_id -+ # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ # session.pop('temp_username', None) -+ # session.pop('temp_user_id', None) -+ # session.pop('awaiting_2fa', None) - -- flash('Login successful using backup code. Please generate new backup codes.', 'warning') -- log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") -- return redirect(url_for('dashboard')) -- else: -+ # flash('Login successful using backup code. Please generate new backup codes.', 'warning') -+ # log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") -+ # return redirect(url_for('dashboard')) -+ # else: - # Try TOTP token -- if verify_totp(user['totp_secret'], token): -+ # 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) -+ # session.permanent = True -+ # session['logged_in'] = True -+ # session['username'] = username -+ # session['user_id'] = user_id -+ # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ # session.pop('temp_username', None) -+ # session.pop('temp_user_id', None) -+ # session.pop('awaiting_2fa', None) - -- log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") -- return redirect(url_for('dashboard')) -+ # log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") -+ # return redirect(url_for('dashboard')) - - # Failed verification -- conn = get_connection() -- cur = conn.cursor() -- cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", -- (datetime.now(), user_id)) -- conn.commit() -- cur.close() -- conn.close() -- -- flash('Invalid authentication code. Please try again.', 'error') -- log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") -- -- return render_template('verify_2fa.html') -+ # conn = get_connection() -+ # cur = conn.cursor() -+ # cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", -+ # (datetime.now(), user_id)) -+ # conn.commit() -+ # cur.close() -+ # conn.close() -+ -+ # flash('Invalid authentication code. Please try again.', 'error') -+ # log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") -+ -+ # return render_template('verify_2fa.html') - --@app.route("/profile") --@login_required -+# @app.route("/profile") -+# @login_required - def profile(): - user = get_user_by_username(session['username']) - if not user: -@@ -351,8 +365,8 @@ def profile(): - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - --@app.route("/profile/change-password", methods=["POST"]) --@login_required -+# @app.route("/profile/change-password", methods=["POST"]) -+# @login_required - def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') -@@ -389,8 +403,8 @@ def change_password(): - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - --@app.route("/profile/setup-2fa") --@login_required -+# @app.route("/profile/setup-2fa") -+# @login_required - def setup_2fa(): - user = get_user_by_username(session['username']) - -@@ -409,8 +423,8 @@ def setup_2fa(): - totp_secret=totp_secret, - qr_code=qr_code) - --@app.route("/profile/enable-2fa", methods=["POST"]) --@login_required -+# @app.route("/profile/enable-2fa", methods=["POST"]) -+# @login_required - def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') -@@ -447,8 +461,8 @@ def enable_2fa(): - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - --@app.route("/profile/disable-2fa", methods=["POST"]) --@login_required -+# @app.route("/profile/disable-2fa", methods=["POST"]) -+# @login_required - def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) -@@ -474,8 +488,8 @@ def disable_2fa(): - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - --@app.route("/heartbeat", methods=['POST']) --@login_required -+# @app.route("/heartbeat", methods=['POST']) -+# @login_required - def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert -@@ -489,8 +503,8 @@ def heartbeat(): - 'username': session.get('username') - }) - --@app.route("/api/generate-license-key", methods=['POST']) --@login_required -+# @app.route("/api/generate-license-key", methods=['POST']) -+# @login_required - def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: -@@ -534,8 +548,8 @@ def api_generate_key(): - 'error': 'Fehler bei der Key-Generierung' - }), 500 - --@app.route("/api/customers", methods=['GET']) --@login_required -+# @app.route("/api/customers", methods=['GET']) -+# @login_required - def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: -@@ -645,8 +659,8 @@ def api_customers(): - 'error': 'Fehler bei der Kundensuche' - }), 500 - --@app.route("/") --@login_required -+# @app.route("/") -+# @login_required - def dashboard(): - conn = get_connection() - cur = conn.cursor() -@@ -875,8 +889,8 @@ def dashboard(): - resource_warning=resource_warning, - username=session.get('username')) - --@app.route("/create", methods=["GET", "POST"]) --@login_required -+# @app.route("/create", methods=["GET", "POST"]) -+# @login_required - def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") -@@ -1106,8 +1120,8 @@ def create_license(): - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - --@app.route("/batch", methods=["GET", "POST"]) --@login_required -+# @app.route("/batch", methods=["GET", "POST"]) -+# @login_required - def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": -@@ -1361,8 +1375,8 @@ def batch_licenses(): - # GET Request - return render_template("batch_form.html") - --@app.route("/batch/export") --@login_required -+# @app.route("/batch/export") -+# @login_required - def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') -@@ -1400,14 +1414,14 @@ def export_batch(): - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - --@app.route("/licenses") --@login_required -+# @app.route("/licenses") -+# @login_required - def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - --@app.route("/license/edit/", methods=["GET", "POST"]) --@login_required -+# @app.route("/license/edit/", methods=["GET", "POST"]) -+# @login_required - def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() -@@ -1498,8 +1512,8 @@ def edit_license(license_id): - - return render_template("edit_license.html", license=license, username=session.get('username')) - --@app.route("/license/delete/", methods=["POST"]) --@login_required -+# @app.route("/license/delete/", methods=["POST"]) -+# @login_required - def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() -@@ -1531,14 +1545,14 @@ def delete_license(license_id): - - return redirect("/licenses") - --@app.route("/customers") --@login_required -+# @app.route("/customers") -+# @login_required - def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - --@app.route("/customer/edit/", methods=["GET", "POST"]) --@login_required -+# @app.route("/customer/edit/", methods=["GET", "POST"]) -+# @login_required - def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() -@@ -1621,8 +1635,8 @@ def edit_customer(customer_id): - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - --@app.route("/customer/create", methods=["GET", "POST"]) --@login_required -+# @app.route("/customer/create", methods=["GET", "POST"]) -+# @login_required - def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": -@@ -1676,8 +1690,8 @@ def create_customer(): - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - --@app.route("/customer/delete/", methods=["POST"]) --@login_required -+# @app.route("/customer/delete/", methods=["POST"]) -+# @login_required - def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() -@@ -1714,8 +1728,8 @@ def delete_customer(customer_id): - - return redirect("/customers") - --@app.route("/customers-licenses") --@login_required -+# @app.route("/customers-licenses") -+# @login_required - def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() -@@ -1807,8 +1821,8 @@ def customers_licenses(): - licenses=licenses, - show_test=show_test) - --@app.route("/api/customer//licenses") --@login_required -+# @app.route("/api/customer//licenses") -+# @login_required - def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() -@@ -1910,8 +1924,8 @@ def api_customer_licenses(customer_id): - 'count': len(licenses) - }) - --@app.route("/api/customer//quick-stats") --@login_required -+# @app.route("/api/customer//quick-stats") -+# @login_required - def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() -@@ -1943,8 +1957,8 @@ def api_customer_quick_stats(customer_id): - } - }) - --@app.route("/api/license//quick-edit", methods=['POST']) --@login_required -+# @app.route("/api/license//quick-edit", methods=['POST']) -+# @login_required - def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() -@@ -2013,8 +2027,8 @@ def api_license_quick_edit(license_id): - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - --@app.route("/api/license//resources") --@login_required -+# @app.route("/api/license//resources") -+# @login_required - def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() -@@ -2063,8 +2077,8 @@ def api_license_resources(license_id): - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - --@app.route("/sessions") --@login_required -+# @app.route("/sessions") -+# @login_required - def sessions(): - conn = get_connection() - cur = conn.cursor() -@@ -2145,8 +2159,8 @@ def sessions(): - ended_order=ended_order, - username=session.get('username')) - --@app.route("/session/end/", methods=["POST"]) --@login_required -+# @app.route("/session/end/", methods=["POST"]) -+# @login_required - def end_session(session_id): - conn = get_connection() - cur = conn.cursor() -@@ -2164,8 +2178,8 @@ def end_session(session_id): - - return redirect("/sessions") - --@app.route("/export/licenses") --@login_required -+# @app.route("/export/licenses") -+# @login_required - def export_licenses(): - conn = get_connection() - cur = conn.cursor() -@@ -2274,8 +2288,8 @@ def export_licenses(): - download_name=f'{filename}.xlsx' - ) - --@app.route("/export/audit") --@login_required -+# @app.route("/export/audit") -+# @login_required - def export_audit(): - conn = get_connection() - cur = conn.cursor() -@@ -2398,8 +2412,8 @@ def export_audit(): - download_name=f'{filename}.xlsx' - ) - --@app.route("/export/customers") --@login_required -+# @app.route("/export/customers") -+# @login_required - def export_customers(): - conn = get_connection() - cur = conn.cursor() -@@ -2502,8 +2516,8 @@ def export_customers(): - download_name=f'{filename}.xlsx' - ) - --@app.route("/export/sessions") --@login_required -+# @app.route("/export/sessions") -+# @login_required - def export_sessions(): - conn = get_connection() - cur = conn.cursor() -@@ -2641,8 +2655,8 @@ def export_sessions(): - download_name=f'{filename}.xlsx' - ) - --@app.route("/export/resources") --@login_required -+# @app.route("/export/resources") -+# @login_required - def export_resources(): - conn = get_connection() - cur = conn.cursor() -@@ -2770,8 +2784,8 @@ def export_resources(): - download_name=f'{filename}.xlsx' - ) - --@app.route("/audit") --@login_required -+# @app.route("/audit") -+# @login_required - def audit_log(): - conn = get_connection() - cur = conn.cursor() -@@ -2864,8 +2878,8 @@ def audit_log(): - order=order, - username=session.get('username')) - --@app.route("/backups") --@login_required -+# @app.route("/backups") -+# @login_required - def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() -@@ -2899,8 +2913,8 @@ def backups(): - last_backup=last_backup, - username=session.get('username')) - --@app.route("/backup/create", methods=["POST"]) --@login_required -+# @app.route("/backup/create", methods=["POST"]) -+# @login_required - def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') -@@ -2917,8 +2931,8 @@ def create_backup_route(): - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - --@app.route("/backup/restore/", methods=["POST"]) --@login_required -+# @app.route("/backup/restore/", methods=["POST"]) -+# @login_required - def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') -@@ -2936,8 +2950,8 @@ def restore_backup_route(backup_id): - 'message': message - }), 500 - --@app.route("/backup/download/") --@login_required -+# @app.route("/backup/download/") -+# @login_required - def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() -@@ -2968,8 +2982,8 @@ def download_backup(backup_id): - - return send_file(filepath, as_attachment=True, download_name=filename) - --@app.route("/backup/delete/", methods=["DELETE"]) --@login_required -+# @app.route("/backup/delete/", methods=["DELETE"]) -+# @login_required - def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() -@@ -3024,8 +3038,8 @@ def delete_backup(backup_id): - cur.close() - conn.close() - --@app.route("/security/blocked-ips") --@login_required -+# @app.route("/security/blocked-ips") -+# @login_required - def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() -@@ -3065,8 +3079,8 @@ def blocked_ips(): - blocked_ips=blocked_ips_list, - username=session.get('username')) - --@app.route("/security/unblock-ip", methods=["POST"]) --@login_required -+# @app.route("/security/unblock-ip", methods=["POST"]) -+# @login_required - def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') -@@ -3091,8 +3105,8 @@ def unblock_ip(): - - return redirect(url_for('blocked_ips')) - --@app.route("/security/clear-attempts", methods=["POST"]) --@login_required -+# @app.route("/security/clear-attempts", methods=["POST"]) -+# @login_required - def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') -@@ -3107,8 +3121,8 @@ def clear_attempts(): - return redirect(url_for('blocked_ips')) - - # API Endpoints for License Management --@app.route("/api/license//toggle", methods=["POST"]) --@login_required -+# @app.route("/api/license//toggle", methods=["POST"]) -+# @login_required - def toggle_license_api(license_id): - """Toggle license active status via API""" - try: -@@ -3139,8 +3153,8 @@ def toggle_license_api(license_id): - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - --@app.route("/api/licenses/bulk-activate", methods=["POST"]) --@login_required -+# @app.route("/api/licenses/bulk-activate", methods=["POST"]) -+# @login_required - def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: -@@ -3175,8 +3189,8 @@ def bulk_activate_licenses(): - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - --@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) --@login_required -+# @app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -+# @login_required - def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: -@@ -3211,8 +3225,8 @@ def bulk_deactivate_licenses(): - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - --@app.route("/api/license//devices") --@login_required -+# @app.route("/api/license//devices") -+# @login_required - def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: -@@ -3266,123 +3280,123 @@ def get_license_devices(license_id): - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - --@app.route("/api/license//register-device", methods=["POST"]) --def register_device(license_id): -- """Registriere ein neues Gerät für eine Lizenz""" -- try: -- data = request.get_json() -- hardware_id = data.get('hardware_id') -- device_name = data.get('device_name', '') -- operating_system = data.get('operating_system', '') -+# @app.route("/api/license//register-device", methods=["POST"]) -+# def register_device(license_id): -+ # """Registriere ein neues Gerät für eine Lizenz""" -+ # try: -+ # data = request.get_json() -+ # hardware_id = data.get('hardware_id') -+ # device_name = data.get('device_name', '') -+ # operating_system = data.get('operating_system', '') - -- if not hardware_id: -- return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 -+ # if not hardware_id: -+ # return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - -- conn = get_connection() -- cur = conn.cursor() -+ # conn = get_connection() -+ # cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist -- cur.execute(""" -- SELECT device_limit, is_active, valid_until -- FROM licenses -- WHERE id = %s -- """, (license_id,)) -- license_data = cur.fetchone() -+ # cur.execute(""" -+ # SELECT device_limit, is_active, valid_until -+ # FROM licenses -+ # WHERE id = %s -+ # """, (license_id,)) -+ # license_data = cur.fetchone() - -- if not license_data: -- return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 -+ # if not license_data: -+ # return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - -- device_limit, is_active, valid_until = license_data -+ # device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist -- if not is_active: -- return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 -+ # if not is_active: -+ # return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - -- if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): -- return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 -+ # if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): -+ # return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist -- cur.execute(""" -- SELECT id, is_active FROM device_registrations -- WHERE license_id = %s AND hardware_id = %s -- """, (license_id, hardware_id)) -- existing_device = cur.fetchone() -- -- if existing_device: -- device_id, is_device_active = existing_device -- if is_device_active: -+ # cur.execute(""" -+ # SELECT id, is_active FROM device_registrations -+ # WHERE license_id = %s AND hardware_id = %s -+ # """, (license_id, hardware_id)) -+ # existing_device = cur.fetchone() -+ -+ # if existing_device: -+ # device_id, is_device_active = existing_device -+ # if is_device_active: - # Gerät ist bereits aktiv, update last_seen -- cur.execute(""" -- UPDATE device_registrations -- SET last_seen = CURRENT_TIMESTAMP, -- ip_address = %s, -- user_agent = %s -- WHERE id = %s -- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -- conn.commit() -- return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) -- else: -+ # cur.execute(""" -+ # UPDATE device_registrations -+ # SET last_seen = CURRENT_TIMESTAMP, -+ # ip_address = %s, -+ # user_agent = %s -+ # WHERE id = %s -+ # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -+ # conn.commit() -+ # return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) -+ # else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können -- cur.execute(""" -- SELECT COUNT(*) FROM device_registrations -- WHERE license_id = %s AND is_active = TRUE -- """, (license_id,)) -- active_count = cur.fetchone()[0] -+ # cur.execute(""" -+ # SELECT COUNT(*) FROM device_registrations -+ # WHERE license_id = %s AND is_active = TRUE -+ # """, (license_id,)) -+ # active_count = cur.fetchone()[0] - -- if active_count >= device_limit: -- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 -+ # if active_count >= device_limit: -+ # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät -- cur.execute(""" -- UPDATE device_registrations -- SET is_active = TRUE, -- last_seen = CURRENT_TIMESTAMP, -- deactivated_at = NULL, -- deactivated_by = NULL, -- ip_address = %s, -- user_agent = %s -- WHERE id = %s -- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -- conn.commit() -- return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) -+ # cur.execute(""" -+ # UPDATE device_registrations -+ # SET is_active = TRUE, -+ # last_seen = CURRENT_TIMESTAMP, -+ # deactivated_at = NULL, -+ # deactivated_by = NULL, -+ # ip_address = %s, -+ # user_agent = %s -+ # WHERE id = %s -+ # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -+ # conn.commit() -+ # return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit -- cur.execute(""" -- SELECT COUNT(*) FROM device_registrations -- WHERE license_id = %s AND is_active = TRUE -- """, (license_id,)) -- active_count = cur.fetchone()[0] -+ # cur.execute(""" -+ # SELECT COUNT(*) FROM device_registrations -+ # WHERE license_id = %s AND is_active = TRUE -+ # """, (license_id,)) -+ # active_count = cur.fetchone()[0] - -- if active_count >= device_limit: -- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 -+ # if active_count >= device_limit: -+ # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät -- cur.execute(""" -- INSERT INTO device_registrations -- (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) -- VALUES (%s, %s, %s, %s, %s, %s) -- RETURNING id -- """, (license_id, hardware_id, device_name, operating_system, -- get_client_ip(), request.headers.get('User-Agent', ''))) -- device_id = cur.fetchone()[0] -+ # cur.execute(""" -+ # INSERT INTO device_registrations -+ # (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) -+ # VALUES (%s, %s, %s, %s, %s, %s) -+ # RETURNING id -+ # """, (license_id, hardware_id, device_name, operating_system, -+ # get_client_ip(), request.headers.get('User-Agent', ''))) -+ # device_id = cur.fetchone()[0] - -- conn.commit() -+ # conn.commit() - - # Audit Log -- log_audit('DEVICE_REGISTER', 'device', device_id, -- new_values={'license_id': license_id, 'hardware_id': hardware_id}) -+ # log_audit('DEVICE_REGISTER', 'device', device_id, -+ # new_values={'license_id': license_id, 'hardware_id': hardware_id}) - -- cur.close() -- conn.close() -+ # cur.close() -+ # conn.close() - -- return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) -+ # return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - -- except Exception as e: -- logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") -- return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 -+ # except Exception as e: -+ # logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") -+ # return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - --@app.route("/api/license//deactivate-device/", methods=["POST"]) --@login_required -+# @app.route("/api/license//deactivate-device/", methods=["POST"]) -+# @login_required - def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: -@@ -3423,8 +3437,8 @@ def deactivate_device(license_id, device_id): - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - --@app.route("/api/licenses/bulk-delete", methods=["POST"]) --@login_required -+# @app.route("/api/licenses/bulk-delete", methods=["POST"]) -+# @login_required - def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: -@@ -3468,8 +3482,8 @@ def bulk_delete_licenses(): - - # ===================== RESOURCE POOL MANAGEMENT ===================== - --@app.route('/resources') --@login_required -+# @app.route('/resources') -+# @login_required - def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() -@@ -3608,8 +3622,8 @@ def resources(): - datetime=datetime, - timedelta=timedelta) - --@app.route('/resources/add', methods=['GET', 'POST']) --@login_required -+# @app.route('/resources/add', methods=['GET', 'POST']) -+# @login_required - def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige -@@ -3672,8 +3686,8 @@ def add_resources(): - - return render_template('add_resources.html', show_test=show_test) - --@app.route('/resources/quarantine/', methods=['POST']) --@login_required -+# @app.route('/resources/quarantine/', methods=['POST']) -+# @login_required - def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') -@@ -3730,8 +3744,8 @@ def quarantine_resource(resource_id): - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - --@app.route('/resources/release', methods=['POST']) --@login_required -+# @app.route('/resources/release', methods=['POST']) -+# @login_required - def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') -@@ -3781,8 +3795,8 @@ def release_resources(): - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - --@app.route('/api/resources/allocate', methods=['POST']) --@login_required -+# @app.route('/api/resources/allocate', methods=['POST']) -+# @login_required - def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json -@@ -3929,8 +3943,8 @@ def allocate_resources_api(): - 'error': str(e) - }), 400 - --@app.route('/api/resources/check-availability', methods=['GET']) --@login_required -+# @app.route('/api/resources/check-availability', methods=['GET']) -+# @login_required - def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') -@@ -3988,8 +4002,8 @@ def check_resource_availability(): - - return jsonify(availability) - --@app.route('/api/global-search', methods=['GET']) --@login_required -+# @app.route('/api/global-search', methods=['GET']) -+# @login_required - def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() -@@ -4051,8 +4065,8 @@ def global_search(): - 'licenses': licenses - }) - --@app.route('/resources/history/') --@login_required -+# @app.route('/resources/history/') -+# @login_required - def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() -@@ -4138,8 +4152,8 @@ def resource_history(resource_id): - license_info=license_info, - history=history_objs) - --@app.route('/resources/metrics') --@login_required -+# @app.route('/resources/metrics') -+# @login_required - def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() -@@ -4302,8 +4316,8 @@ def resources_metrics(): - problem_resources=problem_resources, - daily_metrics=daily_metrics) - --@app.route('/resources/report', methods=['GET']) --@login_required -+# @app.route('/resources/report', methods=['GET']) -+# @login_required - def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde -diff --git a/v2_adminpanel/app.py.backup b/v2_adminpanel/app.py.backup -index 96c85f1..398d007 100644 ---- a/v2_adminpanel/app.py.backup -+++ b/v2_adminpanel/app.py.backup -@@ -1,5032 +1,5032 @@ --import os --import psycopg2 --from psycopg2.extras import Json --from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash --from flask_session import Session --from functools import wraps --from dotenv import load_dotenv --import pandas as pd --from datetime import datetime, timedelta --from zoneinfo import ZoneInfo --import io --import subprocess --import gzip --from cryptography.fernet import Fernet --from pathlib import Path --import time --from apscheduler.schedulers.background import BackgroundScheduler --import logging --import random --import hashlib --import requests --import secrets --import string --import re --import bcrypt --import pyotp --import qrcode --from io import BytesIO --import base64 --import json --from werkzeug.middleware.proxy_fix import ProxyFix --from openpyxl.utils import get_column_letter -- --load_dotenv() -- --app = Flask(__name__) --app.config['SECRET_KEY'] = os.urandom(24) --app.config['SESSION_TYPE'] = 'filesystem' --app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8 --app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8' --app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=5) # 5 Minuten Session-Timeout --app.config['SESSION_COOKIE_HTTPONLY'] = True --app.config['SESSION_COOKIE_SECURE'] = False # Wird auf True gesetzt wenn HTTPS (intern läuft HTTP) --app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' --app.config['SESSION_COOKIE_NAME'] = 'admin_session' --# WICHTIG: Session-Cookie soll auch nach 5 Minuten ablaufen --app.config['SESSION_REFRESH_EACH_REQUEST'] = False --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 --) -- --# Backup-Konfiguration --BACKUP_DIR = Path("/app/backups") --BACKUP_DIR.mkdir(exist_ok=True) -- --# Rate-Limiting Konfiguration --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 -- --# Scheduler für automatische Backups --scheduler = BackgroundScheduler() --scheduler.start() -- --# Logging konfigurieren --logging.basicConfig(level=logging.INFO) -- -- --# Login decorator --def login_required(f): -- @wraps(f) -- def decorated_function(*args, **kwargs): -- if 'logged_in' not in session: -- return redirect(url_for('login')) -- -- # Prüfe ob Session abgelaufen ist -- 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 -- app.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 abgelaufen - Logout -- username = session.get('username', 'unbekannt') -- app.logger.info(f"Session timeout for user {username} - auto logout") -- # Audit-Log für automatischen Logout (vor 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('login')) -- -- # Aktivität NICHT automatisch aktualisieren -- # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) -- return f(*args, **kwargs) -- return decorated_function -- --# DB-Verbindung mit UTF-8 Encoding --def get_connection(): -- 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"), -- options='-c client_encoding=UTF8' -- ) -- conn.set_client_encoding('UTF8') -- return conn -- --# User Authentication Helper Functions --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')) -- --def get_user_by_username(username): -- """Get user from database by username""" -- conn = get_connection() -- cur = conn.cursor() -- try: -- cur.execute(""" -- SELECT id, username, password_hash, email, totp_secret, totp_enabled, -- backup_codes, last_password_change, failed_2fa_attempts -- FROM users WHERE username = %s -- """, (username,)) -- user = cur.fetchone() -- if user: -- return { -- 'id': user[0], -- 'username': user[1], -- 'password_hash': user[2], -- 'email': user[3], -- 'totp_secret': user[4], -- 'totp_enabled': user[5], -- 'backup_codes': user[6], -- 'last_password_change': user[7], -- 'failed_2fa_attempts': user[8] -- } -- return None -- finally: -- cur.close() -- conn.close() -- --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 -- --# Audit-Log-Funktion --def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): -- """Protokolliert Änderungen im Audit-Log""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- username = session.get('username', 'system') -- ip_address = get_client_ip() if request else None -- user_agent = request.headers.get('User-Agent') if request else None -- -- # Debug logging -- app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") -- -- # Konvertiere Dictionaries zu JSONB -- old_json = Json(old_values) if old_values else None -- new_json = Json(new_values) if new_values else None -- -- cur.execute(""" -- INSERT INTO audit_log -- (username, action, entity_type, entity_id, old_values, new_values, -- ip_address, user_agent, additional_info) -- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) -- """, (username, action, entity_type, entity_id, old_json, new_json, -- ip_address, user_agent, additional_info)) -- -- conn.commit() -- except Exception as e: -- print(f"Audit log error: {e}") -- conn.rollback() -- finally: -- cur.close() -- conn.close() -- --# Verschlüsselungs-Funktionen --def get_or_create_encryption_key(): -- """Holt oder erstellt einen Verschlüsselungsschlüssel""" -- key_file = BACKUP_DIR / ".backup_key" -- -- # Versuche Key aus Umgebungsvariable zu lesen -- env_key = os.getenv("BACKUP_ENCRYPTION_KEY") -- if env_key: -- try: -- # Validiere den Key -- Fernet(env_key.encode()) -- return env_key.encode() -- except: -- pass -- -- # Wenn kein gültiger Key in ENV, prüfe Datei -- if key_file.exists(): -- return key_file.read_bytes() -- -- # Erstelle neuen Key -- key = Fernet.generate_key() -- key_file.write_bytes(key) -- logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt") -- return key -- --# Backup-Funktionen --def create_backup(backup_type="manual", created_by=None): -- """Erstellt ein verschlüsseltes Backup der Datenbank""" -- start_time = time.time() -- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") -- filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" -- filepath = BACKUP_DIR / filename -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Backup-Eintrag erstellen -- cur.execute(""" -- INSERT INTO backup_history -- (filename, filepath, backup_type, status, created_by, is_encrypted) -- VALUES (%s, %s, %s, %s, %s, %s) -- RETURNING id -- """, (filename, str(filepath), backup_type, 'in_progress', -- created_by or 'system', True)) -- backup_id = cur.fetchone()[0] -- conn.commit() -- -- try: -- # PostgreSQL Dump erstellen -- dump_command = [ -- 'pg_dump', -- '-h', os.getenv("POSTGRES_HOST", "postgres"), -- '-p', os.getenv("POSTGRES_PORT", "5432"), -- '-U', os.getenv("POSTGRES_USER"), -- '-d', os.getenv("POSTGRES_DB"), -- '--no-password', -- '--verbose' -- ] -- -- # PGPASSWORD setzen -- env = os.environ.copy() -- env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") -- -- # Dump ausführen -- result = subprocess.run(dump_command, capture_output=True, text=True, env=env) -- -- if result.returncode != 0: -- raise Exception(f"pg_dump failed: {result.stderr}") -- -- dump_data = result.stdout.encode('utf-8') -- -- # Komprimieren -- compressed_data = gzip.compress(dump_data) -- -- # Verschlüsseln -- key = get_or_create_encryption_key() -- f = Fernet(key) -- encrypted_data = f.encrypt(compressed_data) -- -- # Speichern -- filepath.write_bytes(encrypted_data) -- -- # Statistiken sammeln -- cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") -- tables_count = cur.fetchone()[0] -- -- cur.execute(""" -- SELECT SUM(n_live_tup) -- FROM pg_stat_user_tables -- """) -- records_count = cur.fetchone()[0] or 0 -- -- duration = time.time() - start_time -- filesize = filepath.stat().st_size -- -- # Backup-Eintrag aktualisieren -- cur.execute(""" -- UPDATE backup_history -- SET status = %s, filesize = %s, tables_count = %s, -- records_count = %s, duration_seconds = %s -- WHERE id = %s -- """, ('success', filesize, tables_count, records_count, duration, backup_id)) -- -- conn.commit() -- -- # Audit-Log -- log_audit('BACKUP', 'database', backup_id, -- additional_info=f"Backup erstellt: {filename} ({filesize} bytes)") -- -- # E-Mail-Benachrichtigung (wenn konfiguriert) -- send_backup_notification(True, filename, filesize, duration) -- -- logging.info(f"Backup erfolgreich erstellt: {filename}") -- return True, filename -- -- except Exception as e: -- # Fehler protokollieren -- cur.execute(""" -- UPDATE backup_history -- SET status = %s, error_message = %s, duration_seconds = %s -- WHERE id = %s -- """, ('failed', str(e), time.time() - start_time, backup_id)) -- conn.commit() -- -- logging.error(f"Backup fehlgeschlagen: {e}") -- send_backup_notification(False, filename, error=str(e)) -- -- return False, str(e) -- -- finally: -- cur.close() -- conn.close() -- --def restore_backup(backup_id, encryption_key=None): -- """Stellt ein Backup wieder her""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Backup-Info abrufen -- cur.execute(""" -- SELECT filename, filepath, is_encrypted -- FROM backup_history -- WHERE id = %s -- """, (backup_id,)) -- backup_info = cur.fetchone() -- -- if not backup_info: -- raise Exception("Backup nicht gefunden") -- -- filename, filepath, is_encrypted = backup_info -- filepath = Path(filepath) -- -- if not filepath.exists(): -- raise Exception("Backup-Datei nicht gefunden") -- -- # Datei lesen -- encrypted_data = filepath.read_bytes() -- -- # Entschlüsseln -- if is_encrypted: -- key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() -- try: -- f = Fernet(key) -- compressed_data = f.decrypt(encrypted_data) -- except: -- raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?") -- else: -- compressed_data = encrypted_data -- -- # Dekomprimieren -- dump_data = gzip.decompress(compressed_data) -- sql_commands = dump_data.decode('utf-8') -- -- # Bestehende Verbindungen schließen -- cur.close() -- conn.close() -- -- # Datenbank wiederherstellen -- restore_command = [ -- 'psql', -- '-h', os.getenv("POSTGRES_HOST", "postgres"), -- '-p', os.getenv("POSTGRES_PORT", "5432"), -- '-U', os.getenv("POSTGRES_USER"), -- '-d', os.getenv("POSTGRES_DB"), -- '--no-password' -- ] -- -- env = os.environ.copy() -- env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") -- -- result = subprocess.run(restore_command, input=sql_commands, -- capture_output=True, text=True, env=env) -- -- if result.returncode != 0: -- raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}") -- -- # Audit-Log (neue Verbindung) -- log_audit('RESTORE', 'database', backup_id, -- additional_info=f"Backup wiederhergestellt: {filename}") -- -- return True, "Backup erfolgreich wiederhergestellt" -- -- except Exception as e: -- logging.error(f"Wiederherstellung fehlgeschlagen: {e}") -- return False, str(e) -- --def send_backup_notification(success, filename, filesize=None, duration=None, error=None): -- """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)""" -- if not os.getenv("EMAIL_ENABLED", "false").lower() == "true": -- return -- -- # E-Mail-Funktion vorbereitet aber deaktiviert -- # TODO: Implementieren wenn E-Mail-Server konfiguriert ist -- logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}") -- --# Scheduled Backup Job --def scheduled_backup(): -- """Führt ein geplantes Backup aus""" -- logging.info("Starte geplantes Backup...") -- create_backup(backup_type="scheduled", created_by="scheduler") -- --# Scheduler konfigurieren - täglich um 3:00 Uhr --scheduler.add_job( -- scheduled_backup, -- 'cron', -- hour=3, -- minute=0, -- id='daily_backup', -- replace_existing=True --) -- --# Rate-Limiting Funktionen --def get_client_ip(): -- """Ermittelt die echte IP-Adresse des Clients""" -- # Debug logging -- app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") -- -- # Try X-Real-IP first (set by nginx) -- if request.headers.get('X-Real-IP'): -- return request.headers.get('X-Real-IP') -- # Then X-Forwarded-For -- elif request.headers.get('X-Forwarded-For'): -- # X-Forwarded-For can contain multiple IPs, take the first one -- return request.headers.get('X-Forwarded-For').split(',')[0].strip() -- # Fallback to remote_addr -- else: -- return request.remote_addr -- --def check_ip_blocked(ip_address): -- """Prüft ob eine IP-Adresse gesperrt ist""" -- conn = get_connection() -- cur = conn.cursor() -- -- cur.execute(""" -- SELECT blocked_until FROM login_attempts -- WHERE ip_address = %s AND blocked_until IS NOT NULL -- """, (ip_address,)) -- -- result = cur.fetchone() -- cur.close() -- conn.close() -- -- 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): -- """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Random Fehlermeldung -- error_message = random.choice(FAIL_MESSAGES) -- -- try: -- # Prüfen ob IP bereits existiert -- cur.execute(""" -- SELECT attempt_count FROM login_attempts -- WHERE ip_address = %s -- """, (ip_address,)) -- -- result = cur.fetchone() -- -- if result: -- # Update bestehenden Eintrag -- 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) -- # E-Mail-Benachrichtigung (wenn aktiviert) -- if os.getenv("EMAIL_ENABLED", "false").lower() == "true": -- 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: -- # Neuen Eintrag erstellen -- 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: -- print(f"Rate limiting error: {e}") -- conn.rollback() -- finally: -- cur.close() -- conn.close() -- -- return error_message -- --def reset_login_attempts(ip_address): -- """Setzt die Login-Versuche für eine IP zurück""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- cur.execute(""" -- DELETE FROM login_attempts -- WHERE ip_address = %s -- """, (ip_address,)) -- conn.commit() -- except Exception as e: -- print(f"Reset attempts error: {e}") -- conn.rollback() -- finally: -- cur.close() -- conn.close() -- --def get_login_attempts(ip_address): -- """Gibt die Anzahl der Login-Versuche für eine IP zurück""" -- conn = get_connection() -- cur = conn.cursor() -- -- cur.execute(""" -- SELECT attempt_count FROM login_attempts -- WHERE ip_address = %s -- """, (ip_address,)) -- -- result = cur.fetchone() -- cur.close() -- conn.close() -- -- return result[0] if result else 0 -- --def send_security_alert_email(ip_address, username, attempt_count): -- """Sendet eine Sicherheitswarnung per E-Mail""" -- 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: E-Mail-Versand implementieren wenn SMTP konfiguriert -- logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") -- print(f"E-Mail würde gesendet: {subject}") -- --def verify_recaptcha(response): -- """Verifiziert die reCAPTCHA v2 Response mit Google""" -- secret_key = os.getenv('RECAPTCHA_SECRET_KEY') -- -- # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) -- if not secret_key: -- logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") -- return True -- -- # Verifizierung bei Google -- try: -- verify_url = 'https://www.google.com/recaptcha/api/siteverify' -- data = { -- 'secret': secret_key, -- 'response': response -- } -- -- # Timeout für Request setzen -- r = requests.post(verify_url, data=data, timeout=5) -- result = r.json() -- -- # Log für Debugging -- if not result.get('success'): -- logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") -- -- return result.get('success', False) -- -- except requests.exceptions.RequestException as e: -- logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") -- # Bei Netzwerkfehlern CAPTCHA als bestanden werten -- return True -- except Exception as e: -- logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") -- return False -- --def generate_license_key(license_type='full'): -- """ -- Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ -- -- AF = Account Factory (Produktkennung) -- F/T = F für Fullversion, T für Testversion -- YYYY = Jahr -- MM = Monat -- XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen -- """ -- # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) -- chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' -- -- # Datum-Teil -- now = datetime.now(ZoneInfo("Europe/Berlin")) -- date_part = now.strftime('%Y%m') -- type_char = 'F' if license_type == 'full' else 'T' -- -- # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) -- parts = [] -- for _ in range(3): -- part = ''.join(secrets.choice(chars) for _ in range(4)) -- parts.append(part) -- -- # Key zusammensetzen -- key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" -- -- return key -- --def validate_license_key(key): -- """ -- Validiert das License Key Format -- Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ -- """ -- if not key: -- return False -- -- # Pattern für das neue Format -- # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen -- pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' -- -- # Großbuchstaben für Vergleich -- return bool(re.match(pattern, key.upper())) -- --@app.route("/login", methods=["GET", "POST"]) --def login(): -- # Timing-Attack Schutz - Start Zeit merken -- start_time = time.time() -- -- # IP-Adresse ermitteln -- ip_address = get_client_ip() -- -- # Prüfen ob IP gesperrt ist -- is_blocked, blocked_until = check_ip_blocked(ip_address) -- if is_blocked: -- time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 -- error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." -- return render_template("login.html", error=error_msg, error_type="blocked") -- -- # Anzahl bisheriger Versuche -- attempt_count = get_login_attempts(ip_address) -- -- if request.method == "POST": -- username = request.form.get("username") -- password = request.form.get("password") -- captcha_response = request.form.get("g-recaptcha-response") -- -- # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind -- recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') -- if attempt_count >= 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, 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, 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 -- admin1_user = os.getenv("ADMIN1_USERNAME") -- admin1_pass = os.getenv("ADMIN1_PASSWORD") -- admin2_user = os.getenv("ADMIN2_USERNAME") -- admin2_pass = os.getenv("ADMIN2_PASSWORD") -- -- if ((username == admin1_user and password == admin1_pass) or -- (username == admin2_user and password == admin2_pass)): -- login_success = True -- -- # Timing-Attack Schutz - Mindestens 1 Sekunde warten -- elapsed = time.time() - start_time -- if elapsed < 1.0: -- time.sleep(1.0 - elapsed) -- -- if login_success: -- # Erfolgreicher Login -- if needs_2fa: -- # Store temporary session for 2FA verification -- session['temp_username'] = username -- session['temp_user_id'] = user['id'] -- session['awaiting_2fa'] = True -- return redirect(url_for('verify_2fa')) -- else: -- # Complete login without 2FA -- session.permanent = True # Aktiviert das Timeout -- session['logged_in'] = True -- session['username'] = username -- session['user_id'] = user['id'] if user else None -- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- reset_login_attempts(ip_address) -- log_audit('LOGIN_SUCCESS', 'user', -- additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") -- return redirect(url_for('dashboard')) -- else: -- # Fehlgeschlagener Login -- error_message = record_failed_attempt(ip_address, username) -- new_attempt_count = get_login_attempts(ip_address) -- -- # Prüfen ob jetzt gesperrt -- is_now_blocked, _ = check_ip_blocked(ip_address) -- if is_now_blocked: -- log_audit('LOGIN_BLOCKED', 'security', -- additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") -- -- return render_template("login.html", -- error=error_message, -- show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), -- error_type="failed", -- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), -- recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) -- -- # GET Request -- return render_template("login.html", -- show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), -- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), -- recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) -- --@app.route("/logout") --def logout(): -- username = session.get('username', 'unknown') -- log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") -- session.pop('logged_in', None) -- session.pop('username', None) -- session.pop('user_id', None) -- session.pop('temp_username', None) -- session.pop('temp_user_id', None) -- session.pop('awaiting_2fa', None) -- return redirect(url_for('login')) -- --@app.route("/verify-2fa", methods=["GET", "POST"]) --def verify_2fa(): -- if not session.get('awaiting_2fa'): -- return redirect(url_for('login')) -- -- if request.method == "POST": -- token = request.form.get('token', '').replace(' ', '') -- username = session.get('temp_username') -- user_id = session.get('temp_user_id') -- -- if not username or not user_id: -- flash('Session expired. Please login again.', 'error') -- return redirect(url_for('login')) -- -- user = get_user_by_username(username) -- if not user: -- flash('User not found.', 'error') -- return redirect(url_for('login')) -- -- # Check if it's a backup code -- if len(token) == 8 and token.isupper(): -- # Try backup code -- backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] -- if verify_backup_code(token, backup_codes): -- # Remove used backup code -- code_hash = hash_backup_code(token) -- backup_codes.remove(code_hash) -- -- conn = get_connection() -- cur = conn.cursor() -- cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", -- (json.dumps(backup_codes), user_id)) -- conn.commit() -- cur.close() -- conn.close() -- -- # Complete login -- session.permanent = True -- session['logged_in'] = True -- session['username'] = username -- session['user_id'] = user_id -- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- session.pop('temp_username', None) -- session.pop('temp_user_id', None) -- session.pop('awaiting_2fa', None) -- -- flash('Login successful using backup code. Please generate new backup codes.', 'warning') -- log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") -- return redirect(url_for('dashboard')) -- else: -- # Try TOTP token -- if verify_totp(user['totp_secret'], token): -- # Complete login -- session.permanent = True -- session['logged_in'] = True -- session['username'] = username -- session['user_id'] = user_id -- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- session.pop('temp_username', None) -- session.pop('temp_user_id', None) -- session.pop('awaiting_2fa', None) -- -- log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") -- return redirect(url_for('dashboard')) -- -- # Failed verification -- conn = get_connection() -- cur = conn.cursor() -- cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", -- (datetime.now(), user_id)) -- conn.commit() -- cur.close() -- conn.close() -- -- flash('Invalid authentication code. Please try again.', 'error') -- log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") -- -- return render_template('verify_2fa.html') -- --@app.route("/profile") --@login_required --def profile(): -- user = get_user_by_username(session['username']) -- if not user: -- # For environment-based users, redirect with message -- flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') -- return redirect(url_for('dashboard')) -- return render_template('profile.html', user=user) -- --@app.route("/profile/change-password", methods=["POST"]) --@login_required --def change_password(): -- current_password = request.form.get('current_password') -- new_password = request.form.get('new_password') -- confirm_password = request.form.get('confirm_password') -- -- user = get_user_by_username(session['username']) -- -- # Verify current password -- if not verify_password(current_password, user['password_hash']): -- flash('Current password is incorrect.', 'error') -- return redirect(url_for('profile')) -- -- # Check new password -- if new_password != confirm_password: -- flash('New passwords do not match.', 'error') -- return redirect(url_for('profile')) -- -- if len(new_password) < 8: -- flash('Password must be at least 8 characters long.', 'error') -- return redirect(url_for('profile')) -- -- # Update password -- new_hash = hash_password(new_password) -- conn = get_connection() -- cur = conn.cursor() -- cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", -- (new_hash, datetime.now(), user['id'])) -- conn.commit() -- cur.close() -- conn.close() -- -- log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], -- additional_info="Password changed successfully") -- flash('Password changed successfully.', 'success') -- return redirect(url_for('profile')) -- --@app.route("/profile/setup-2fa") --@login_required --def setup_2fa(): -- user = get_user_by_username(session['username']) -- -- if user['totp_enabled']: -- flash('2FA is already enabled for your account.', 'info') -- return redirect(url_for('profile')) -- -- # Generate new TOTP secret -- totp_secret = generate_totp_secret() -- session['temp_totp_secret'] = totp_secret -- -- # Generate QR code -- qr_code = generate_qr_code(user['username'], totp_secret) -- -- return render_template('setup_2fa.html', -- totp_secret=totp_secret, -- qr_code=qr_code) -- --@app.route("/profile/enable-2fa", methods=["POST"]) --@login_required --def enable_2fa(): -- token = request.form.get('token', '').replace(' ', '') -- totp_secret = session.get('temp_totp_secret') -- -- if not totp_secret: -- flash('2FA setup session expired. Please try again.', 'error') -- return redirect(url_for('setup_2fa')) -- -- # Verify the token -- if not verify_totp(totp_secret, token): -- flash('Invalid authentication code. Please try again.', 'error') -- return redirect(url_for('setup_2fa')) -- -- # Generate backup codes -- backup_codes = generate_backup_codes() -- hashed_codes = [hash_backup_code(code) for code in backup_codes] -- -- # Enable 2FA -- conn = get_connection() -- cur = conn.cursor() -- cur.execute(""" -- UPDATE users -- SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s -- WHERE username = %s -- """, (totp_secret, json.dumps(hashed_codes), session['username'])) -- conn.commit() -- cur.close() -- conn.close() -- -- session.pop('temp_totp_secret', None) -- -- log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") -- -- # Show backup codes -- return render_template('backup_codes.html', backup_codes=backup_codes) -- --@app.route("/profile/disable-2fa", methods=["POST"]) --@login_required --def disable_2fa(): -- password = request.form.get('password') -- user = get_user_by_username(session['username']) -- -- # Verify password -- if not verify_password(password, user['password_hash']): -- flash('Incorrect password.', 'error') -- return redirect(url_for('profile')) -- -- # Disable 2FA -- conn = get_connection() -- cur = conn.cursor() -- cur.execute(""" -- UPDATE users -- SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL -- WHERE username = %s -- """, (session['username'],)) -- conn.commit() -- cur.close() -- conn.close() -- -- log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") -- flash('2FA has been disabled for your account.', 'success') -- return redirect(url_for('profile')) -- --@app.route("/heartbeat", methods=['POST']) --@login_required --def heartbeat(): -- """Endpoint für Session Keep-Alive - aktualisiert last_activity""" -- # Aktualisiere last_activity nur wenn explizit angefordert -- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- # Force session save -- session.modified = True -- -- return jsonify({ -- 'status': 'ok', -- 'last_activity': session['last_activity'], -- 'username': session.get('username') -- }) -- --@app.route("/api/generate-license-key", methods=['POST']) --@login_required --def api_generate_key(): -- """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" -- try: -- # Lizenztyp aus Request holen (default: full) -- data = request.get_json() or {} -- license_type = data.get('type', 'full') -- -- # Key generieren -- key = generate_license_key(license_type) -- -- # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) -- conn = get_connection() -- cur = conn.cursor() -- -- # Wiederhole bis eindeutiger Key gefunden -- attempts = 0 -- while attempts < 10: # Max 10 Versuche -- cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) -- if not cur.fetchone(): -- break # Key ist eindeutig -- key = generate_license_key(license_type) -- attempts += 1 -- -- cur.close() -- conn.close() -- -- # Log für Audit -- log_audit('GENERATE_KEY', 'license', -- additional_info={'type': license_type, 'key': key}) -- -- return jsonify({ -- 'success': True, -- 'key': key, -- 'type': license_type -- }) -- -- except Exception as e: -- logging.error(f"Fehler bei Key-Generierung: {str(e)}") -- return jsonify({ -- 'success': False, -- 'error': 'Fehler bei der Key-Generierung' -- }), 500 -- --@app.route("/api/customers", methods=['GET']) --@login_required --def api_customers(): -- """API Endpoint für die Kundensuche mit Select2""" -- try: -- # Suchparameter -- search = request.args.get('q', '').strip() -- page = request.args.get('page', 1, type=int) -- per_page = 20 -- customer_id = request.args.get('id', type=int) -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Einzelnen Kunden per ID abrufen -- if customer_id: -- cur.execute(""" -- SELECT c.id, c.name, c.email, -- COUNT(l.id) as license_count -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- WHERE c.id = %s -- GROUP BY c.id, c.name, c.email -- """, (customer_id,)) -- -- customer = cur.fetchone() -- results = [] -- if customer: -- results.append({ -- 'id': customer[0], -- 'text': f"{customer[1]} ({customer[2]})", -- 'name': customer[1], -- 'email': customer[2], -- 'license_count': customer[3] -- }) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'results': results, -- 'pagination': {'more': False} -- }) -- -- # SQL Query mit optionaler Suche -- elif search: -- cur.execute(""" -- SELECT c.id, c.name, c.email, -- COUNT(l.id) as license_count -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- WHERE LOWER(c.name) LIKE LOWER(%s) -- OR LOWER(c.email) LIKE LOWER(%s) -- GROUP BY c.id, c.name, c.email -- ORDER BY c.name -- LIMIT %s OFFSET %s -- """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) -- else: -- cur.execute(""" -- SELECT c.id, c.name, c.email, -- COUNT(l.id) as license_count -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- GROUP BY c.id, c.name, c.email -- ORDER BY c.name -- LIMIT %s OFFSET %s -- """, (per_page, (page - 1) * per_page)) -- -- customers = cur.fetchall() -- -- # Format für Select2 -- results = [] -- for customer in customers: -- results.append({ -- 'id': customer[0], -- 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", -- 'name': customer[1], -- 'email': customer[2], -- 'license_count': customer[3] -- }) -- -- # Gesamtanzahl für Pagination -- if search: -- cur.execute(""" -- SELECT COUNT(*) FROM customers -- WHERE LOWER(name) LIKE LOWER(%s) -- OR LOWER(email) LIKE LOWER(%s) -- """, (f'%{search}%', f'%{search}%')) -- else: -- cur.execute("SELECT COUNT(*) FROM customers") -- -- total_count = cur.fetchone()[0] -- -- cur.close() -- conn.close() -- -- # Select2 Response Format -- return jsonify({ -- 'results': results, -- 'pagination': { -- 'more': (page * per_page) < total_count -- } -- }) -- -- except Exception as e: -- logging.error(f"Fehler bei Kundensuche: {str(e)}") -- return jsonify({ -- 'results': [], -- 'error': 'Fehler bei der Kundensuche' -- }), 500 -- --@app.route("/") --@login_required --def dashboard(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Statistiken abrufen -- # Gesamtanzahl Kunden (ohne Testdaten) -- cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") -- total_customers = cur.fetchone()[0] -- -- # Gesamtanzahl Lizenzen (ohne Testdaten) -- cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") -- total_licenses = cur.fetchone()[0] -- -- # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) -- cur.execute(""" -- SELECT COUNT(*) FROM licenses -- WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE -- """) -- active_licenses = cur.fetchone()[0] -- -- # Aktive Sessions -- cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") -- active_sessions_count = cur.fetchone()[0] -- -- # Abgelaufene Lizenzen (ohne Testdaten) -- cur.execute(""" -- SELECT COUNT(*) FROM licenses -- WHERE valid_until < CURRENT_DATE AND is_test = FALSE -- """) -- expired_licenses = cur.fetchone()[0] -- -- # Deaktivierte Lizenzen (ohne Testdaten) -- cur.execute(""" -- SELECT COUNT(*) FROM licenses -- WHERE is_active = FALSE AND is_test = FALSE -- """) -- inactive_licenses = cur.fetchone()[0] -- -- # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) -- cur.execute(""" -- SELECT COUNT(*) FROM licenses -- WHERE valid_until >= CURRENT_DATE -- AND valid_until < CURRENT_DATE + INTERVAL '30 days' -- AND is_active = TRUE -- AND is_test = FALSE -- """) -- expiring_soon = cur.fetchone()[0] -- -- # Testlizenzen vs Vollversionen (ohne Testdaten) -- cur.execute(""" -- SELECT license_type, COUNT(*) -- FROM licenses -- WHERE is_test = FALSE -- GROUP BY license_type -- """) -- license_types = dict(cur.fetchall()) -- -- # Anzahl Testdaten -- cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") -- test_data_count = cur.fetchone()[0] -- -- # Anzahl Test-Kunden -- cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") -- test_customers_count = cur.fetchone()[0] -- -- # Anzahl Test-Ressourcen -- cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") -- test_resources_count = cur.fetchone()[0] -- -- # Letzte 5 erstellten Lizenzen (ohne Testdaten) -- cur.execute(""" -- SELECT l.id, l.license_key, c.name, l.valid_until, -- CASE -- WHEN l.is_active = FALSE THEN 'deaktiviert' -- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -- ELSE 'aktiv' -- END as status -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- WHERE l.is_test = FALSE -- ORDER BY l.id DESC -- LIMIT 5 -- """) -- recent_licenses = cur.fetchall() -- -- # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) -- cur.execute(""" -- SELECT l.id, l.license_key, c.name, l.valid_until, -- l.valid_until - CURRENT_DATE as days_left -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- WHERE l.valid_until >= CURRENT_DATE -- AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' -- AND l.is_active = TRUE -- AND l.is_test = FALSE -- ORDER BY l.valid_until -- LIMIT 10 -- """) -- expiring_licenses = cur.fetchall() -- -- # Letztes Backup -- cur.execute(""" -- SELECT created_at, filesize, duration_seconds, backup_type, status -- FROM backup_history -- ORDER BY created_at DESC -- LIMIT 1 -- """) -- last_backup_info = cur.fetchone() -- -- # Sicherheitsstatistiken -- # Gesperrte IPs -- cur.execute(""" -- SELECT COUNT(*) FROM login_attempts -- WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP -- """) -- blocked_ips_count = cur.fetchone()[0] -- -- # Fehlversuche heute -- cur.execute(""" -- SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts -- WHERE last_attempt::date = CURRENT_DATE -- """) -- failed_attempts_today = cur.fetchone()[0] -- -- # Letzte 5 Sicherheitsereignisse -- cur.execute(""" -- SELECT -- la.ip_address, -- la.attempt_count, -- la.last_attempt, -- la.blocked_until, -- la.last_username_tried, -- la.last_error_message -- FROM login_attempts la -- ORDER BY la.last_attempt DESC -- LIMIT 5 -- """) -- recent_security_events = [] -- for event in cur.fetchall(): -- recent_security_events.append({ -- 'ip_address': event[0], -- 'attempt_count': event[1], -- 'last_attempt': event[2].strftime('%d.%m %H:%M'), -- 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, -- 'username_tried': event[4], -- 'error_message': event[5] -- }) -- -- # Sicherheitslevel berechnen -- if blocked_ips_count > 5 or failed_attempts_today > 50: -- security_level = 'danger' -- security_level_text = 'KRITISCH' -- elif blocked_ips_count > 2 or failed_attempts_today > 20: -- security_level = 'warning' -- security_level_text = 'ERHÖHT' -- else: -- security_level = 'success' -- security_level_text = 'NORMAL' -- -- # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) -- cur.execute(""" -- SELECT -- resource_type, -- COUNT(*) FILTER (WHERE status = 'available') as available, -- COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -- COUNT(*) as total -- FROM resource_pools -- WHERE is_test = FALSE -- GROUP BY resource_type -- """) -- -- resource_stats = {} -- resource_warning = None -- -- for row in cur.fetchall(): -- available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) -- resource_stats[row[0]] = { -- 'available': row[1], -- 'allocated': row[2], -- 'quarantine': row[3], -- 'total': row[4], -- 'available_percent': available_percent, -- 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' -- } -- -- # Warnung bei niedrigem Bestand -- if row[1] < 50: -- if not resource_warning: -- resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" -- else: -- resource_warning += f" | {row[0].upper()}: {row[1]}" -- -- cur.close() -- conn.close() -- -- stats = { -- 'total_customers': total_customers, -- 'total_licenses': total_licenses, -- 'active_licenses': active_licenses, -- 'expired_licenses': expired_licenses, -- 'inactive_licenses': inactive_licenses, -- 'expiring_soon': expiring_soon, -- 'full_licenses': license_types.get('full', 0), -- 'test_licenses': license_types.get('test', 0), -- 'test_data_count': test_data_count, -- 'test_customers_count': test_customers_count, -- 'test_resources_count': test_resources_count, -- 'recent_licenses': recent_licenses, -- 'expiring_licenses': expiring_licenses, -- 'active_sessions': active_sessions_count, -- 'last_backup': last_backup_info, -- # Sicherheitsstatistiken -- 'blocked_ips_count': blocked_ips_count, -- 'failed_attempts_today': failed_attempts_today, -- 'recent_security_events': recent_security_events, -- 'security_level': security_level, -- 'security_level_text': security_level_text, -- 'resource_stats': resource_stats -- } -- -- return render_template("dashboard.html", -- stats=stats, -- resource_stats=resource_stats, -- resource_warning=resource_warning, -- username=session.get('username')) -- --@app.route("/create", methods=["GET", "POST"]) --@login_required --def create_license(): -- if request.method == "POST": -- customer_id = request.form.get("customer_id") -- license_key = request.form["license_key"].upper() # Immer Großbuchstaben -- license_type = request.form["license_type"] -- valid_from = request.form["valid_from"] -- is_test = request.form.get("is_test") == "on" # Checkbox value -- -- # Berechne valid_until basierend auf Laufzeit -- duration = int(request.form.get("duration", 1)) -- duration_type = request.form.get("duration_type", "years") -- -- from datetime import datetime, timedelta -- from dateutil.relativedelta import relativedelta -- -- start_date = datetime.strptime(valid_from, "%Y-%m-%d") -- -- if duration_type == "days": -- end_date = start_date + timedelta(days=duration) -- elif duration_type == "months": -- end_date = start_date + relativedelta(months=duration) -- else: # years -- end_date = start_date + relativedelta(years=duration) -- -- # Ein Tag abziehen, da der Starttag mitgezählt wird -- end_date = end_date - timedelta(days=1) -- valid_until = end_date.strftime("%Y-%m-%d") -- -- # Validiere License Key Format -- if not validate_license_key(license_key): -- flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') -- return redirect(url_for('create_license')) -- -- # Resource counts -- domain_count = int(request.form.get("domain_count", 1)) -- ipv4_count = int(request.form.get("ipv4_count", 1)) -- phone_count = int(request.form.get("phone_count", 1)) -- device_limit = int(request.form.get("device_limit", 3)) -- -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Prüfe ob neuer Kunde oder bestehender -- if customer_id == "new": -- # Neuer Kunde -- name = request.form.get("customer_name") -- email = request.form.get("email") -- -- if not name: -- flash('Kundenname ist erforderlich!', 'error') -- return redirect(url_for('create_license')) -- -- # Prüfe ob E-Mail bereits existiert -- if email: -- cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) -- existing = cur.fetchone() -- if existing: -- flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') -- return redirect(url_for('create_license')) -- -- # Kunde einfügen (erbt Test-Status von Lizenz) -- cur.execute(""" -- INSERT INTO customers (name, email, is_test, created_at) -- VALUES (%s, %s, %s, NOW()) -- RETURNING id -- """, (name, email, is_test)) -- customer_id = cur.fetchone()[0] -- customer_info = {'name': name, 'email': email, 'is_test': is_test} -- -- # Audit-Log für neuen Kunden -- log_audit('CREATE', 'customer', customer_id, -- new_values={'name': name, 'email': email, 'is_test': is_test}) -- else: -- # Bestehender Kunde - hole Infos für Audit-Log -- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -- customer_data = cur.fetchone() -- if not customer_data: -- flash('Kunde nicht gefunden!', 'error') -- return redirect(url_for('create_license')) -- customer_info = {'name': customer_data[0], 'email': customer_data[1]} -- -- # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren -- if customer_data[2]: # is_test des Kunden -- is_test = True -- -- # Lizenz hinzufügen -- cur.execute(""" -- INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, -- domain_count, ipv4_count, phone_count, device_limit, is_test) -- VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) -- RETURNING id -- """, (license_key, customer_id, license_type, valid_from, valid_until, -- domain_count, ipv4_count, phone_count, device_limit, is_test)) -- license_id = cur.fetchone()[0] -- -- # Ressourcen zuweisen -- try: -- # Prüfe Verfügbarkeit -- cur.execute(""" -- SELECT -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones -- """, (is_test, is_test, is_test)) -- available = cur.fetchone() -- -- if available[0] < domain_count: -- raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") -- if available[1] < ipv4_count: -- raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") -- if available[2] < phone_count: -- raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") -- -- # Domains zuweisen -- if domain_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, domain_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- # IPv4s zuweisen -- if ipv4_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, ipv4_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- # Telefonnummern zuweisen -- if phone_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, phone_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- except ValueError as e: -- conn.rollback() -- flash(str(e), 'error') -- return redirect(url_for('create_license')) -- -- conn.commit() -- -- # Audit-Log -- log_audit('CREATE', 'license', license_id, -- new_values={ -- 'license_key': license_key, -- 'customer_name': customer_info['name'], -- 'customer_email': customer_info['email'], -- 'license_type': license_type, -- 'valid_from': valid_from, -- 'valid_until': valid_until, -- 'device_limit': device_limit, -- 'is_test': is_test -- }) -- -- flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') -- -- except Exception as e: -- conn.rollback() -- logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") -- flash('Fehler beim Erstellen der Lizenz!', 'error') -- finally: -- cur.close() -- conn.close() -- -- # Preserve show_test parameter if present -- redirect_url = "/create" -- if request.args.get('show_test') == 'true': -- redirect_url += "?show_test=true" -- return redirect(redirect_url) -- -- # Unterstützung für vorausgewählten Kunden -- preselected_customer_id = request.args.get('customer_id', type=int) -- return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) -- --@app.route("/batch", methods=["GET", "POST"]) --@login_required --def batch_licenses(): -- """Batch-Generierung mehrerer Lizenzen für einen Kunden""" -- if request.method == "POST": -- # Formulardaten -- customer_id = request.form.get("customer_id") -- license_type = request.form["license_type"] -- quantity = int(request.form["quantity"]) -- valid_from = request.form["valid_from"] -- is_test = request.form.get("is_test") == "on" # Checkbox value -- -- # Berechne valid_until basierend auf Laufzeit -- duration = int(request.form.get("duration", 1)) -- duration_type = request.form.get("duration_type", "years") -- -- from datetime import datetime, timedelta -- from dateutil.relativedelta import relativedelta -- -- start_date = datetime.strptime(valid_from, "%Y-%m-%d") -- -- if duration_type == "days": -- end_date = start_date + timedelta(days=duration) -- elif duration_type == "months": -- end_date = start_date + relativedelta(months=duration) -- else: # years -- end_date = start_date + relativedelta(years=duration) -- -- # Ein Tag abziehen, da der Starttag mitgezählt wird -- end_date = end_date - timedelta(days=1) -- valid_until = end_date.strftime("%Y-%m-%d") -- -- # Resource counts -- domain_count = int(request.form.get("domain_count", 1)) -- ipv4_count = int(request.form.get("ipv4_count", 1)) -- phone_count = int(request.form.get("phone_count", 1)) -- device_limit = int(request.form.get("device_limit", 3)) -- -- # Sicherheitslimit -- if quantity < 1 or quantity > 100: -- flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') -- return redirect(url_for('batch_licenses')) -- -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Prüfe ob neuer Kunde oder bestehender -- if customer_id == "new": -- # Neuer Kunde -- name = request.form.get("customer_name") -- email = request.form.get("email") -- -- if not name: -- flash('Kundenname ist erforderlich!', 'error') -- return redirect(url_for('batch_licenses')) -- -- # Prüfe ob E-Mail bereits existiert -- if email: -- cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) -- existing = cur.fetchone() -- if existing: -- flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') -- return redirect(url_for('batch_licenses')) -- -- # Kunde einfügen (erbt Test-Status von Lizenz) -- cur.execute(""" -- INSERT INTO customers (name, email, is_test, created_at) -- VALUES (%s, %s, %s, NOW()) -- RETURNING id -- """, (name, email, is_test)) -- customer_id = cur.fetchone()[0] -- -- # Audit-Log für neuen Kunden -- log_audit('CREATE', 'customer', customer_id, -- new_values={'name': name, 'email': email, 'is_test': is_test}) -- else: -- # Bestehender Kunde - hole Infos -- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -- customer_data = cur.fetchone() -- if not customer_data: -- flash('Kunde nicht gefunden!', 'error') -- return redirect(url_for('batch_licenses')) -- name = customer_data[0] -- email = customer_data[1] -- -- # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren -- if customer_data[2]: # is_test des Kunden -- is_test = True -- -- # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch -- total_domains_needed = domain_count * quantity -- total_ipv4s_needed = ipv4_count * quantity -- total_phones_needed = phone_count * quantity -- -- cur.execute(""" -- SELECT -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones -- """, (is_test, is_test, is_test)) -- available = cur.fetchone() -- -- if available[0] < total_domains_needed: -- flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') -- return redirect(url_for('batch_licenses')) -- if available[1] < total_ipv4s_needed: -- flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') -- return redirect(url_for('batch_licenses')) -- if available[2] < total_phones_needed: -- flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') -- return redirect(url_for('batch_licenses')) -- -- # Lizenzen generieren und speichern -- generated_licenses = [] -- for i in range(quantity): -- # Eindeutigen Key generieren -- attempts = 0 -- while attempts < 10: -- license_key = generate_license_key(license_type) -- cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) -- if not cur.fetchone(): -- break -- attempts += 1 -- -- # Lizenz einfügen -- cur.execute(""" -- INSERT INTO licenses (license_key, customer_id, license_type, is_test, -- valid_from, valid_until, is_active, -- domain_count, ipv4_count, phone_count, device_limit) -- VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) -- RETURNING id -- """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, -- domain_count, ipv4_count, phone_count, device_limit)) -- license_id = cur.fetchone()[0] -- -- # Ressourcen für diese Lizenz zuweisen -- # Domains -- if domain_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, domain_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- # IPv4s -- if ipv4_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, ipv4_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- # Telefonnummern -- if phone_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, phone_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- generated_licenses.append({ -- 'id': license_id, -- 'key': license_key, -- 'type': license_type -- }) -- -- conn.commit() -- -- # Audit-Log -- log_audit('CREATE_BATCH', 'license', -- new_values={'customer': name, 'quantity': quantity, 'type': license_type}, -- additional_info=f"Batch-Generierung von {quantity} Lizenzen") -- -- # Session für Export speichern -- session['batch_export'] = { -- 'customer': name, -- 'email': email, -- 'licenses': generated_licenses, -- 'valid_from': valid_from, -- 'valid_until': valid_until, -- 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- } -- -- flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') -- return render_template("batch_result.html", -- customer=name, -- email=email, -- licenses=generated_licenses, -- valid_from=valid_from, -- valid_until=valid_until) -- -- except Exception as e: -- conn.rollback() -- logging.error(f"Fehler bei Batch-Generierung: {str(e)}") -- flash('Fehler bei der Batch-Generierung!', 'error') -- return redirect(url_for('batch_licenses')) -- finally: -- cur.close() -- conn.close() -- -- # GET Request -- return render_template("batch_form.html") -- --@app.route("/batch/export") --@login_required --def export_batch(): -- """Exportiert die zuletzt generierten Batch-Lizenzen""" -- batch_data = session.get('batch_export') -- if not batch_data: -- flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') -- return redirect(url_for('batch_licenses')) -- -- # CSV generieren -- output = io.StringIO() -- output.write('\ufeff') # UTF-8 BOM für Excel -- -- # Header -- output.write(f"Kunde: {batch_data['customer']}\n") -- output.write(f"E-Mail: {batch_data['email']}\n") -- output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") -- output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") -- output.write("\n") -- output.write("Nr;Lizenzschlüssel;Typ\n") -- -- # Lizenzen -- for i, license in enumerate(batch_data['licenses'], 1): -- typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" -- output.write(f"{i};{license['key']};{typ_text}\n") -- -- output.seek(0) -- -- # Audit-Log -- log_audit('EXPORT', 'batch_licenses', -- additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8-sig')), -- mimetype='text/csv', -- as_attachment=True, -- download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" -- ) -- --@app.route("/licenses") --@login_required --def licenses(): -- # Redirect zur kombinierten Ansicht -- return redirect("/customers-licenses") -- --@app.route("/license/edit/", methods=["GET", "POST"]) --@login_required --def edit_license(license_id): -- conn = get_connection() -- cur = conn.cursor() -- -- if request.method == "POST": -- # Alte Werte für Audit-Log abrufen -- cur.execute(""" -- SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit -- FROM licenses WHERE id = %s -- """, (license_id,)) -- old_license = cur.fetchone() -- -- # Update license -- license_key = request.form["license_key"] -- license_type = request.form["license_type"] -- valid_from = request.form["valid_from"] -- valid_until = request.form["valid_until"] -- is_active = request.form.get("is_active") == "on" -- is_test = request.form.get("is_test") == "on" -- device_limit = int(request.form.get("device_limit", 3)) -- -- cur.execute(""" -- UPDATE licenses -- SET license_key = %s, license_type = %s, valid_from = %s, -- valid_until = %s, is_active = %s, is_test = %s, device_limit = %s -- WHERE id = %s -- """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) -- -- conn.commit() -- -- # Audit-Log -- log_audit('UPDATE', 'license', license_id, -- old_values={ -- 'license_key': old_license[0], -- 'license_type': old_license[1], -- 'valid_from': str(old_license[2]), -- 'valid_until': str(old_license[3]), -- 'is_active': old_license[4], -- 'is_test': old_license[5], -- 'device_limit': old_license[6] -- }, -- new_values={ -- 'license_key': license_key, -- 'license_type': license_type, -- 'valid_from': valid_from, -- 'valid_until': valid_until, -- 'is_active': is_active, -- 'is_test': is_test, -- 'device_limit': device_limit -- }) -- -- cur.close() -- conn.close() -- -- # Redirect zurück zu customers-licenses mit beibehaltenen Parametern -- redirect_url = "/customers-licenses" -- -- # Behalte show_test Parameter bei (aus Form oder GET-Parameter) -- show_test = request.form.get('show_test') or request.args.get('show_test') -- if show_test == 'true': -- redirect_url += "?show_test=true" -- -- # Behalte customer_id bei wenn vorhanden -- if request.referrer and 'customer_id=' in request.referrer: -- import re -- match = re.search(r'customer_id=(\d+)', request.referrer) -- if match: -- connector = "&" if "?" in redirect_url else "?" -- redirect_url += f"{connector}customer_id={match.group(1)}" -- -- return redirect(redirect_url) -- -- # Get license data -- cur.execute(""" -- SELECT l.id, l.license_key, c.name, c.email, l.license_type, -- l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- WHERE l.id = %s -- """, (license_id,)) -- -- license = cur.fetchone() -- cur.close() -- conn.close() -- -- if not license: -- return redirect("/licenses") -- -- return render_template("edit_license.html", license=license, username=session.get('username')) -- --@app.route("/license/delete/", methods=["POST"]) --@login_required --def delete_license(license_id): -- conn = get_connection() -- cur = conn.cursor() -- -- # Lizenzdetails für Audit-Log abrufen -- cur.execute(""" -- SELECT l.license_key, c.name, l.license_type -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- WHERE l.id = %s -- """, (license_id,)) -- license_info = cur.fetchone() -- -- cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) -- -- conn.commit() -- -- # Audit-Log -- if license_info: -- log_audit('DELETE', 'license', license_id, -- old_values={ -- 'license_key': license_info[0], -- 'customer_name': license_info[1], -- 'license_type': license_info[2] -- }) -- -- cur.close() -- conn.close() -- -- return redirect("/licenses") -- --@app.route("/customers") --@login_required --def customers(): -- # Redirect zur kombinierten Ansicht -- return redirect("/customers-licenses") -- --@app.route("/customer/edit/", methods=["GET", "POST"]) --@login_required --def edit_customer(customer_id): -- conn = get_connection() -- cur = conn.cursor() -- -- if request.method == "POST": -- # Alte Werte für Audit-Log abrufen -- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -- old_customer = cur.fetchone() -- -- # Update customer -- name = request.form["name"] -- email = request.form["email"] -- is_test = request.form.get("is_test") == "on" -- -- cur.execute(""" -- UPDATE customers -- SET name = %s, email = %s, is_test = %s -- WHERE id = %s -- """, (name, email, is_test, customer_id)) -- -- conn.commit() -- -- # Audit-Log -- log_audit('UPDATE', 'customer', customer_id, -- old_values={ -- 'name': old_customer[0], -- 'email': old_customer[1], -- 'is_test': old_customer[2] -- }, -- new_values={ -- 'name': name, -- 'email': email, -- 'is_test': is_test -- }) -- -- cur.close() -- conn.close() -- -- # Redirect zurück zu customers-licenses mit beibehaltenen Parametern -- redirect_url = "/customers-licenses" -- -- # Behalte show_test Parameter bei (aus Form oder GET-Parameter) -- show_test = request.form.get('show_test') or request.args.get('show_test') -- if show_test == 'true': -- redirect_url += "?show_test=true" -- -- # Behalte customer_id bei (immer der aktuelle Kunde) -- connector = "&" if "?" in redirect_url else "?" -- redirect_url += f"{connector}customer_id={customer_id}" -- -- return redirect(redirect_url) -- -- # Get customer data with licenses -- cur.execute(""" -- SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s -- """, (customer_id,)) -- -- customer = cur.fetchone() -- if not customer: -- cur.close() -- conn.close() -- return "Kunde nicht gefunden", 404 -- -- -- # Get customer's licenses -- cur.execute(""" -- SELECT id, license_key, license_type, valid_from, valid_until, is_active -- FROM licenses -- WHERE customer_id = %s -- ORDER BY valid_until DESC -- """, (customer_id,)) -- -- licenses = cur.fetchall() -- -- cur.close() -- conn.close() -- -- if not customer: -- return redirect("/customers-licenses") -- -- return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) -- --@app.route("/customer/create", methods=["GET", "POST"]) --@login_required --def create_customer(): -- """Erstellt einen neuen Kunden ohne Lizenz""" -- if request.method == "POST": -- name = request.form.get('name') -- email = request.form.get('email') -- is_test = request.form.get('is_test') == 'on' -- -- if not name or not email: -- flash("Name und E-Mail sind Pflichtfelder!", "error") -- return render_template("create_customer.html", username=session.get('username')) -- -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Prüfen ob E-Mail bereits existiert -- cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) -- existing = cur.fetchone() -- if existing: -- flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") -- return render_template("create_customer.html", username=session.get('username')) -- -- # Kunde erstellen -- cur.execute(""" -- INSERT INTO customers (name, email, created_at, is_test) -- VALUES (%s, %s, %s, %s) RETURNING id -- """, (name, email, datetime.now(), is_test)) -- -- customer_id = cur.fetchone()[0] -- conn.commit() -- -- # Audit-Log -- log_audit('CREATE', 'customer', customer_id, -- new_values={ -- 'name': name, -- 'email': email, -- 'is_test': is_test -- }) -- -- flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") -- return redirect(f"/customer/edit/{customer_id}") -- -- except Exception as e: -- conn.rollback() -- flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") -- return render_template("create_customer.html", username=session.get('username')) -- finally: -- cur.close() -- conn.close() -- -- # GET Request - Formular anzeigen -- return render_template("create_customer.html", username=session.get('username')) -- --@app.route("/customer/delete/", methods=["POST"]) --@login_required --def delete_customer(customer_id): -- conn = get_connection() -- cur = conn.cursor() -- -- # Prüfen ob Kunde Lizenzen hat -- cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) -- license_count = cur.fetchone()[0] -- -- if license_count > 0: -- # Kunde hat Lizenzen - nicht löschen -- cur.close() -- conn.close() -- return redirect("/customers") -- -- # Kundendetails für Audit-Log abrufen -- cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) -- customer_info = cur.fetchone() -- -- # Kunde löschen wenn keine Lizenzen vorhanden -- cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) -- -- conn.commit() -- -- # Audit-Log -- if customer_info: -- log_audit('DELETE', 'customer', customer_id, -- old_values={ -- 'name': customer_info[0], -- 'email': customer_info[1] -- }) -- -- cur.close() -- conn.close() -- -- return redirect("/customers") -- --@app.route("/customers-licenses") --@login_required --def customers_licenses(): -- """Kombinierte Ansicht für Kunden und deren Lizenzen""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) -- show_test = request.args.get('show_test', 'false').lower() == 'true' -- -- query = """ -- SELECT -- c.id, -- c.name, -- c.email, -- c.created_at, -- COUNT(l.id) as total_licenses, -- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- """ -- -- if not show_test: -- query += " WHERE c.is_test = FALSE" -- -- query += """ -- GROUP BY c.id, c.name, c.email, c.created_at -- ORDER BY c.name -- """ -- -- cur.execute(query) -- customers = cur.fetchall() -- -- # Hole ausgewählten Kunden nur wenn explizit in URL angegeben -- selected_customer_id = request.args.get('customer_id', type=int) -- licenses = [] -- selected_customer = None -- -- if customers and selected_customer_id: -- # Hole Daten des ausgewählten Kunden -- for customer in customers: -- if customer[0] == selected_customer_id: -- selected_customer = customer -- break -- -- # Hole Lizenzen des ausgewählten Kunden -- if selected_customer: -- cur.execute(""" -- SELECT -- l.id, -- l.license_key, -- l.license_type, -- l.valid_from, -- l.valid_until, -- l.is_active, -- CASE -- WHEN l.is_active = FALSE THEN 'deaktiviert' -- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -- ELSE 'aktiv' -- END as status, -- l.domain_count, -- l.ipv4_count, -- l.phone_count, -- l.device_limit, -- (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, -- -- Actual resource counts -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count -- FROM licenses l -- WHERE l.customer_id = %s -- ORDER BY l.created_at DESC, l.id DESC -- """, (selected_customer_id,)) -- licenses = cur.fetchall() -- -- cur.close() -- conn.close() -- -- return render_template("customers_licenses.html", -- customers=customers, -- selected_customer=selected_customer, -- selected_customer_id=selected_customer_id, -- licenses=licenses, -- show_test=show_test) -- --@app.route("/api/customer//licenses") --@login_required --def api_customer_licenses(customer_id): -- """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Hole Lizenzen des Kunden -- cur.execute(""" -- SELECT -- l.id, -- l.license_key, -- l.license_type, -- l.valid_from, -- l.valid_until, -- l.is_active, -- CASE -- WHEN l.is_active = FALSE THEN 'deaktiviert' -- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -- ELSE 'aktiv' -- END as status, -- l.domain_count, -- l.ipv4_count, -- l.phone_count, -- l.device_limit, -- (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, -- -- Actual resource counts -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count -- FROM licenses l -- WHERE l.customer_id = %s -- ORDER BY l.created_at DESC, l.id DESC -- """, (customer_id,)) -- -- licenses = [] -- for row in cur.fetchall(): -- license_id = row[0] -- -- # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz -- cur.execute(""" -- SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at -- FROM resource_pools rp -- JOIN license_resources lr ON rp.id = lr.resource_id -- WHERE lr.license_id = %s AND lr.is_active = true -- ORDER BY rp.resource_type, rp.resource_value -- """, (license_id,)) -- -- resources = { -- 'domains': [], -- 'ipv4s': [], -- 'phones': [] -- } -- -- for res_row in cur.fetchall(): -- resource_info = { -- 'id': res_row[0], -- 'value': res_row[2], -- 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' -- } -- -- if res_row[1] == 'domain': -- resources['domains'].append(resource_info) -- elif res_row[1] == 'ipv4': -- resources['ipv4s'].append(resource_info) -- elif res_row[1] == 'phone': -- resources['phones'].append(resource_info) -- -- licenses.append({ -- 'id': row[0], -- 'license_key': row[1], -- 'license_type': row[2], -- 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', -- 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', -- 'is_active': row[5], -- 'status': row[6], -- 'domain_count': row[7], # limit -- 'ipv4_count': row[8], # limit -- 'phone_count': row[9], # limit -- 'device_limit': row[10], -- 'active_devices': row[11], -- 'actual_domain_count': row[12], # actual count -- 'actual_ipv4_count': row[13], # actual count -- 'actual_phone_count': row[14], # actual count -- 'resources': resources -- }) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'success': True, -- 'licenses': licenses, -- 'count': len(licenses) -- }) -- --@app.route("/api/customer//quick-stats") --@login_required --def api_customer_quick_stats(customer_id): -- """API-Endpoint für Schnellstatistiken eines Kunden""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Hole Kundenstatistiken -- cur.execute(""" -- SELECT -- COUNT(l.id) as total_licenses, -- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, -- COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon -- FROM licenses l -- WHERE l.customer_id = %s -- """, (customer_id,)) -- -- stats = cur.fetchone() -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'success': True, -- 'stats': { -- 'total': stats[0], -- 'active': stats[1], -- 'expired': stats[2], -- 'expiring_soon': stats[3] -- } -- }) -- --@app.route("/api/license//quick-edit", methods=['POST']) --@login_required --def api_license_quick_edit(license_id): -- """API-Endpoint für schnelle Lizenz-Bearbeitung""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- data = request.get_json() -- -- # Hole alte Werte für Audit-Log -- cur.execute(""" -- SELECT is_active, valid_until, license_type -- FROM licenses WHERE id = %s -- """, (license_id,)) -- old_values = cur.fetchone() -- -- if not old_values: -- return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 -- -- # Update-Felder vorbereiten -- updates = [] -- params = [] -- new_values = {} -- -- if 'is_active' in data: -- updates.append("is_active = %s") -- params.append(data['is_active']) -- new_values['is_active'] = data['is_active'] -- -- if 'valid_until' in data: -- updates.append("valid_until = %s") -- params.append(data['valid_until']) -- new_values['valid_until'] = data['valid_until'] -- -- if 'license_type' in data: -- updates.append("license_type = %s") -- params.append(data['license_type']) -- new_values['license_type'] = data['license_type'] -- -- if updates: -- params.append(license_id) -- cur.execute(f""" -- UPDATE licenses -- SET {', '.join(updates)} -- WHERE id = %s -- """, params) -- -- conn.commit() -- -- # Audit-Log -- log_audit('UPDATE', 'license', license_id, -- old_values={ -- 'is_active': old_values[0], -- 'valid_until': old_values[1].isoformat() if old_values[1] else None, -- 'license_type': old_values[2] -- }, -- new_values=new_values) -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True}) -- -- except Exception as e: -- conn.rollback() -- cur.close() -- conn.close() -- return jsonify({'success': False, 'error': str(e)}), 500 -- --@app.route("/api/license//resources") --@login_required --def api_license_resources(license_id): -- """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz -- cur.execute(""" -- SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at -- FROM resource_pools rp -- JOIN license_resources lr ON rp.id = lr.resource_id -- WHERE lr.license_id = %s AND lr.is_active = true -- ORDER BY rp.resource_type, rp.resource_value -- """, (license_id,)) -- -- resources = { -- 'domains': [], -- 'ipv4s': [], -- 'phones': [] -- } -- -- for row in cur.fetchall(): -- resource_info = { -- 'id': row[0], -- 'value': row[2], -- 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' -- } -- -- if row[1] == 'domain': -- resources['domains'].append(resource_info) -- elif row[1] == 'ipv4': -- resources['ipv4s'].append(resource_info) -- elif row[1] == 'phone': -- resources['phones'].append(resource_info) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'success': True, -- 'resources': resources -- }) -- -- except Exception as e: -- cur.close() -- conn.close() -- return jsonify({'success': False, 'error': str(e)}), 500 -- --@app.route("/sessions") --@login_required --def sessions(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Sortierparameter -- active_sort = request.args.get('active_sort', 'last_heartbeat') -- active_order = request.args.get('active_order', 'desc') -- ended_sort = request.args.get('ended_sort', 'ended_at') -- ended_order = request.args.get('ended_order', 'desc') -- -- # Whitelist für erlaubte Sortierfelder - Aktive Sessions -- active_sort_fields = { -- 'customer': 'c.name', -- 'license': 'l.license_key', -- 'ip': 's.ip_address', -- 'started': 's.started_at', -- 'last_heartbeat': 's.last_heartbeat', -- 'inactive': 'minutes_inactive' -- } -- -- # Whitelist für erlaubte Sortierfelder - Beendete Sessions -- ended_sort_fields = { -- 'customer': 'c.name', -- 'license': 'l.license_key', -- 'ip': 's.ip_address', -- 'started': 's.started_at', -- 'ended_at': 's.ended_at', -- 'duration': 'duration_minutes' -- } -- -- # Validierung -- if active_sort not in active_sort_fields: -- active_sort = 'last_heartbeat' -- if ended_sort not in ended_sort_fields: -- ended_sort = 'ended_at' -- if active_order not in ['asc', 'desc']: -- active_order = 'desc' -- if ended_order not in ['asc', 'desc']: -- ended_order = 'desc' -- -- # Aktive Sessions abrufen -- cur.execute(f""" -- SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, -- s.user_agent, s.started_at, s.last_heartbeat, -- EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive -- FROM sessions s -- JOIN licenses l ON s.license_id = l.id -- JOIN customers c ON l.customer_id = c.id -- WHERE s.is_active = TRUE -- ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} -- """) -- active_sessions = cur.fetchall() -- -- # Inaktive Sessions der letzten 24 Stunden -- cur.execute(f""" -- SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, -- s.started_at, s.ended_at, -- EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes -- FROM sessions s -- JOIN licenses l ON s.license_id = l.id -- JOIN customers c ON l.customer_id = c.id -- WHERE s.is_active = FALSE -- AND s.ended_at > NOW() - INTERVAL '24 hours' -- ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} -- LIMIT 50 -- """) -- recent_sessions = cur.fetchall() -- -- cur.close() -- conn.close() -- -- return render_template("sessions.html", -- active_sessions=active_sessions, -- recent_sessions=recent_sessions, -- active_sort=active_sort, -- active_order=active_order, -- ended_sort=ended_sort, -- ended_order=ended_order, -- username=session.get('username')) -- --@app.route("/session/end/", methods=["POST"]) --@login_required --def end_session(session_id): -- conn = get_connection() -- cur = conn.cursor() -- -- # Session beenden -- cur.execute(""" -- UPDATE sessions -- SET is_active = FALSE, ended_at = NOW() -- WHERE id = %s AND is_active = TRUE -- """, (session_id,)) -- -- conn.commit() -- cur.close() -- conn.close() -- -- return redirect("/sessions") -- --@app.route("/export/licenses") --@login_required --def export_licenses(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) -- include_test = request.args.get('include_test', 'false').lower() == 'true' -- customer_id = request.args.get('customer_id', type=int) -- -- query = """ -- SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, -- l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, -- CASE -- WHEN l.is_active = FALSE THEN 'Deaktiviert' -- WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' -- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' -- ELSE 'Aktiv' -- END as status -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- """ -- -- # Build WHERE clause -- where_conditions = [] -- params = [] -- -- if not include_test: -- where_conditions.append("l.is_test = FALSE") -- -- if customer_id: -- where_conditions.append("l.customer_id = %s") -- params.append(customer_id) -- -- if where_conditions: -- query += " WHERE " + " AND ".join(where_conditions) -- -- query += " ORDER BY l.id" -- -- cur.execute(query, params) -- -- # Spaltennamen -- columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', -- 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] -- -- # Daten in DataFrame -- data = cur.fetchall() -- df = pd.DataFrame(data, columns=columns) -- -- # Datumsformatierung -- df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') -- df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') -- -- # Typ und Aktiv Status anpassen -- df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) -- df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) -- df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) -- -- cur.close() -- conn.close() -- -- # Export Format -- export_format = request.args.get('format', 'excel') -- -- # Audit-Log -- log_audit('EXPORT', 'license', -- additional_info=f"Export aller Lizenzen als {export_format.upper()}") -- filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' -- -- if export_format == 'csv': -- # CSV Export -- output = io.StringIO() -- df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') -- output.seek(0) -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8-sig')), -- mimetype='text/csv', -- as_attachment=True, -- download_name=f'{filename}.csv' -- ) -- else: -- # Excel Export -- output = io.BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, sheet_name='Lizenzen', index=False) -- -- # Formatierung -- worksheet = writer.sheets['Lizenzen'] -- for column in worksheet.columns: -- max_length = 0 -- column_letter = column[0].column_letter -- for cell in column: -- try: -- if len(str(cell.value)) > max_length: -- max_length = len(str(cell.value)) -- except: -- pass -- adjusted_width = min(max_length + 2, 50) -- worksheet.column_dimensions[column_letter].width = adjusted_width -- -- output.seek(0) -- -- return send_file( -- output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx' -- ) -- --@app.route("/export/audit") --@login_required --def export_audit(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Holen der Filter-Parameter -- filter_user = request.args.get('user', '') -- filter_action = request.args.get('action', '') -- filter_entity = request.args.get('entity', '') -- export_format = request.args.get('format', 'excel') -- -- # SQL Query mit Filtern -- query = """ -- SELECT id, timestamp, username, action, entity_type, entity_id, -- old_values, new_values, ip_address, user_agent, additional_info -- FROM audit_log -- WHERE 1=1 -- """ -- params = [] -- -- if filter_user: -- query += " AND username ILIKE %s" -- params.append(f'%{filter_user}%') -- -- if filter_action: -- query += " AND action = %s" -- params.append(filter_action) -- -- if filter_entity: -- query += " AND entity_type = %s" -- params.append(filter_entity) -- -- query += " ORDER BY timestamp DESC" -- -- cur.execute(query, params) -- audit_logs = cur.fetchall() -- cur.close() -- conn.close() -- -- # Daten für Export vorbereiten -- data = [] -- for log in audit_logs: -- action_text = { -- 'CREATE': 'Erstellt', -- 'UPDATE': 'Bearbeitet', -- 'DELETE': 'Gelöscht', -- 'LOGIN': 'Anmeldung', -- 'LOGOUT': 'Abmeldung', -- 'AUTO_LOGOUT': 'Auto-Logout', -- 'EXPORT': 'Export', -- 'GENERATE_KEY': 'Key generiert', -- 'CREATE_BATCH': 'Batch erstellt', -- 'BACKUP': 'Backup erstellt', -- 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', -- 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', -- 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', -- 'LOGIN_BLOCKED': 'Login-Blockiert', -- 'RESTORE': 'Wiederhergestellt', -- 'PASSWORD_CHANGE': 'Passwort geändert', -- '2FA_ENABLED': '2FA aktiviert', -- '2FA_DISABLED': '2FA deaktiviert' -- }.get(log[3], log[3]) -- -- data.append({ -- 'ID': log[0], -- 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), -- 'Benutzer': log[2], -- 'Aktion': action_text, -- 'Entität': log[4], -- 'Entität-ID': log[5] or '', -- 'IP-Adresse': log[8] or '', -- 'Zusatzinfo': log[10] or '' -- }) -- -- # DataFrame erstellen -- df = pd.DataFrame(data) -- -- # Timestamp für Dateiname -- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -- filename = f'audit_log_export_{timestamp}' -- -- # Audit Log für Export -- log_audit('EXPORT', 'audit_log', -- additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") -- -- if export_format == 'csv': -- # CSV Export -- output = io.StringIO() -- # UTF-8 BOM für Excel -- output.write('\ufeff') -- df.to_csv(output, index=False, sep=';', encoding='utf-8') -- output.seek(0) -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8')), -- mimetype='text/csv;charset=utf-8', -- as_attachment=True, -- download_name=f'{filename}.csv' -- ) -- else: -- # Excel Export -- output = BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, index=False, sheet_name='Audit Log') -- -- # Spaltenbreiten anpassen -- worksheet = writer.sheets['Audit Log'] -- for idx, col in enumerate(df.columns): -- max_length = max( -- df[col].astype(str).map(len).max(), -- len(col) -- ) + 2 -- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -- -- output.seek(0) -- -- return send_file( -- output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx' -- ) -- --@app.route("/export/customers") --@login_required --def export_customers(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Check if test data should be included -- include_test = request.args.get('include_test', 'false').lower() == 'true' -- -- # Build query based on test data filter -- if include_test: -- # Include all customers -- query = """ -- SELECT c.id, c.name, c.email, c.created_at, c.is_test, -- COUNT(l.id) as total_licenses, -- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- GROUP BY c.id, c.name, c.email, c.created_at, c.is_test -- ORDER BY c.id -- """ -- else: -- # Exclude test customers and test licenses -- query = """ -- SELECT c.id, c.name, c.email, c.created_at, c.is_test, -- COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, -- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, -- COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- WHERE c.is_test = FALSE -- GROUP BY c.id, c.name, c.email, c.created_at, c.is_test -- ORDER BY c.id -- """ -- -- cur.execute(query) -- -- # Spaltennamen -- columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', -- 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] -- -- # Daten in DataFrame -- data = cur.fetchall() -- df = pd.DataFrame(data, columns=columns) -- -- # Datumsformatierung -- df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') -- -- # Testdaten formatting -- df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) -- -- cur.close() -- conn.close() -- -- # Export Format -- export_format = request.args.get('format', 'excel') -- -- # Audit-Log -- log_audit('EXPORT', 'customer', -- additional_info=f"Export aller Kunden als {export_format.upper()}") -- filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' -- -- if export_format == 'csv': -- # CSV Export -- output = io.StringIO() -- df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') -- output.seek(0) -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8-sig')), -- mimetype='text/csv', -- as_attachment=True, -- download_name=f'{filename}.csv' -- ) -- else: -- # Excel Export -- output = io.BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, sheet_name='Kunden', index=False) -- -- # Formatierung -- worksheet = writer.sheets['Kunden'] -- for column in worksheet.columns: -- max_length = 0 -- column_letter = column[0].column_letter -- for cell in column: -- try: -- if len(str(cell.value)) > max_length: -- max_length = len(str(cell.value)) -- except: -- pass -- adjusted_width = min(max_length + 2, 50) -- worksheet.column_dimensions[column_letter].width = adjusted_width -- -- output.seek(0) -- -- return send_file( -- output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx' -- ) -- --@app.route("/export/sessions") --@login_required --def export_sessions(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Holen des Session-Typs (active oder ended) -- session_type = request.args.get('type', 'active') -- export_format = request.args.get('format', 'excel') -- -- # Daten je nach Typ abrufen -- if session_type == 'active': -- # Aktive Lizenz-Sessions -- cur.execute(""" -- SELECT s.id, l.license_key, c.name as customer_name, s.session_id, -- s.started_at, s.last_heartbeat, -- EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, -- s.ip_address, s.user_agent -- FROM sessions s -- JOIN licenses l ON s.license_id = l.id -- JOIN customers c ON l.customer_id = c.id -- WHERE s.is_active = true -- ORDER BY s.last_heartbeat DESC -- """) -- sessions = cur.fetchall() -- -- # Daten für Export vorbereiten -- data = [] -- for sess in sessions: -- duration = sess[6] -- hours = duration // 3600 -- minutes = (duration % 3600) // 60 -- seconds = duration % 60 -- -- data.append({ -- 'Session-ID': sess[0], -- 'Lizenzschlüssel': sess[1], -- 'Kunde': sess[2], -- 'Session-ID (Tech)': sess[3], -- 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), -- 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), -- 'Dauer': f"{hours}h {minutes}m {seconds}s", -- 'IP-Adresse': sess[7], -- 'Browser': sess[8] -- }) -- -- sheet_name = 'Aktive Sessions' -- filename_prefix = 'aktive_sessions' -- else: -- # Beendete Lizenz-Sessions -- cur.execute(""" -- SELECT s.id, l.license_key, c.name as customer_name, s.session_id, -- s.started_at, s.ended_at, -- EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, -- s.ip_address, s.user_agent -- FROM sessions s -- JOIN licenses l ON s.license_id = l.id -- JOIN customers c ON l.customer_id = c.id -- WHERE s.is_active = false AND s.ended_at IS NOT NULL -- ORDER BY s.ended_at DESC -- LIMIT 1000 -- """) -- sessions = cur.fetchall() -- -- # Daten für Export vorbereiten -- data = [] -- for sess in sessions: -- duration = sess[6] if sess[6] else 0 -- hours = duration // 3600 -- minutes = (duration % 3600) // 60 -- seconds = duration % 60 -- -- data.append({ -- 'Session-ID': sess[0], -- 'Lizenzschlüssel': sess[1], -- 'Kunde': sess[2], -- 'Session-ID (Tech)': sess[3], -- 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), -- 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', -- 'Dauer': f"{hours}h {minutes}m {seconds}s", -- 'IP-Adresse': sess[7], -- 'Browser': sess[8] -- }) -- -- sheet_name = 'Beendete Sessions' -- filename_prefix = 'beendete_sessions' -- -- cur.close() -- conn.close() -- -- # DataFrame erstellen -- df = pd.DataFrame(data) -- -- # Timestamp für Dateiname -- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -- filename = f'{filename_prefix}_export_{timestamp}' -- -- # Audit Log für Export -- log_audit('EXPORT', 'sessions', -- additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") -- -- if export_format == 'csv': -- # CSV Export -- output = io.StringIO() -- # UTF-8 BOM für Excel -- output.write('\ufeff') -- df.to_csv(output, index=False, sep=';', encoding='utf-8') -- output.seek(0) -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8')), -- mimetype='text/csv;charset=utf-8', -- as_attachment=True, -- download_name=f'{filename}.csv' -- ) -- else: -- # Excel Export -- output = BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, index=False, sheet_name=sheet_name) -- -- # Spaltenbreiten anpassen -- worksheet = writer.sheets[sheet_name] -- for idx, col in enumerate(df.columns): -- max_length = max( -- df[col].astype(str).map(len).max(), -- len(col) -- ) + 2 -- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -- -- output.seek(0) -- -- return send_file( -- output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx' -- ) -- --@app.route("/export/resources") --@login_required --def export_resources(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Holen der Filter-Parameter -- filter_type = request.args.get('type', '') -- filter_status = request.args.get('status', '') -- search_query = request.args.get('search', '') -- show_test = request.args.get('show_test', 'false').lower() == 'true' -- export_format = request.args.get('format', 'excel') -- -- # SQL Query mit Filtern -- query = """ -- SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, -- r.created_at, r.status_changed_at, -- l.license_key, c.name as customer_name, c.email as customer_email, -- l.license_type -- FROM resource_pools r -- LEFT JOIN licenses l ON r.allocated_to_license = l.id -- LEFT JOIN customers c ON l.customer_id = c.id -- WHERE 1=1 -- """ -- params = [] -- -- # Filter für Testdaten -- if not show_test: -- query += " AND (r.is_test = false OR r.is_test IS NULL)" -- -- # Filter für Ressourcentyp -- if filter_type: -- query += " AND r.resource_type = %s" -- params.append(filter_type) -- -- # Filter für Status -- if filter_status: -- query += " AND r.status = %s" -- params.append(filter_status) -- -- # Suchfilter -- if search_query: -- query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" -- params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) -- -- query += " ORDER BY r.id DESC" -- -- cur.execute(query, params) -- resources = cur.fetchall() -- cur.close() -- conn.close() -- -- # Daten für Export vorbereiten -- data = [] -- for res in resources: -- status_text = { -- 'available': 'Verfügbar', -- 'allocated': 'Zugewiesen', -- 'quarantine': 'Quarantäne' -- }.get(res[3], res[3]) -- -- type_text = { -- 'domain': 'Domain', -- 'ipv4': 'IPv4', -- 'phone': 'Telefon' -- }.get(res[1], res[1]) -- -- data.append({ -- 'ID': res[0], -- 'Typ': type_text, -- 'Ressource': res[2], -- 'Status': status_text, -- 'Lizenzschlüssel': res[7] or '', -- 'Kunde': res[8] or '', -- 'Kunden-Email': res[9] or '', -- 'Lizenztyp': res[10] or '', -- 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', -- 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' -- }) -- -- # DataFrame erstellen -- df = pd.DataFrame(data) -- -- # Timestamp für Dateiname -- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -- filename = f'resources_export_{timestamp}' -- -- # Audit Log für Export -- log_audit('EXPORT', 'resources', -- additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") -- -- if export_format == 'csv': -- # CSV Export -- output = io.StringIO() -- # UTF-8 BOM für Excel -- output.write('\ufeff') -- df.to_csv(output, index=False, sep=';', encoding='utf-8') -- output.seek(0) -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8')), -- mimetype='text/csv;charset=utf-8', -- as_attachment=True, -- download_name=f'{filename}.csv' -- ) -- else: -- # Excel Export -- output = BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, index=False, sheet_name='Resources') -- -- # Spaltenbreiten anpassen -- worksheet = writer.sheets['Resources'] -- for idx, col in enumerate(df.columns): -- max_length = max( -- df[col].astype(str).map(len).max(), -- len(col) -- ) + 2 -- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -- -- output.seek(0) -- -- return send_file( -- output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx' -- ) -- --@app.route("/audit") --@login_required --def audit_log(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Parameter -- filter_user = request.args.get('user', '').strip() -- filter_action = request.args.get('action', '').strip() -- filter_entity = request.args.get('entity', '').strip() -- page = request.args.get('page', 1, type=int) -- sort = request.args.get('sort', 'timestamp') -- order = request.args.get('order', 'desc') -- per_page = 50 -- -- # Whitelist für erlaubte Sortierfelder -- allowed_sort_fields = { -- 'timestamp': 'timestamp', -- 'username': 'username', -- 'action': 'action', -- 'entity': 'entity_type', -- 'ip': 'ip_address' -- } -- -- # Validierung -- if sort not in allowed_sort_fields: -- sort = 'timestamp' -- if order not in ['asc', 'desc']: -- order = 'desc' -- -- sort_field = allowed_sort_fields[sort] -- -- # SQL Query mit optionalen Filtern -- query = """ -- SELECT id, timestamp, username, action, entity_type, entity_id, -- old_values, new_values, ip_address, user_agent, additional_info -- FROM audit_log -- WHERE 1=1 -- """ -- -- params = [] -- -- # Filter -- if filter_user: -- query += " AND LOWER(username) LIKE LOWER(%s)" -- params.append(f'%{filter_user}%') -- -- if filter_action: -- query += " AND action = %s" -- params.append(filter_action) -- -- if filter_entity: -- query += " AND entity_type = %s" -- params.append(filter_entity) -- -- # Gesamtanzahl für Pagination -- count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" -- cur.execute(count_query, params) -- total = cur.fetchone()[0] -- -- # Pagination -- offset = (page - 1) * per_page -- query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" -- params.extend([per_page, offset]) -- -- cur.execute(query, params) -- logs = cur.fetchall() -- -- # JSON-Werte parsen -- parsed_logs = [] -- for log in logs: -- parsed_log = list(log) -- # old_values und new_values sind bereits Dictionaries (JSONB) -- # Keine Konvertierung nötig -- parsed_logs.append(parsed_log) -- -- # Pagination Info -- total_pages = (total + per_page - 1) // per_page -- -- cur.close() -- conn.close() -- -- return render_template("audit_log.html", -- logs=parsed_logs, -- filter_user=filter_user, -- filter_action=filter_action, -- filter_entity=filter_entity, -- page=page, -- total_pages=total_pages, -- total=total, -- sort=sort, -- order=order, -- username=session.get('username')) -- --@app.route("/backups") --@login_required --def backups(): -- """Zeigt die Backup-Historie an""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Letztes erfolgreiches Backup für Dashboard -- cur.execute(""" -- SELECT created_at, filesize, duration_seconds -- FROM backup_history -- WHERE status = 'success' -- ORDER BY created_at DESC -- LIMIT 1 -- """) -- last_backup = cur.fetchone() -- -- # Alle Backups abrufen -- cur.execute(""" -- SELECT id, filename, filesize, backup_type, status, error_message, -- created_at, created_by, tables_count, records_count, -- duration_seconds, is_encrypted -- FROM backup_history -- ORDER BY created_at DESC -- """) -- backups = cur.fetchall() -- -- cur.close() -- conn.close() -- -- return render_template("backups.html", -- backups=backups, -- last_backup=last_backup, -- username=session.get('username')) -- --@app.route("/backup/create", methods=["POST"]) --@login_required --def create_backup_route(): -- """Erstellt ein manuelles Backup""" -- username = session.get('username') -- success, result = create_backup(backup_type="manual", created_by=username) -- -- if success: -- return jsonify({ -- 'success': True, -- 'message': f'Backup erfolgreich erstellt: {result}' -- }) -- else: -- return jsonify({ -- 'success': False, -- 'message': f'Backup fehlgeschlagen: {result}' -- }), 500 -- --@app.route("/backup/restore/", methods=["POST"]) --@login_required --def restore_backup_route(backup_id): -- """Stellt ein Backup wieder her""" -- encryption_key = request.form.get('encryption_key') -- -- success, message = restore_backup(backup_id, encryption_key) -- -- if success: -- return jsonify({ -- 'success': True, -- 'message': message -- }) -- else: -- return jsonify({ -- 'success': False, -- 'message': message -- }), 500 -- --@app.route("/backup/download/") --@login_required --def download_backup(backup_id): -- """Lädt eine Backup-Datei herunter""" -- conn = get_connection() -- cur = conn.cursor() -- -- cur.execute(""" -- SELECT filename, filepath -- FROM backup_history -- WHERE id = %s -- """, (backup_id,)) -- backup_info = cur.fetchone() -- -- cur.close() -- conn.close() -- -- if not backup_info: -- return "Backup nicht gefunden", 404 -- -- filename, filepath = backup_info -- filepath = Path(filepath) -- -- if not filepath.exists(): -- return "Backup-Datei nicht gefunden", 404 -- -- # Audit-Log -- log_audit('DOWNLOAD', 'backup', backup_id, -- additional_info=f"Backup heruntergeladen: {filename}") -- -- return send_file(filepath, as_attachment=True, download_name=filename) -- --@app.route("/backup/delete/", methods=["DELETE"]) --@login_required --def delete_backup(backup_id): -- """Löscht ein Backup""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Backup-Informationen abrufen -- cur.execute(""" -- SELECT filename, filepath -- FROM backup_history -- WHERE id = %s -- """, (backup_id,)) -- backup_info = cur.fetchone() -- -- if not backup_info: -- return jsonify({ -- 'success': False, -- 'message': 'Backup nicht gefunden' -- }), 404 -- -- filename, filepath = backup_info -- filepath = Path(filepath) -- -- # Datei löschen, wenn sie existiert -- if filepath.exists(): -- filepath.unlink() -- -- # Aus Datenbank löschen -- cur.execute(""" -- DELETE FROM backup_history -- WHERE id = %s -- """, (backup_id,)) -- -- conn.commit() -- -- # Audit-Log -- log_audit('DELETE', 'backup', backup_id, -- additional_info=f"Backup gelöscht: {filename}") -- -- return jsonify({ -- 'success': True, -- 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' -- }) -- -- except Exception as e: -- conn.rollback() -- return jsonify({ -- 'success': False, -- 'message': f'Fehler beim Löschen des Backups: {str(e)}' -- }), 500 -- finally: -- cur.close() -- conn.close() -- --@app.route("/security/blocked-ips") --@login_required --def blocked_ips(): -- """Zeigt alle gesperrten IPs an""" -- conn = get_connection() -- cur = conn.cursor() -- -- cur.execute(""" -- SELECT -- ip_address, -- attempt_count, -- first_attempt, -- last_attempt, -- blocked_until, -- last_username_tried, -- last_error_message -- FROM login_attempts -- WHERE blocked_until IS NOT NULL -- ORDER BY blocked_until DESC -- """) -- -- blocked_ips_list = [] -- for ip in cur.fetchall(): -- blocked_ips_list.append({ -- 'ip_address': ip[0], -- 'attempt_count': ip[1], -- 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), -- 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), -- 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), -- 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), -- 'last_username': ip[5], -- 'last_error': ip[6] -- }) -- -- cur.close() -- conn.close() -- -- return render_template("blocked_ips.html", -- blocked_ips=blocked_ips_list, -- username=session.get('username')) -- --@app.route("/security/unblock-ip", methods=["POST"]) --@login_required --def unblock_ip(): -- """Entsperrt eine IP-Adresse""" -- ip_address = request.form.get('ip_address') -- -- if ip_address: -- conn = get_connection() -- cur = conn.cursor() -- -- cur.execute(""" -- UPDATE login_attempts -- SET blocked_until = NULL -- WHERE ip_address = %s -- """, (ip_address,)) -- -- conn.commit() -- cur.close() -- conn.close() -- -- # Audit-Log -- log_audit('UNBLOCK_IP', 'security', -- additional_info=f"IP {ip_address} manuell entsperrt") -- -- return redirect(url_for('blocked_ips')) -- --@app.route("/security/clear-attempts", methods=["POST"]) --@login_required --def clear_attempts(): -- """Löscht alle Login-Versuche für eine IP""" -- ip_address = request.form.get('ip_address') -- -- if ip_address: -- reset_login_attempts(ip_address) -- -- # Audit-Log -- log_audit('CLEAR_ATTEMPTS', 'security', -- additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") -- -- return redirect(url_for('blocked_ips')) -- --# API Endpoints for License Management --@app.route("/api/license//toggle", methods=["POST"]) --@login_required --def toggle_license_api(license_id): -- """Toggle license active status via API""" -- try: -- data = request.get_json() -- is_active = data.get('is_active', False) -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Update license status -- cur.execute(""" -- UPDATE licenses -- SET is_active = %s -- WHERE id = %s -- """, (is_active, license_id)) -- -- conn.commit() -- -- # Log the action -- log_audit('UPDATE', 'license', license_id, -- new_values={'is_active': is_active}, -- additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) -- except Exception as e: -- return jsonify({'success': False, 'message': str(e)}), 500 -- --@app.route("/api/licenses/bulk-activate", methods=["POST"]) --@login_required --def bulk_activate_licenses(): -- """Activate multiple licenses at once""" -- try: -- data = request.get_json() -- license_ids = data.get('ids', []) -- -- if not license_ids: -- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Update all selected licenses (nur Live-Daten) -- cur.execute(""" -- UPDATE licenses -- SET is_active = TRUE -- WHERE id = ANY(%s) AND is_test = FALSE -- """, (license_ids,)) -- -- affected_rows = cur.rowcount -- conn.commit() -- -- # Log the bulk action -- log_audit('BULK_UPDATE', 'licenses', None, -- new_values={'is_active': True, 'count': affected_rows}, -- additional_info=f"{affected_rows} Lizenzen aktiviert") -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) -- except Exception as e: -- return jsonify({'success': False, 'message': str(e)}), 500 -- --@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) --@login_required --def bulk_deactivate_licenses(): -- """Deactivate multiple licenses at once""" -- try: -- data = request.get_json() -- license_ids = data.get('ids', []) -- -- if not license_ids: -- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Update all selected licenses (nur Live-Daten) -- cur.execute(""" -- UPDATE licenses -- SET is_active = FALSE -- WHERE id = ANY(%s) AND is_test = FALSE -- """, (license_ids,)) -- -- affected_rows = cur.rowcount -- conn.commit() -- -- # Log the bulk action -- log_audit('BULK_UPDATE', 'licenses', None, -- new_values={'is_active': False, 'count': affected_rows}, -- additional_info=f"{affected_rows} Lizenzen deaktiviert") -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) -- except Exception as e: -- return jsonify({'success': False, 'message': str(e)}), 500 -- --@app.route("/api/license//devices") --@login_required --def get_license_devices(license_id): -- """Hole alle registrierten Geräte einer Lizenz""" -- try: -- conn = get_connection() -- cur = conn.cursor() -- -- # Prüfe ob Lizenz existiert und hole device_limit -- cur.execute(""" -- SELECT device_limit FROM licenses WHERE id = %s -- """, (license_id,)) -- license_data = cur.fetchone() -- -- if not license_data: -- return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 -- -- device_limit = license_data[0] -- -- # Hole alle Geräte für diese Lizenz -- cur.execute(""" -- SELECT id, hardware_id, device_name, operating_system, -- first_seen, last_seen, is_active, ip_address -- FROM device_registrations -- WHERE license_id = %s -- ORDER BY is_active DESC, last_seen DESC -- """, (license_id,)) -- -- devices = [] -- for row in cur.fetchall(): -- devices.append({ -- 'id': row[0], -- 'hardware_id': row[1], -- 'device_name': row[2] or 'Unbekanntes Gerät', -- 'operating_system': row[3] or 'Unbekannt', -- 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', -- 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', -- 'is_active': row[6], -- 'ip_address': row[7] or '-' -- }) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'success': True, -- 'devices': devices, -- 'device_limit': device_limit, -- 'active_count': sum(1 for d in devices if d['is_active']) -- }) -- -- except Exception as e: -- logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") -- return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 -- --@app.route("/api/license//register-device", methods=["POST"]) --def register_device(license_id): -- """Registriere ein neues Gerät für eine Lizenz""" -- try: -- data = request.get_json() -- hardware_id = data.get('hardware_id') -- device_name = data.get('device_name', '') -- operating_system = data.get('operating_system', '') -- -- if not hardware_id: -- return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Prüfe ob Lizenz existiert und aktiv ist -- cur.execute(""" -- SELECT device_limit, is_active, valid_until -- FROM licenses -- WHERE id = %s -- """, (license_id,)) -- license_data = cur.fetchone() -- -- if not license_data: -- return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 -- -- device_limit, is_active, valid_until = license_data -- -- # Prüfe ob Lizenz aktiv und gültig ist -- if not is_active: -- return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 -- -- if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): -- return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 -- -- # Prüfe ob Gerät bereits registriert ist -- cur.execute(""" -- SELECT id, is_active FROM device_registrations -- WHERE license_id = %s AND hardware_id = %s -- """, (license_id, hardware_id)) -- existing_device = cur.fetchone() -- -- if existing_device: -- device_id, is_device_active = existing_device -- if is_device_active: -- # Gerät ist bereits aktiv, update last_seen -- cur.execute(""" -- UPDATE device_registrations -- SET last_seen = CURRENT_TIMESTAMP, -- ip_address = %s, -- user_agent = %s -- WHERE id = %s -- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -- conn.commit() -- return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) -- else: -- # Gerät war deaktiviert, prüfe ob wir es reaktivieren können -- cur.execute(""" -- SELECT COUNT(*) FROM device_registrations -- WHERE license_id = %s AND is_active = TRUE -- """, (license_id,)) -- active_count = cur.fetchone()[0] -- -- if active_count >= device_limit: -- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 -- -- # Reaktiviere das Gerät -- cur.execute(""" -- UPDATE device_registrations -- SET is_active = TRUE, -- last_seen = CURRENT_TIMESTAMP, -- deactivated_at = NULL, -- deactivated_by = NULL, -- ip_address = %s, -- user_agent = %s -- WHERE id = %s -- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -- conn.commit() -- return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) -- -- # Neues Gerät - prüfe Gerätelimit -- cur.execute(""" -- SELECT COUNT(*) FROM device_registrations -- WHERE license_id = %s AND is_active = TRUE -- """, (license_id,)) -- active_count = cur.fetchone()[0] -- -- if active_count >= device_limit: -- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 -- -- # Registriere neues Gerät -- cur.execute(""" -- INSERT INTO device_registrations -- (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) -- VALUES (%s, %s, %s, %s, %s, %s) -- RETURNING id -- """, (license_id, hardware_id, device_name, operating_system, -- get_client_ip(), request.headers.get('User-Agent', ''))) -- device_id = cur.fetchone()[0] -- -- conn.commit() -- -- # Audit Log -- log_audit('DEVICE_REGISTER', 'device', device_id, -- new_values={'license_id': license_id, 'hardware_id': hardware_id}) -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) -- -- except Exception as e: -- logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") -- return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 -- --@app.route("/api/license//deactivate-device/", methods=["POST"]) --@login_required --def deactivate_device(license_id, device_id): -- """Deaktiviere ein registriertes Gerät""" -- try: -- conn = get_connection() -- cur = conn.cursor() -- -- # Prüfe ob das Gerät zu dieser Lizenz gehört -- cur.execute(""" -- SELECT id FROM device_registrations -- WHERE id = %s AND license_id = %s AND is_active = TRUE -- """, (device_id, license_id)) -- -- if not cur.fetchone(): -- return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 -- -- # Deaktiviere das Gerät -- cur.execute(""" -- UPDATE device_registrations -- SET is_active = FALSE, -- deactivated_at = CURRENT_TIMESTAMP, -- deactivated_by = %s -- WHERE id = %s -- """, (session['username'], device_id)) -- -- conn.commit() -- -- # Audit Log -- log_audit('DEVICE_DEACTIVATE', 'device', device_id, -- old_values={'is_active': True}, -- new_values={'is_active': False}) -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) -- -- except Exception as e: -- logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") -- return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 -- --@app.route("/api/licenses/bulk-delete", methods=["POST"]) --@login_required --def bulk_delete_licenses(): -- """Delete multiple licenses at once""" -- try: -- data = request.get_json() -- license_ids = data.get('ids', []) -- -- if not license_ids: -- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Get license info for audit log (nur Live-Daten) -- cur.execute(""" -- SELECT license_key -- FROM licenses -- WHERE id = ANY(%s) AND is_test = FALSE -- """, (license_ids,)) -- license_keys = [row[0] for row in cur.fetchall()] -- -- # Delete all selected licenses (nur Live-Daten) -- cur.execute(""" -- DELETE FROM licenses -- WHERE id = ANY(%s) AND is_test = FALSE -- """, (license_ids,)) -- -- affected_rows = cur.rowcount -- conn.commit() -- -- # Log the bulk action -- log_audit('BULK_DELETE', 'licenses', None, -- old_values={'license_keys': license_keys, 'count': affected_rows}, -- additional_info=f"{affected_rows} Lizenzen gelöscht") -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) -- except Exception as e: -- return jsonify({'success': False, 'message': str(e)}), 500 -- --# ===================== RESOURCE POOL MANAGEMENT ===================== -- --@app.route('/resources') --@login_required --def resources(): -- """Resource Pool Hauptübersicht""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) -- show_test = request.args.get('show_test', 'false').lower() == 'true' -- -- # Statistiken abrufen -- cur.execute(""" -- SELECT -- resource_type, -- COUNT(*) FILTER (WHERE status = 'available') as available, -- COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -- COUNT(*) as total -- FROM resource_pools -- WHERE is_test = %s -- GROUP BY resource_type -- """, (show_test,)) -- -- stats = {} -- for row in cur.fetchall(): -- stats[row[0]] = { -- 'available': row[1], -- 'allocated': row[2], -- 'quarantine': row[3], -- 'total': row[4], -- 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) -- } -- -- # Letzte Aktivitäten (gefiltert nach Test/Live) -- cur.execute(""" -- SELECT -- rh.action, -- rh.action_by, -- rh.action_at, -- rp.resource_type, -- rp.resource_value, -- rh.details -- FROM resource_history rh -- JOIN resource_pools rp ON rh.resource_id = rp.id -- WHERE rp.is_test = %s -- ORDER BY rh.action_at DESC -- LIMIT 10 -- """, (show_test,)) -- recent_activities = cur.fetchall() -- -- # Ressourcen-Liste mit Pagination -- page = request.args.get('page', 1, type=int) -- per_page = 50 -- offset = (page - 1) * per_page -- -- resource_type = request.args.get('type', '') -- status_filter = request.args.get('status', '') -- search = request.args.get('search', '') -- -- # Sortierung -- sort_by = request.args.get('sort', 'id') -- sort_order = request.args.get('order', 'desc') -- -- # Base Query -- query = """ -- SELECT -- rp.id, -- rp.resource_type, -- rp.resource_value, -- rp.status, -- rp.allocated_to_license, -- l.license_key, -- c.name as customer_name, -- rp.status_changed_at, -- rp.quarantine_reason, -- rp.quarantine_until, -- c.id as customer_id -- FROM resource_pools rp -- LEFT JOIN licenses l ON rp.allocated_to_license = l.id -- LEFT JOIN customers c ON l.customer_id = c.id -- WHERE rp.is_test = %s -- """ -- params = [show_test] -- -- if resource_type: -- query += " AND rp.resource_type = %s" -- params.append(resource_type) -- -- if status_filter: -- query += " AND rp.status = %s" -- params.append(status_filter) -- -- if search: -- query += " AND rp.resource_value ILIKE %s" -- params.append(f'%{search}%') -- -- # Count total -- count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" -- cur.execute(count_query, params) -- total = cur.fetchone()[0] -- total_pages = (total + per_page - 1) // per_page -- -- # Get paginated results with dynamic sorting -- sort_column_map = { -- 'id': 'rp.id', -- 'type': 'rp.resource_type', -- 'resource': 'rp.resource_value', -- 'status': 'rp.status', -- 'assigned': 'c.name', -- 'changed': 'rp.status_changed_at' -- } -- -- sort_column = sort_column_map.get(sort_by, 'rp.id') -- sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' -- -- query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" -- params.extend([per_page, offset]) -- -- cur.execute(query, params) -- resources = cur.fetchall() -- -- cur.close() -- conn.close() -- -- return render_template('resources.html', -- stats=stats, -- resources=resources, -- recent_activities=recent_activities, -- page=page, -- total_pages=total_pages, -- total=total, -- resource_type=resource_type, -- status_filter=status_filter, -- search=search, -- show_test=show_test, -- sort_by=sort_by, -- sort_order=sort_order, -- datetime=datetime, -- timedelta=timedelta) -- --@app.route('/resources/add', methods=['GET', 'POST']) --@login_required --def add_resources(): -- """Ressourcen zum Pool hinzufügen""" -- # Hole show_test Parameter für die Anzeige -- show_test = request.args.get('show_test', 'false').lower() == 'true' -- -- if request.method == 'POST': -- resource_type = request.form.get('resource_type') -- resources_text = request.form.get('resources_text', '') -- is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten -- -- # Parse resources (one per line) -- resources = [r.strip() for r in resources_text.split('\n') if r.strip()] -- -- if not resources: -- flash('Keine Ressourcen angegeben', 'error') -- return redirect(url_for('add_resources', show_test=show_test)) -- -- conn = get_connection() -- cur = conn.cursor() -- -- added = 0 -- duplicates = 0 -- -- for resource_value in resources: -- try: -- cur.execute(""" -- INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) -- VALUES (%s, %s, %s, %s) -- ON CONFLICT (resource_type, resource_value) DO NOTHING -- """, (resource_type, resource_value, session['username'], is_test)) -- -- if cur.rowcount > 0: -- added += 1 -- # Get the inserted ID -- cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", -- (resource_type, resource_value)) -- resource_id = cur.fetchone()[0] -- -- # Log in history -- cur.execute(""" -- INSERT INTO resource_history (resource_id, action, action_by, ip_address) -- VALUES (%s, 'created', %s, %s) -- """, (resource_id, session['username'], get_client_ip())) -- else: -- duplicates += 1 -- -- except Exception as e: -- app.logger.error(f"Error adding resource {resource_value}: {e}") -- -- conn.commit() -- cur.close() -- conn.close() -- -- log_audit('CREATE', 'resource_pool', None, -- new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, -- additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") -- -- flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') -- return redirect(url_for('resources', show_test=show_test)) -- -- return render_template('add_resources.html', show_test=show_test) -- --@app.route('/resources/quarantine/', methods=['POST']) --@login_required --def quarantine_resource(resource_id): -- """Ressource in Quarantäne setzen""" -- reason = request.form.get('reason', 'review') -- until_date = request.form.get('until_date') -- notes = request.form.get('notes', '') -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Get current resource info -- cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) -- resource = cur.fetchone() -- -- if not resource: -- flash('Ressource nicht gefunden', 'error') -- return redirect(url_for('resources')) -- -- old_status = resource[2] -- -- # Update resource -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'quarantine', -- quarantine_reason = %s, -- quarantine_until = %s, -- notes = %s, -- status_changed_at = CURRENT_TIMESTAMP, -- status_changed_by = %s -- WHERE id = %s -- """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) -- -- # Log in history -- cur.execute(""" -- INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) -- VALUES (%s, 'quarantined', %s, %s, %s) -- """, (resource_id, session['username'], get_client_ip(), -- Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) -- -- conn.commit() -- cur.close() -- conn.close() -- -- log_audit('UPDATE', 'resource', resource_id, -- old_values={'status': old_status}, -- new_values={'status': 'quarantine', 'reason': reason}, -- additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") -- -- flash('Ressource in Quarantäne gesetzt', 'success') -- -- # Redirect mit allen aktuellen Filtern -- return redirect(url_for('resources', -- show_test=request.args.get('show_test', request.form.get('show_test', 'false')), -- type=request.args.get('type', request.form.get('type', '')), -- status=request.args.get('status', request.form.get('status', '')), -- search=request.args.get('search', request.form.get('search', '')))) -- --@app.route('/resources/release', methods=['POST']) --@login_required --def release_resources(): -- """Ressourcen aus Quarantäne freigeben""" -- resource_ids = request.form.getlist('resource_ids') -- -- if not resource_ids: -- flash('Keine Ressourcen ausgewählt', 'error') -- return redirect(url_for('resources')) -- -- conn = get_connection() -- cur = conn.cursor() -- -- released = 0 -- for resource_id in resource_ids: -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'available', -- quarantine_reason = NULL, -- quarantine_until = NULL, -- allocated_to_license = NULL, -- status_changed_at = CURRENT_TIMESTAMP, -- status_changed_by = %s -- WHERE id = %s AND status = 'quarantine' -- """, (session['username'], resource_id)) -- -- if cur.rowcount > 0: -- released += 1 -- # Log in history -- cur.execute(""" -- INSERT INTO resource_history (resource_id, action, action_by, ip_address) -- VALUES (%s, 'released', %s, %s) -- """, (resource_id, session['username'], get_client_ip())) -- -- conn.commit() -- cur.close() -- conn.close() -- -- log_audit('UPDATE', 'resource_pool', None, -- new_values={'released': released}, -- additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") -- -- flash(f'{released} Ressourcen freigegeben', 'success') -- -- # Redirect mit allen aktuellen Filtern -- return redirect(url_for('resources', -- show_test=request.args.get('show_test', request.form.get('show_test', 'false')), -- type=request.args.get('type', request.form.get('type', '')), -- status=request.args.get('status', request.form.get('status', '')), -- search=request.args.get('search', request.form.get('search', '')))) -- --@app.route('/api/resources/allocate', methods=['POST']) --@login_required --def allocate_resources_api(): -- """API für Ressourcen-Zuweisung bei Lizenzerstellung""" -- data = request.json -- license_id = data.get('license_id') -- domain_count = data.get('domain_count', 1) -- ipv4_count = data.get('ipv4_count', 1) -- phone_count = data.get('phone_count', 1) -- -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- allocated = {'domains': [], 'ipv4s': [], 'phones': []} -- -- # Allocate domains -- if domain_count > 0: -- cur.execute(""" -- SELECT id, resource_value FROM resource_pools -- WHERE resource_type = 'domain' AND status = 'available' -- LIMIT %s FOR UPDATE -- """, (domain_count,)) -- domains = cur.fetchall() -- -- if len(domains) < domain_count: -- raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") -- -- for domain_id, domain_value in domains: -- # Update resource status -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', -- allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, -- status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], domain_id)) -- -- # Create assignment -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, domain_id, session['username'])) -- -- # Log history -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (domain_id, license_id, session['username'], get_client_ip())) -- -- allocated['domains'].append(domain_value) -- -- # Allocate IPv4s (similar logic) -- if ipv4_count > 0: -- cur.execute(""" -- SELECT id, resource_value FROM resource_pools -- WHERE resource_type = 'ipv4' AND status = 'available' -- LIMIT %s FOR UPDATE -- """, (ipv4_count,)) -- ipv4s = cur.fetchall() -- -- if len(ipv4s) < ipv4_count: -- raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") -- -- for ipv4_id, ipv4_value in ipv4s: -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', -- allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, -- status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], ipv4_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, ipv4_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (ipv4_id, license_id, session['username'], get_client_ip())) -- -- allocated['ipv4s'].append(ipv4_value) -- -- # Allocate phones (similar logic) -- if phone_count > 0: -- cur.execute(""" -- SELECT id, resource_value FROM resource_pools -- WHERE resource_type = 'phone' AND status = 'available' -- LIMIT %s FOR UPDATE -- """, (phone_count,)) -- phones = cur.fetchall() -- -- if len(phones) < phone_count: -- raise ValueError(f"Nicht genügend Telefonnummern verfügbar") -- -- for phone_id, phone_value in phones: -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', -- allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, -- status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], phone_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, phone_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (phone_id, license_id, session['username'], get_client_ip())) -- -- allocated['phones'].append(phone_value) -- -- # Update license resource counts -- cur.execute(""" -- UPDATE licenses -- SET domain_count = %s, -- ipv4_count = %s, -- phone_count = %s -- WHERE id = %s -- """, (domain_count, ipv4_count, phone_count, license_id)) -- -- conn.commit() -- cur.close() -- conn.close() -- -- return jsonify({ -- 'success': True, -- 'allocated': allocated -- }) -- -- except Exception as e: -- conn.rollback() -- cur.close() -- conn.close() -- return jsonify({ -- 'success': False, -- 'error': str(e) -- }), 400 -- --@app.route('/api/resources/check-availability', methods=['GET']) --@login_required --def check_resource_availability(): -- """Prüft verfügbare Ressourcen""" -- resource_type = request.args.get('type', '') -- count = request.args.get('count', 10, type=int) -- show_test = request.args.get('show_test', 'false').lower() == 'true' -- -- conn = get_connection() -- cur = conn.cursor() -- -- if resource_type: -- # Spezifische Ressourcen für einen Typ -- cur.execute(""" -- SELECT id, resource_value -- FROM resource_pools -- WHERE status = 'available' -- AND resource_type = %s -- AND is_test = %s -- ORDER BY resource_value -- LIMIT %s -- """, (resource_type, show_test, count)) -- -- resources = [] -- for row in cur.fetchall(): -- resources.append({ -- 'id': row[0], -- 'value': row[1] -- }) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'available': resources, -- 'type': resource_type, -- 'count': len(resources) -- }) -- else: -- # Zusammenfassung aller Typen -- cur.execute(""" -- SELECT -- resource_type, -- COUNT(*) as available -- FROM resource_pools -- WHERE status = 'available' -- AND is_test = %s -- GROUP BY resource_type -- """, (show_test,)) -- -- availability = {} -- for row in cur.fetchall(): -- availability[row[0]] = row[1] -- -- cur.close() -- conn.close() -- -- return jsonify(availability) -- --@app.route('/api/global-search', methods=['GET']) --@login_required --def global_search(): -- """Global search API endpoint for searching customers and licenses""" -- query = request.args.get('q', '').strip() -- -- if not query or len(query) < 2: -- return jsonify({'customers': [], 'licenses': []}) -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Search pattern with wildcards -- search_pattern = f'%{query}%' -- -- # Search customers -- cur.execute(""" -- SELECT id, name, email, company_name -- FROM customers -- WHERE (LOWER(name) LIKE LOWER(%s) -- OR LOWER(email) LIKE LOWER(%s) -- OR LOWER(company_name) LIKE LOWER(%s)) -- AND is_test = FALSE -- ORDER BY name -- LIMIT 5 -- """, (search_pattern, search_pattern, search_pattern)) -- -- customers = [] -- for row in cur.fetchall(): -- customers.append({ -- 'id': row[0], -- 'name': row[1], -- 'email': row[2], -- 'company_name': row[3] -- }) -- -- # Search licenses -- cur.execute(""" -- SELECT l.id, l.license_key, c.name as customer_name -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- WHERE LOWER(l.license_key) LIKE LOWER(%s) -- AND l.is_test = FALSE -- ORDER BY l.created_at DESC -- LIMIT 5 -- """, (search_pattern,)) -- -- licenses = [] -- for row in cur.fetchall(): -- licenses.append({ -- 'id': row[0], -- 'license_key': row[1], -- 'customer_name': row[2] -- }) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'customers': customers, -- 'licenses': licenses -- }) -- --@app.route('/resources/history/') --@login_required --def resource_history(resource_id): -- """Zeigt die komplette Historie einer Ressource""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Get complete resource info using named columns -- cur.execute(""" -- SELECT id, resource_type, resource_value, status, allocated_to_license, -- status_changed_at, status_changed_by, quarantine_reason, -- quarantine_until, created_at, notes -- FROM resource_pools -- WHERE id = %s -- """, (resource_id,)) -- row = cur.fetchone() -- -- if not row: -- flash('Ressource nicht gefunden', 'error') -- return redirect(url_for('resources')) -- -- # Create resource object with named attributes -- resource = { -- 'id': row[0], -- 'resource_type': row[1], -- 'resource_value': row[2], -- 'status': row[3], -- 'allocated_to_license': row[4], -- 'status_changed_at': row[5], -- 'status_changed_by': row[6], -- 'quarantine_reason': row[7], -- 'quarantine_until': row[8], -- 'created_at': row[9], -- 'notes': row[10] -- } -- -- # Get license info if allocated -- license_info = None -- if resource['allocated_to_license']: -- cur.execute("SELECT license_key FROM licenses WHERE id = %s", -- (resource['allocated_to_license'],)) -- lic = cur.fetchone() -- if lic: -- license_info = {'license_key': lic[0]} -- -- # Get history with named columns -- cur.execute(""" -- SELECT -- rh.action, -- rh.action_by, -- rh.action_at, -- rh.details, -- rh.license_id, -- rh.ip_address -- FROM resource_history rh -- WHERE rh.resource_id = %s -- ORDER BY rh.action_at DESC -- """, (resource_id,)) -- -- history = [] -- for row in cur.fetchall(): -- history.append({ -- 'action': row[0], -- 'action_by': row[1], -- 'action_at': row[2], -- 'details': row[3], -- 'license_id': row[4], -- 'ip_address': row[5] -- }) -- -- cur.close() -- conn.close() -- -- # Convert to object-like for template -- class ResourceObj: -- def __init__(self, data): -- for key, value in data.items(): -- setattr(self, key, value) -- -- resource_obj = ResourceObj(resource) -- history_objs = [ResourceObj(h) for h in history] -- -- return render_template('resource_history.html', -- resource=resource_obj, -- license_info=license_info, -- history=history_objs) -- --@app.route('/resources/metrics') --@login_required --def resources_metrics(): -- """Dashboard für Resource Metrics und Reports""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Overall stats with fallback values -- cur.execute(""" -- SELECT -- COUNT(DISTINCT resource_id) as total_resources, -- COALESCE(AVG(performance_score), 0) as avg_performance, -- COALESCE(SUM(cost), 0) as total_cost, -- COALESCE(SUM(revenue), 0) as total_revenue, -- COALESCE(SUM(issues_count), 0) as total_issues -- FROM resource_metrics -- WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' -- """) -- row = cur.fetchone() -- -- # Calculate ROI -- roi = 0 -- if row[2] > 0: # if total_cost > 0 -- roi = row[3] / row[2] # revenue / cost -- -- stats = { -- 'total_resources': row[0] or 0, -- 'avg_performance': row[1] or 0, -- 'total_cost': row[2] or 0, -- 'total_revenue': row[3] or 0, -- 'total_issues': row[4] or 0, -- 'roi': roi -- } -- -- # Performance by type -- cur.execute(""" -- SELECT -- rp.resource_type, -- COALESCE(AVG(rm.performance_score), 0) as avg_score, -- COUNT(DISTINCT rp.id) as resource_count -- FROM resource_pools rp -- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -- GROUP BY rp.resource_type -- ORDER BY rp.resource_type -- """) -- performance_by_type = cur.fetchall() -- -- # Utilization data -- cur.execute(""" -- SELECT -- resource_type, -- COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -- COUNT(*) as total, -- ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent -- FROM resource_pools -- GROUP BY resource_type -- """) -- utilization_rows = cur.fetchall() -- utilization_data = [ -- { -- 'type': row[0].upper(), -- 'allocated': row[1], -- 'total': row[2], -- 'allocated_percent': row[3] -- } -- for row in utilization_rows -- ] -- -- # Top performing resources -- cur.execute(""" -- SELECT -- rp.id, -- rp.resource_type, -- rp.resource_value, -- COALESCE(AVG(rm.performance_score), 0) as avg_score, -- COALESCE(SUM(rm.revenue), 0) as total_revenue, -- COALESCE(SUM(rm.cost), 1) as total_cost, -- CASE -- WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 -- ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) -- END as roi -- FROM resource_pools rp -- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -- WHERE rp.status != 'quarantine' -- GROUP BY rp.id, rp.resource_type, rp.resource_value -- HAVING AVG(rm.performance_score) IS NOT NULL -- ORDER BY avg_score DESC -- LIMIT 10 -- """) -- top_rows = cur.fetchall() -- top_performers = [ -- { -- 'id': row[0], -- 'resource_type': row[1], -- 'resource_value': row[2], -- 'avg_score': row[3], -- 'roi': row[6] -- } -- for row in top_rows -- ] -- -- # Resources with issues -- cur.execute(""" -- SELECT -- rp.id, -- rp.resource_type, -- rp.resource_value, -- rp.status, -- COALESCE(SUM(rm.issues_count), 0) as total_issues -- FROM resource_pools rp -- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -- WHERE rm.issues_count > 0 OR rp.status = 'quarantine' -- GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status -- HAVING SUM(rm.issues_count) > 0 -- ORDER BY total_issues DESC -- LIMIT 10 -- """) -- problem_rows = cur.fetchall() -- problem_resources = [ -- { -- 'id': row[0], -- 'resource_type': row[1], -- 'resource_value': row[2], -- 'status': row[3], -- 'total_issues': row[4] -- } -- for row in problem_rows -- ] -- -- # Daily metrics for trend chart (last 30 days) -- cur.execute(""" -- SELECT -- metric_date, -- COALESCE(AVG(performance_score), 0) as avg_performance, -- COALESCE(SUM(issues_count), 0) as total_issues -- FROM resource_metrics -- WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' -- GROUP BY metric_date -- ORDER BY metric_date -- """) -- daily_rows = cur.fetchall() -- daily_metrics = [ -- { -- 'date': row[0].strftime('%d.%m'), -- 'performance': float(row[1]), -- 'issues': int(row[2]) -- } -- for row in daily_rows -- ] -- -- cur.close() -- conn.close() -- -- return render_template('resource_metrics.html', -- stats=stats, -- performance_by_type=performance_by_type, -- utilization_data=utilization_data, -- top_performers=top_performers, -- problem_resources=problem_resources, -- daily_metrics=daily_metrics) -- --@app.route('/resources/report', methods=['GET']) --@login_required --def resources_report(): -- """Generiert Ressourcen-Reports oder zeigt Report-Formular""" -- # Prüfe ob Download angefordert wurde -- if request.args.get('download') == 'true': -- report_type = request.args.get('type', 'usage') -- format_type = request.args.get('format', 'excel') -- date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) -- date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) -- -- conn = get_connection() -- cur = conn.cursor() -- -- if report_type == 'usage': -- # Auslastungsreport -- query = """ -- SELECT -- rp.resource_type, -- rp.resource_value, -- rp.status, -- COUNT(DISTINCT rh.license_id) as unique_licenses, -- COUNT(rh.id) as total_allocations, -- MIN(rh.action_at) as first_used, -- MAX(rh.action_at) as last_used -- FROM resource_pools rp -- LEFT JOIN resource_history rh ON rp.id = rh.resource_id -- AND rh.action = 'allocated' -- AND rh.action_at BETWEEN %s AND %s -- GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status -- ORDER BY rp.resource_type, total_allocations DESC -- """ -- cur.execute(query, (date_from, date_to)) -- columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] -- -- elif report_type == 'performance': -- # Performance-Report -- query = """ -- SELECT -- rp.resource_type, -- rp.resource_value, -- AVG(rm.performance_score) as avg_performance, -- SUM(rm.usage_count) as total_usage, -- SUM(rm.revenue) as total_revenue, -- SUM(rm.cost) as total_cost, -- SUM(rm.revenue - rm.cost) as profit, -- SUM(rm.issues_count) as total_issues -- FROM resource_pools rp -- JOIN resource_metrics rm ON rp.id = rm.resource_id -- WHERE rm.metric_date BETWEEN %s AND %s -- GROUP BY rp.id, rp.resource_type, rp.resource_value -- ORDER BY profit DESC -- """ -- cur.execute(query, (date_from, date_to)) -- columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] -- -- elif report_type == 'compliance': -- # Compliance-Report -- query = """ -- SELECT -- rh.action_at, -- rh.action, -- rh.action_by, -- rp.resource_type, -- rp.resource_value, -- l.license_key, -- c.name as customer_name, -- rh.ip_address -- FROM resource_history rh -- JOIN resource_pools rp ON rh.resource_id = rp.id -- LEFT JOIN licenses l ON rh.license_id = l.id -- LEFT JOIN customers c ON l.customer_id = c.id -- WHERE rh.action_at BETWEEN %s AND %s -- ORDER BY rh.action_at DESC -- """ -- cur.execute(query, (date_from, date_to)) -- columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] -- -- else: # inventory report -- # Inventar-Report -- query = """ -- SELECT -- resource_type, -- COUNT(*) FILTER (WHERE status = 'available') as available, -- COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -- COUNT(*) as total -- FROM resource_pools -- GROUP BY resource_type -- ORDER BY resource_type -- """ -- cur.execute(query) -- columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] -- -- # Convert to DataFrame -- data = cur.fetchall() -- df = pd.DataFrame(data, columns=columns) -- -- cur.close() -- conn.close() -- -- # Generate file -- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -- filename = f"resource_report_{report_type}_{timestamp}" -- -- if format_type == 'excel': -- output = io.BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, sheet_name='Report', index=False) -- -- # Auto-adjust columns width -- worksheet = writer.sheets['Report'] -- for column in worksheet.columns: -- max_length = 0 -- column = [cell for cell in column] -- for cell in column: -- try: -- if len(str(cell.value)) > max_length: -- max_length = len(str(cell.value)) -- except: -- pass -- adjusted_width = (max_length + 2) -- worksheet.column_dimensions[column[0].column_letter].width = adjusted_width -- -- output.seek(0) -- -- log_audit('EXPORT', 'resource_report', None, -- new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, -- additional_info=f"Resource Report {report_type} exportiert") -- -- return send_file(output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx') -- -- else: # CSV -- output = io.StringIO() -- df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') -- output.seek(0) -- -- log_audit('EXPORT', 'resource_report', None, -- new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, -- additional_info=f"Resource Report {report_type} exportiert") -- -- return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), -- mimetype='text/csv', -- as_attachment=True, -- download_name=f'{filename}.csv') -- -- # Wenn kein Download, zeige Report-Formular -- return render_template('resource_report.html', -- datetime=datetime, -- timedelta=timedelta, -- username=session.get('username')) -- --if __name__ == "__main__": -- app.run(host="0.0.0.0", port=5000) -+import os -+import psycopg2 -+from psycopg2.extras import Json -+from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -+from flask_session import Session -+from functools import wraps -+from dotenv import load_dotenv -+import pandas as pd -+from datetime import datetime, timedelta -+from zoneinfo import ZoneInfo -+import io -+import subprocess -+import gzip -+from cryptography.fernet import Fernet -+from pathlib import Path -+import time -+from apscheduler.schedulers.background import BackgroundScheduler -+import logging -+import random -+import hashlib -+import requests -+import secrets -+import string -+import re -+import bcrypt -+import pyotp -+import qrcode -+from io import BytesIO -+import base64 -+import json -+from werkzeug.middleware.proxy_fix import ProxyFix -+from openpyxl.utils import get_column_letter -+ -+load_dotenv() -+ -+app = Flask(__name__) -+app.config['SECRET_KEY'] = os.urandom(24) -+app.config['SESSION_TYPE'] = 'filesystem' -+app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8 -+app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8' -+app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=5) # 5 Minuten Session-Timeout -+app.config['SESSION_COOKIE_HTTPONLY'] = True -+app.config['SESSION_COOKIE_SECURE'] = False # Wird auf True gesetzt wenn HTTPS (intern läuft HTTP) -+app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' -+app.config['SESSION_COOKIE_NAME'] = 'admin_session' -+# WICHTIG: Session-Cookie soll auch nach 5 Minuten ablaufen -+app.config['SESSION_REFRESH_EACH_REQUEST'] = False -+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 -+) -+ -+# Backup-Konfiguration -+BACKUP_DIR = Path("/app/backups") -+BACKUP_DIR.mkdir(exist_ok=True) -+ -+# Rate-Limiting Konfiguration -+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 -+ -+# Scheduler für automatische Backups -+scheduler = BackgroundScheduler() -+scheduler.start() -+ -+# Logging konfigurieren -+logging.basicConfig(level=logging.INFO) -+ -+ -+# Login decorator -+def login_required(f): -+ @wraps(f) -+ def decorated_function(*args, **kwargs): -+ if 'logged_in' not in session: -+ return redirect(url_for('login')) -+ -+ # Prüfe ob Session abgelaufen ist -+ 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 -+ app.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 abgelaufen - Logout -+ username = session.get('username', 'unbekannt') -+ app.logger.info(f"Session timeout for user {username} - auto logout") -+ # Audit-Log für automatischen Logout (vor 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('login')) -+ -+ # Aktivität NICHT automatisch aktualisieren -+ # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) -+ return f(*args, **kwargs) -+ return decorated_function -+ -+# DB-Verbindung mit UTF-8 Encoding -+def get_connection(): -+ 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"), -+ options='-c client_encoding=UTF8' -+ ) -+ conn.set_client_encoding('UTF8') -+ return conn -+ -+# User Authentication Helper Functions -+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')) -+ -+def get_user_by_username(username): -+ """Get user from database by username""" -+ conn = get_connection() -+ cur = conn.cursor() -+ try: -+ cur.execute(""" -+ SELECT id, username, password_hash, email, totp_secret, totp_enabled, -+ backup_codes, last_password_change, failed_2fa_attempts -+ FROM users WHERE username = %s -+ """, (username,)) -+ user = cur.fetchone() -+ if user: -+ return { -+ 'id': user[0], -+ 'username': user[1], -+ 'password_hash': user[2], -+ 'email': user[3], -+ 'totp_secret': user[4], -+ 'totp_enabled': user[5], -+ 'backup_codes': user[6], -+ 'last_password_change': user[7], -+ 'failed_2fa_attempts': user[8] -+ } -+ return None -+ finally: -+ cur.close() -+ conn.close() -+ -+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 -+ -+# Audit-Log-Funktion -+def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): -+ """Protokolliert Änderungen im Audit-Log""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ username = session.get('username', 'system') -+ ip_address = get_client_ip() if request else None -+ user_agent = request.headers.get('User-Agent') if request else None -+ -+ # Debug logging -+ app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") -+ -+ # Konvertiere Dictionaries zu JSONB -+ old_json = Json(old_values) if old_values else None -+ new_json = Json(new_values) if new_values else None -+ -+ cur.execute(""" -+ INSERT INTO audit_log -+ (username, action, entity_type, entity_id, old_values, new_values, -+ ip_address, user_agent, additional_info) -+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) -+ """, (username, action, entity_type, entity_id, old_json, new_json, -+ ip_address, user_agent, additional_info)) -+ -+ conn.commit() -+ except Exception as e: -+ print(f"Audit log error: {e}") -+ conn.rollback() -+ finally: -+ cur.close() -+ conn.close() -+ -+# Verschlüsselungs-Funktionen -+def get_or_create_encryption_key(): -+ """Holt oder erstellt einen Verschlüsselungsschlüssel""" -+ key_file = BACKUP_DIR / ".backup_key" -+ -+ # Versuche Key aus Umgebungsvariable zu lesen -+ env_key = os.getenv("BACKUP_ENCRYPTION_KEY") -+ if env_key: -+ try: -+ # Validiere den Key -+ Fernet(env_key.encode()) -+ return env_key.encode() -+ except: -+ pass -+ -+ # Wenn kein gültiger Key in ENV, prüfe Datei -+ if key_file.exists(): -+ return key_file.read_bytes() -+ -+ # Erstelle neuen Key -+ key = Fernet.generate_key() -+ key_file.write_bytes(key) -+ logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt") -+ return key -+ -+# Backup-Funktionen -+def create_backup(backup_type="manual", created_by=None): -+ """Erstellt ein verschlüsseltes Backup der Datenbank""" -+ start_time = time.time() -+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") -+ filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" -+ filepath = BACKUP_DIR / filename -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Backup-Eintrag erstellen -+ cur.execute(""" -+ INSERT INTO backup_history -+ (filename, filepath, backup_type, status, created_by, is_encrypted) -+ VALUES (%s, %s, %s, %s, %s, %s) -+ RETURNING id -+ """, (filename, str(filepath), backup_type, 'in_progress', -+ created_by or 'system', True)) -+ backup_id = cur.fetchone()[0] -+ conn.commit() -+ -+ try: -+ # PostgreSQL Dump erstellen -+ dump_command = [ -+ 'pg_dump', -+ '-h', os.getenv("POSTGRES_HOST", "postgres"), -+ '-p', os.getenv("POSTGRES_PORT", "5432"), -+ '-U', os.getenv("POSTGRES_USER"), -+ '-d', os.getenv("POSTGRES_DB"), -+ '--no-password', -+ '--verbose' -+ ] -+ -+ # PGPASSWORD setzen -+ env = os.environ.copy() -+ env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") -+ -+ # Dump ausführen -+ result = subprocess.run(dump_command, capture_output=True, text=True, env=env) -+ -+ if result.returncode != 0: -+ raise Exception(f"pg_dump failed: {result.stderr}") -+ -+ dump_data = result.stdout.encode('utf-8') -+ -+ # Komprimieren -+ compressed_data = gzip.compress(dump_data) -+ -+ # Verschlüsseln -+ key = get_or_create_encryption_key() -+ f = Fernet(key) -+ encrypted_data = f.encrypt(compressed_data) -+ -+ # Speichern -+ filepath.write_bytes(encrypted_data) -+ -+ # Statistiken sammeln -+ cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") -+ tables_count = cur.fetchone()[0] -+ -+ cur.execute(""" -+ SELECT SUM(n_live_tup) -+ FROM pg_stat_user_tables -+ """) -+ records_count = cur.fetchone()[0] or 0 -+ -+ duration = time.time() - start_time -+ filesize = filepath.stat().st_size -+ -+ # Backup-Eintrag aktualisieren -+ cur.execute(""" -+ UPDATE backup_history -+ SET status = %s, filesize = %s, tables_count = %s, -+ records_count = %s, duration_seconds = %s -+ WHERE id = %s -+ """, ('success', filesize, tables_count, records_count, duration, backup_id)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('BACKUP', 'database', backup_id, -+ additional_info=f"Backup erstellt: {filename} ({filesize} bytes)") -+ -+ # E-Mail-Benachrichtigung (wenn konfiguriert) -+ send_backup_notification(True, filename, filesize, duration) -+ -+ logging.info(f"Backup erfolgreich erstellt: {filename}") -+ return True, filename -+ -+ except Exception as e: -+ # Fehler protokollieren -+ cur.execute(""" -+ UPDATE backup_history -+ SET status = %s, error_message = %s, duration_seconds = %s -+ WHERE id = %s -+ """, ('failed', str(e), time.time() - start_time, backup_id)) -+ conn.commit() -+ -+ logging.error(f"Backup fehlgeschlagen: {e}") -+ send_backup_notification(False, filename, error=str(e)) -+ -+ return False, str(e) -+ -+ finally: -+ cur.close() -+ conn.close() -+ -+def restore_backup(backup_id, encryption_key=None): -+ """Stellt ein Backup wieder her""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Backup-Info abrufen -+ cur.execute(""" -+ SELECT filename, filepath, is_encrypted -+ FROM backup_history -+ WHERE id = %s -+ """, (backup_id,)) -+ backup_info = cur.fetchone() -+ -+ if not backup_info: -+ raise Exception("Backup nicht gefunden") -+ -+ filename, filepath, is_encrypted = backup_info -+ filepath = Path(filepath) -+ -+ if not filepath.exists(): -+ raise Exception("Backup-Datei nicht gefunden") -+ -+ # Datei lesen -+ encrypted_data = filepath.read_bytes() -+ -+ # Entschlüsseln -+ if is_encrypted: -+ key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() -+ try: -+ f = Fernet(key) -+ compressed_data = f.decrypt(encrypted_data) -+ except: -+ raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?") -+ else: -+ compressed_data = encrypted_data -+ -+ # Dekomprimieren -+ dump_data = gzip.decompress(compressed_data) -+ sql_commands = dump_data.decode('utf-8') -+ -+ # Bestehende Verbindungen schließen -+ cur.close() -+ conn.close() -+ -+ # Datenbank wiederherstellen -+ restore_command = [ -+ 'psql', -+ '-h', os.getenv("POSTGRES_HOST", "postgres"), -+ '-p', os.getenv("POSTGRES_PORT", "5432"), -+ '-U', os.getenv("POSTGRES_USER"), -+ '-d', os.getenv("POSTGRES_DB"), -+ '--no-password' -+ ] -+ -+ env = os.environ.copy() -+ env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") -+ -+ result = subprocess.run(restore_command, input=sql_commands, -+ capture_output=True, text=True, env=env) -+ -+ if result.returncode != 0: -+ raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}") -+ -+ # Audit-Log (neue Verbindung) -+ log_audit('RESTORE', 'database', backup_id, -+ additional_info=f"Backup wiederhergestellt: {filename}") -+ -+ return True, "Backup erfolgreich wiederhergestellt" -+ -+ except Exception as e: -+ logging.error(f"Wiederherstellung fehlgeschlagen: {e}") -+ return False, str(e) -+ -+def send_backup_notification(success, filename, filesize=None, duration=None, error=None): -+ """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)""" -+ if not os.getenv("EMAIL_ENABLED", "false").lower() == "true": -+ return -+ -+ # E-Mail-Funktion vorbereitet aber deaktiviert -+ # TODO: Implementieren wenn E-Mail-Server konfiguriert ist -+ logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}") -+ -+# Scheduled Backup Job -+def scheduled_backup(): -+ """Führt ein geplantes Backup aus""" -+ logging.info("Starte geplantes Backup...") -+ create_backup(backup_type="scheduled", created_by="scheduler") -+ -+# Scheduler konfigurieren - täglich um 3:00 Uhr -+scheduler.add_job( -+ scheduled_backup, -+ 'cron', -+ hour=3, -+ minute=0, -+ id='daily_backup', -+ replace_existing=True -+) -+ -+# Rate-Limiting Funktionen -+def get_client_ip(): -+ """Ermittelt die echte IP-Adresse des Clients""" -+ # Debug logging -+ app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") -+ -+ # Try X-Real-IP first (set by nginx) -+ if request.headers.get('X-Real-IP'): -+ return request.headers.get('X-Real-IP') -+ # Then X-Forwarded-For -+ elif request.headers.get('X-Forwarded-For'): -+ # X-Forwarded-For can contain multiple IPs, take the first one -+ return request.headers.get('X-Forwarded-For').split(',')[0].strip() -+ # Fallback to remote_addr -+ else: -+ return request.remote_addr -+ -+def check_ip_blocked(ip_address): -+ """Prüft ob eine IP-Adresse gesperrt ist""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ cur.execute(""" -+ SELECT blocked_until FROM login_attempts -+ WHERE ip_address = %s AND blocked_until IS NOT NULL -+ """, (ip_address,)) -+ -+ result = cur.fetchone() -+ cur.close() -+ conn.close() -+ -+ 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): -+ """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Random Fehlermeldung -+ error_message = random.choice(FAIL_MESSAGES) -+ -+ try: -+ # Prüfen ob IP bereits existiert -+ cur.execute(""" -+ SELECT attempt_count FROM login_attempts -+ WHERE ip_address = %s -+ """, (ip_address,)) -+ -+ result = cur.fetchone() -+ -+ if result: -+ # Update bestehenden Eintrag -+ 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) -+ # E-Mail-Benachrichtigung (wenn aktiviert) -+ if os.getenv("EMAIL_ENABLED", "false").lower() == "true": -+ 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: -+ # Neuen Eintrag erstellen -+ 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: -+ print(f"Rate limiting error: {e}") -+ conn.rollback() -+ finally: -+ cur.close() -+ conn.close() -+ -+ return error_message -+ -+def reset_login_attempts(ip_address): -+ """Setzt die Login-Versuche für eine IP zurück""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ cur.execute(""" -+ DELETE FROM login_attempts -+ WHERE ip_address = %s -+ """, (ip_address,)) -+ conn.commit() -+ except Exception as e: -+ print(f"Reset attempts error: {e}") -+ conn.rollback() -+ finally: -+ cur.close() -+ conn.close() -+ -+def get_login_attempts(ip_address): -+ """Gibt die Anzahl der Login-Versuche für eine IP zurück""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ cur.execute(""" -+ SELECT attempt_count FROM login_attempts -+ WHERE ip_address = %s -+ """, (ip_address,)) -+ -+ result = cur.fetchone() -+ cur.close() -+ conn.close() -+ -+ return result[0] if result else 0 -+ -+def send_security_alert_email(ip_address, username, attempt_count): -+ """Sendet eine Sicherheitswarnung per E-Mail""" -+ 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: E-Mail-Versand implementieren wenn SMTP konfiguriert -+ logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") -+ print(f"E-Mail würde gesendet: {subject}") -+ -+def verify_recaptcha(response): -+ """Verifiziert die reCAPTCHA v2 Response mit Google""" -+ secret_key = os.getenv('RECAPTCHA_SECRET_KEY') -+ -+ # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) -+ if not secret_key: -+ logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") -+ return True -+ -+ # Verifizierung bei Google -+ try: -+ verify_url = 'https://www.google.com/recaptcha/api/siteverify' -+ data = { -+ 'secret': secret_key, -+ 'response': response -+ } -+ -+ # Timeout für Request setzen -+ r = requests.post(verify_url, data=data, timeout=5) -+ result = r.json() -+ -+ # Log für Debugging -+ if not result.get('success'): -+ logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") -+ -+ return result.get('success', False) -+ -+ except requests.exceptions.RequestException as e: -+ logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") -+ # Bei Netzwerkfehlern CAPTCHA als bestanden werten -+ return True -+ except Exception as e: -+ logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") -+ return False -+ -+def generate_license_key(license_type='full'): -+ """ -+ Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ -+ -+ AF = Account Factory (Produktkennung) -+ F/T = F für Fullversion, T für Testversion -+ YYYY = Jahr -+ MM = Monat -+ XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen -+ """ -+ # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) -+ chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' -+ -+ # Datum-Teil -+ now = datetime.now(ZoneInfo("Europe/Berlin")) -+ date_part = now.strftime('%Y%m') -+ type_char = 'F' if license_type == 'full' else 'T' -+ -+ # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) -+ parts = [] -+ for _ in range(3): -+ part = ''.join(secrets.choice(chars) for _ in range(4)) -+ parts.append(part) -+ -+ # Key zusammensetzen -+ key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" -+ -+ return key -+ -+def validate_license_key(key): -+ """ -+ Validiert das License Key Format -+ Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ -+ """ -+ if not key: -+ return False -+ -+ # Pattern für das neue Format -+ # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen -+ pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' -+ -+ # Großbuchstaben für Vergleich -+ return bool(re.match(pattern, key.upper())) -+ -+@app.route("/login", methods=["GET", "POST"]) -+def login(): -+ # Timing-Attack Schutz - Start Zeit merken -+ start_time = time.time() -+ -+ # IP-Adresse ermitteln -+ ip_address = get_client_ip() -+ -+ # Prüfen ob IP gesperrt ist -+ is_blocked, blocked_until = check_ip_blocked(ip_address) -+ if is_blocked: -+ time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 -+ error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." -+ return render_template("login.html", error=error_msg, error_type="blocked") -+ -+ # Anzahl bisheriger Versuche -+ attempt_count = get_login_attempts(ip_address) -+ -+ if request.method == "POST": -+ username = request.form.get("username") -+ password = request.form.get("password") -+ captcha_response = request.form.get("g-recaptcha-response") -+ -+ # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind -+ recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') -+ if attempt_count >= 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, 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, 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 -+ admin1_user = os.getenv("ADMIN1_USERNAME") -+ admin1_pass = os.getenv("ADMIN1_PASSWORD") -+ admin2_user = os.getenv("ADMIN2_USERNAME") -+ admin2_pass = os.getenv("ADMIN2_PASSWORD") -+ -+ if ((username == admin1_user and password == admin1_pass) or -+ (username == admin2_user and password == admin2_pass)): -+ login_success = True -+ -+ # Timing-Attack Schutz - Mindestens 1 Sekunde warten -+ elapsed = time.time() - start_time -+ if elapsed < 1.0: -+ time.sleep(1.0 - elapsed) -+ -+ if login_success: -+ # Erfolgreicher Login -+ if needs_2fa: -+ # Store temporary session for 2FA verification -+ session['temp_username'] = username -+ session['temp_user_id'] = user['id'] -+ session['awaiting_2fa'] = True -+ return redirect(url_for('verify_2fa')) -+ else: -+ # Complete login without 2FA -+ session.permanent = True # Aktiviert das Timeout -+ session['logged_in'] = True -+ session['username'] = username -+ session['user_id'] = user['id'] if user else None -+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ reset_login_attempts(ip_address) -+ log_audit('LOGIN_SUCCESS', 'user', -+ additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") -+ return redirect(url_for('dashboard')) -+ else: -+ # Fehlgeschlagener Login -+ error_message = record_failed_attempt(ip_address, username) -+ new_attempt_count = get_login_attempts(ip_address) -+ -+ # Prüfen ob jetzt gesperrt -+ is_now_blocked, _ = check_ip_blocked(ip_address) -+ if is_now_blocked: -+ log_audit('LOGIN_BLOCKED', 'security', -+ additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") -+ -+ return render_template("login.html", -+ error=error_message, -+ show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), -+ error_type="failed", -+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), -+ recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) -+ -+ # GET Request -+ return render_template("login.html", -+ show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), -+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), -+ recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) -+ -+@app.route("/logout") -+def logout(): -+ username = session.get('username', 'unknown') -+ log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") -+ session.pop('logged_in', None) -+ session.pop('username', None) -+ session.pop('user_id', None) -+ session.pop('temp_username', None) -+ session.pop('temp_user_id', None) -+ session.pop('awaiting_2fa', None) -+ return redirect(url_for('login')) -+ -+@app.route("/verify-2fa", methods=["GET", "POST"]) -+def verify_2fa(): -+ if not session.get('awaiting_2fa'): -+ return redirect(url_for('login')) -+ -+ if request.method == "POST": -+ token = request.form.get('token', '').replace(' ', '') -+ username = session.get('temp_username') -+ user_id = session.get('temp_user_id') -+ -+ if not username or not user_id: -+ flash('Session expired. Please login again.', 'error') -+ return redirect(url_for('login')) -+ -+ user = get_user_by_username(username) -+ if not user: -+ flash('User not found.', 'error') -+ return redirect(url_for('login')) -+ -+ # Check if it's a backup code -+ if len(token) == 8 and token.isupper(): -+ # Try backup code -+ backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] -+ if verify_backup_code(token, backup_codes): -+ # Remove used backup code -+ code_hash = hash_backup_code(token) -+ backup_codes.remove(code_hash) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", -+ (json.dumps(backup_codes), user_id)) -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ # Complete login -+ session.permanent = True -+ session['logged_in'] = True -+ session['username'] = username -+ session['user_id'] = user_id -+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ session.pop('temp_username', None) -+ session.pop('temp_user_id', None) -+ session.pop('awaiting_2fa', None) -+ -+ flash('Login successful using backup code. Please generate new backup codes.', 'warning') -+ log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") -+ return redirect(url_for('dashboard')) -+ else: -+ # Try TOTP token -+ if verify_totp(user['totp_secret'], token): -+ # Complete login -+ session.permanent = True -+ session['logged_in'] = True -+ session['username'] = username -+ session['user_id'] = user_id -+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ session.pop('temp_username', None) -+ session.pop('temp_user_id', None) -+ session.pop('awaiting_2fa', None) -+ -+ log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") -+ return redirect(url_for('dashboard')) -+ -+ # Failed verification -+ conn = get_connection() -+ cur = conn.cursor() -+ cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", -+ (datetime.now(), user_id)) -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ flash('Invalid authentication code. Please try again.', 'error') -+ log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") -+ -+ return render_template('verify_2fa.html') -+ -+@app.route("/profile") -+@login_required -+def profile(): -+ user = get_user_by_username(session['username']) -+ if not user: -+ # For environment-based users, redirect with message -+ flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') -+ return redirect(url_for('dashboard')) -+ return render_template('profile.html', user=user) -+ -+@app.route("/profile/change-password", methods=["POST"]) -+@login_required -+def change_password(): -+ current_password = request.form.get('current_password') -+ new_password = request.form.get('new_password') -+ confirm_password = request.form.get('confirm_password') -+ -+ user = get_user_by_username(session['username']) -+ -+ # Verify current password -+ if not verify_password(current_password, user['password_hash']): -+ flash('Current password is incorrect.', 'error') -+ return redirect(url_for('profile')) -+ -+ # Check new password -+ if new_password != confirm_password: -+ flash('New passwords do not match.', 'error') -+ return redirect(url_for('profile')) -+ -+ if len(new_password) < 8: -+ flash('Password must be at least 8 characters long.', 'error') -+ return redirect(url_for('profile')) -+ -+ # Update password -+ new_hash = hash_password(new_password) -+ conn = get_connection() -+ cur = conn.cursor() -+ cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", -+ (new_hash, datetime.now(), user['id'])) -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], -+ additional_info="Password changed successfully") -+ flash('Password changed successfully.', 'success') -+ return redirect(url_for('profile')) -+ -+@app.route("/profile/setup-2fa") -+@login_required -+def setup_2fa(): -+ user = get_user_by_username(session['username']) -+ -+ if user['totp_enabled']: -+ flash('2FA is already enabled for your account.', 'info') -+ return redirect(url_for('profile')) -+ -+ # Generate new TOTP secret -+ totp_secret = generate_totp_secret() -+ session['temp_totp_secret'] = totp_secret -+ -+ # Generate QR code -+ qr_code = generate_qr_code(user['username'], totp_secret) -+ -+ return render_template('setup_2fa.html', -+ totp_secret=totp_secret, -+ qr_code=qr_code) -+ -+@app.route("/profile/enable-2fa", methods=["POST"]) -+@login_required -+def enable_2fa(): -+ token = request.form.get('token', '').replace(' ', '') -+ totp_secret = session.get('temp_totp_secret') -+ -+ if not totp_secret: -+ flash('2FA setup session expired. Please try again.', 'error') -+ return redirect(url_for('setup_2fa')) -+ -+ # Verify the token -+ if not verify_totp(totp_secret, token): -+ flash('Invalid authentication code. Please try again.', 'error') -+ return redirect(url_for('setup_2fa')) -+ -+ # Generate backup codes -+ backup_codes = generate_backup_codes() -+ hashed_codes = [hash_backup_code(code) for code in backup_codes] -+ -+ # Enable 2FA -+ conn = get_connection() -+ cur = conn.cursor() -+ cur.execute(""" -+ UPDATE users -+ SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s -+ WHERE username = %s -+ """, (totp_secret, json.dumps(hashed_codes), session['username'])) -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ session.pop('temp_totp_secret', None) -+ -+ log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") -+ -+ # Show backup codes -+ return render_template('backup_codes.html', backup_codes=backup_codes) -+ -+@app.route("/profile/disable-2fa", methods=["POST"]) -+@login_required -+def disable_2fa(): -+ password = request.form.get('password') -+ user = get_user_by_username(session['username']) -+ -+ # Verify password -+ if not verify_password(password, user['password_hash']): -+ flash('Incorrect password.', 'error') -+ return redirect(url_for('profile')) -+ -+ # Disable 2FA -+ conn = get_connection() -+ cur = conn.cursor() -+ cur.execute(""" -+ UPDATE users -+ SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL -+ WHERE username = %s -+ """, (session['username'],)) -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") -+ flash('2FA has been disabled for your account.', 'success') -+ return redirect(url_for('profile')) -+ -+@app.route("/heartbeat", methods=['POST']) -+@login_required -+def heartbeat(): -+ """Endpoint für Session Keep-Alive - aktualisiert last_activity""" -+ # Aktualisiere last_activity nur wenn explizit angefordert -+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ # Force session save -+ session.modified = True -+ -+ return jsonify({ -+ 'status': 'ok', -+ 'last_activity': session['last_activity'], -+ 'username': session.get('username') -+ }) -+ -+@app.route("/api/generate-license-key", methods=['POST']) -+@login_required -+def api_generate_key(): -+ """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" -+ try: -+ # Lizenztyp aus Request holen (default: full) -+ data = request.get_json() or {} -+ license_type = data.get('type', 'full') -+ -+ # Key generieren -+ key = generate_license_key(license_type) -+ -+ # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Wiederhole bis eindeutiger Key gefunden -+ attempts = 0 -+ while attempts < 10: # Max 10 Versuche -+ cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) -+ if not cur.fetchone(): -+ break # Key ist eindeutig -+ key = generate_license_key(license_type) -+ attempts += 1 -+ -+ cur.close() -+ conn.close() -+ -+ # Log für Audit -+ log_audit('GENERATE_KEY', 'license', -+ additional_info={'type': license_type, 'key': key}) -+ -+ return jsonify({ -+ 'success': True, -+ 'key': key, -+ 'type': license_type -+ }) -+ -+ except Exception as e: -+ logging.error(f"Fehler bei Key-Generierung: {str(e)}") -+ return jsonify({ -+ 'success': False, -+ 'error': 'Fehler bei der Key-Generierung' -+ }), 500 -+ -+@app.route("/api/customers", methods=['GET']) -+@login_required -+def api_customers(): -+ """API Endpoint für die Kundensuche mit Select2""" -+ try: -+ # Suchparameter -+ search = request.args.get('q', '').strip() -+ page = request.args.get('page', 1, type=int) -+ per_page = 20 -+ customer_id = request.args.get('id', type=int) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Einzelnen Kunden per ID abrufen -+ if customer_id: -+ cur.execute(""" -+ SELECT c.id, c.name, c.email, -+ COUNT(l.id) as license_count -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ WHERE c.id = %s -+ GROUP BY c.id, c.name, c.email -+ """, (customer_id,)) -+ -+ customer = cur.fetchone() -+ results = [] -+ if customer: -+ results.append({ -+ 'id': customer[0], -+ 'text': f"{customer[1]} ({customer[2]})", -+ 'name': customer[1], -+ 'email': customer[2], -+ 'license_count': customer[3] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'results': results, -+ 'pagination': {'more': False} -+ }) -+ -+ # SQL Query mit optionaler Suche -+ elif search: -+ cur.execute(""" -+ SELECT c.id, c.name, c.email, -+ COUNT(l.id) as license_count -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ WHERE LOWER(c.name) LIKE LOWER(%s) -+ OR LOWER(c.email) LIKE LOWER(%s) -+ GROUP BY c.id, c.name, c.email -+ ORDER BY c.name -+ LIMIT %s OFFSET %s -+ """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) -+ else: -+ cur.execute(""" -+ SELECT c.id, c.name, c.email, -+ COUNT(l.id) as license_count -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ GROUP BY c.id, c.name, c.email -+ ORDER BY c.name -+ LIMIT %s OFFSET %s -+ """, (per_page, (page - 1) * per_page)) -+ -+ customers = cur.fetchall() -+ -+ # Format für Select2 -+ results = [] -+ for customer in customers: -+ results.append({ -+ 'id': customer[0], -+ 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", -+ 'name': customer[1], -+ 'email': customer[2], -+ 'license_count': customer[3] -+ }) -+ -+ # Gesamtanzahl für Pagination -+ if search: -+ cur.execute(""" -+ SELECT COUNT(*) FROM customers -+ WHERE LOWER(name) LIKE LOWER(%s) -+ OR LOWER(email) LIKE LOWER(%s) -+ """, (f'%{search}%', f'%{search}%')) -+ else: -+ cur.execute("SELECT COUNT(*) FROM customers") -+ -+ total_count = cur.fetchone()[0] -+ -+ cur.close() -+ conn.close() -+ -+ # Select2 Response Format -+ return jsonify({ -+ 'results': results, -+ 'pagination': { -+ 'more': (page * per_page) < total_count -+ } -+ }) -+ -+ except Exception as e: -+ logging.error(f"Fehler bei Kundensuche: {str(e)}") -+ return jsonify({ -+ 'results': [], -+ 'error': 'Fehler bei der Kundensuche' -+ }), 500 -+ -+@app.route("/") -+@login_required -+def dashboard(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Statistiken abrufen -+ # Gesamtanzahl Kunden (ohne Testdaten) -+ cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") -+ total_customers = cur.fetchone()[0] -+ -+ # Gesamtanzahl Lizenzen (ohne Testdaten) -+ cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") -+ total_licenses = cur.fetchone()[0] -+ -+ # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) -+ cur.execute(""" -+ SELECT COUNT(*) FROM licenses -+ WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE -+ """) -+ active_licenses = cur.fetchone()[0] -+ -+ # Aktive Sessions -+ cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") -+ active_sessions_count = cur.fetchone()[0] -+ -+ # Abgelaufene Lizenzen (ohne Testdaten) -+ cur.execute(""" -+ SELECT COUNT(*) FROM licenses -+ WHERE valid_until < CURRENT_DATE AND is_test = FALSE -+ """) -+ expired_licenses = cur.fetchone()[0] -+ -+ # Deaktivierte Lizenzen (ohne Testdaten) -+ cur.execute(""" -+ SELECT COUNT(*) FROM licenses -+ WHERE is_active = FALSE AND is_test = FALSE -+ """) -+ inactive_licenses = cur.fetchone()[0] -+ -+ # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) -+ cur.execute(""" -+ SELECT COUNT(*) FROM licenses -+ WHERE valid_until >= CURRENT_DATE -+ AND valid_until < CURRENT_DATE + INTERVAL '30 days' -+ AND is_active = TRUE -+ AND is_test = FALSE -+ """) -+ expiring_soon = cur.fetchone()[0] -+ -+ # Testlizenzen vs Vollversionen (ohne Testdaten) -+ cur.execute(""" -+ SELECT license_type, COUNT(*) -+ FROM licenses -+ WHERE is_test = FALSE -+ GROUP BY license_type -+ """) -+ license_types = dict(cur.fetchall()) -+ -+ # Anzahl Testdaten -+ cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") -+ test_data_count = cur.fetchone()[0] -+ -+ # Anzahl Test-Kunden -+ cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") -+ test_customers_count = cur.fetchone()[0] -+ -+ # Anzahl Test-Ressourcen -+ cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") -+ test_resources_count = cur.fetchone()[0] -+ -+ # Letzte 5 erstellten Lizenzen (ohne Testdaten) -+ cur.execute(""" -+ SELECT l.id, l.license_key, c.name, l.valid_until, -+ CASE -+ WHEN l.is_active = FALSE THEN 'deaktiviert' -+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -+ ELSE 'aktiv' -+ END as status -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ WHERE l.is_test = FALSE -+ ORDER BY l.id DESC -+ LIMIT 5 -+ """) -+ recent_licenses = cur.fetchall() -+ -+ # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) -+ cur.execute(""" -+ SELECT l.id, l.license_key, c.name, l.valid_until, -+ l.valid_until - CURRENT_DATE as days_left -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ WHERE l.valid_until >= CURRENT_DATE -+ AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' -+ AND l.is_active = TRUE -+ AND l.is_test = FALSE -+ ORDER BY l.valid_until -+ LIMIT 10 -+ """) -+ expiring_licenses = cur.fetchall() -+ -+ # Letztes Backup -+ cur.execute(""" -+ SELECT created_at, filesize, duration_seconds, backup_type, status -+ FROM backup_history -+ ORDER BY created_at DESC -+ LIMIT 1 -+ """) -+ last_backup_info = cur.fetchone() -+ -+ # Sicherheitsstatistiken -+ # Gesperrte IPs -+ cur.execute(""" -+ SELECT COUNT(*) FROM login_attempts -+ WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP -+ """) -+ blocked_ips_count = cur.fetchone()[0] -+ -+ # Fehlversuche heute -+ cur.execute(""" -+ SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts -+ WHERE last_attempt::date = CURRENT_DATE -+ """) -+ failed_attempts_today = cur.fetchone()[0] -+ -+ # Letzte 5 Sicherheitsereignisse -+ cur.execute(""" -+ SELECT -+ la.ip_address, -+ la.attempt_count, -+ la.last_attempt, -+ la.blocked_until, -+ la.last_username_tried, -+ la.last_error_message -+ FROM login_attempts la -+ ORDER BY la.last_attempt DESC -+ LIMIT 5 -+ """) -+ recent_security_events = [] -+ for event in cur.fetchall(): -+ recent_security_events.append({ -+ 'ip_address': event[0], -+ 'attempt_count': event[1], -+ 'last_attempt': event[2].strftime('%d.%m %H:%M'), -+ 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, -+ 'username_tried': event[4], -+ 'error_message': event[5] -+ }) -+ -+ # Sicherheitslevel berechnen -+ if blocked_ips_count > 5 or failed_attempts_today > 50: -+ security_level = 'danger' -+ security_level_text = 'KRITISCH' -+ elif blocked_ips_count > 2 or failed_attempts_today > 20: -+ security_level = 'warning' -+ security_level_text = 'ERHÖHT' -+ else: -+ security_level = 'success' -+ security_level_text = 'NORMAL' -+ -+ # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) -+ cur.execute(""" -+ SELECT -+ resource_type, -+ COUNT(*) FILTER (WHERE status = 'available') as available, -+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -+ COUNT(*) as total -+ FROM resource_pools -+ WHERE is_test = FALSE -+ GROUP BY resource_type -+ """) -+ -+ resource_stats = {} -+ resource_warning = None -+ -+ for row in cur.fetchall(): -+ available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) -+ resource_stats[row[0]] = { -+ 'available': row[1], -+ 'allocated': row[2], -+ 'quarantine': row[3], -+ 'total': row[4], -+ 'available_percent': available_percent, -+ 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' -+ } -+ -+ # Warnung bei niedrigem Bestand -+ if row[1] < 50: -+ if not resource_warning: -+ resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" -+ else: -+ resource_warning += f" | {row[0].upper()}: {row[1]}" -+ -+ cur.close() -+ conn.close() -+ -+ stats = { -+ 'total_customers': total_customers, -+ 'total_licenses': total_licenses, -+ 'active_licenses': active_licenses, -+ 'expired_licenses': expired_licenses, -+ 'inactive_licenses': inactive_licenses, -+ 'expiring_soon': expiring_soon, -+ 'full_licenses': license_types.get('full', 0), -+ 'test_licenses': license_types.get('test', 0), -+ 'test_data_count': test_data_count, -+ 'test_customers_count': test_customers_count, -+ 'test_resources_count': test_resources_count, -+ 'recent_licenses': recent_licenses, -+ 'expiring_licenses': expiring_licenses, -+ 'active_sessions': active_sessions_count, -+ 'last_backup': last_backup_info, -+ # Sicherheitsstatistiken -+ 'blocked_ips_count': blocked_ips_count, -+ 'failed_attempts_today': failed_attempts_today, -+ 'recent_security_events': recent_security_events, -+ 'security_level': security_level, -+ 'security_level_text': security_level_text, -+ 'resource_stats': resource_stats -+ } -+ -+ return render_template("dashboard.html", -+ stats=stats, -+ resource_stats=resource_stats, -+ resource_warning=resource_warning, -+ username=session.get('username')) -+ -+@app.route("/create", methods=["GET", "POST"]) -+@login_required -+def create_license(): -+ if request.method == "POST": -+ customer_id = request.form.get("customer_id") -+ license_key = request.form["license_key"].upper() # Immer Großbuchstaben -+ license_type = request.form["license_type"] -+ valid_from = request.form["valid_from"] -+ is_test = request.form.get("is_test") == "on" # Checkbox value -+ -+ # Berechne valid_until basierend auf Laufzeit -+ duration = int(request.form.get("duration", 1)) -+ duration_type = request.form.get("duration_type", "years") -+ -+ from datetime import datetime, timedelta -+ from dateutil.relativedelta import relativedelta -+ -+ start_date = datetime.strptime(valid_from, "%Y-%m-%d") -+ -+ if duration_type == "days": -+ end_date = start_date + timedelta(days=duration) -+ elif duration_type == "months": -+ end_date = start_date + relativedelta(months=duration) -+ else: # years -+ end_date = start_date + relativedelta(years=duration) -+ -+ # Ein Tag abziehen, da der Starttag mitgezählt wird -+ end_date = end_date - timedelta(days=1) -+ valid_until = end_date.strftime("%Y-%m-%d") -+ -+ # Validiere License Key Format -+ if not validate_license_key(license_key): -+ flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') -+ return redirect(url_for('create_license')) -+ -+ # Resource counts -+ domain_count = int(request.form.get("domain_count", 1)) -+ ipv4_count = int(request.form.get("ipv4_count", 1)) -+ phone_count = int(request.form.get("phone_count", 1)) -+ device_limit = int(request.form.get("device_limit", 3)) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Prüfe ob neuer Kunde oder bestehender -+ if customer_id == "new": -+ # Neuer Kunde -+ name = request.form.get("customer_name") -+ email = request.form.get("email") -+ -+ if not name: -+ flash('Kundenname ist erforderlich!', 'error') -+ return redirect(url_for('create_license')) -+ -+ # Prüfe ob E-Mail bereits existiert -+ if email: -+ cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) -+ existing = cur.fetchone() -+ if existing: -+ flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') -+ return redirect(url_for('create_license')) -+ -+ # Kunde einfügen (erbt Test-Status von Lizenz) -+ cur.execute(""" -+ INSERT INTO customers (name, email, is_test, created_at) -+ VALUES (%s, %s, %s, NOW()) -+ RETURNING id -+ """, (name, email, is_test)) -+ customer_id = cur.fetchone()[0] -+ customer_info = {'name': name, 'email': email, 'is_test': is_test} -+ -+ # Audit-Log für neuen Kunden -+ log_audit('CREATE', 'customer', customer_id, -+ new_values={'name': name, 'email': email, 'is_test': is_test}) -+ else: -+ # Bestehender Kunde - hole Infos für Audit-Log -+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -+ customer_data = cur.fetchone() -+ if not customer_data: -+ flash('Kunde nicht gefunden!', 'error') -+ return redirect(url_for('create_license')) -+ customer_info = {'name': customer_data[0], 'email': customer_data[1]} -+ -+ # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren -+ if customer_data[2]: # is_test des Kunden -+ is_test = True -+ -+ # Lizenz hinzufügen -+ cur.execute(""" -+ INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, -+ domain_count, ipv4_count, phone_count, device_limit, is_test) -+ VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) -+ RETURNING id -+ """, (license_key, customer_id, license_type, valid_from, valid_until, -+ domain_count, ipv4_count, phone_count, device_limit, is_test)) -+ license_id = cur.fetchone()[0] -+ -+ # Ressourcen zuweisen -+ try: -+ # Prüfe Verfügbarkeit -+ cur.execute(""" -+ SELECT -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones -+ """, (is_test, is_test, is_test)) -+ available = cur.fetchone() -+ -+ if available[0] < domain_count: -+ raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") -+ if available[1] < ipv4_count: -+ raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") -+ if available[2] < phone_count: -+ raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") -+ -+ # Domains zuweisen -+ if domain_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, domain_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ # IPv4s zuweisen -+ if ipv4_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, ipv4_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ # Telefonnummern zuweisen -+ if phone_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, phone_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ except ValueError as e: -+ conn.rollback() -+ flash(str(e), 'error') -+ return redirect(url_for('create_license')) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('CREATE', 'license', license_id, -+ new_values={ -+ 'license_key': license_key, -+ 'customer_name': customer_info['name'], -+ 'customer_email': customer_info['email'], -+ 'license_type': license_type, -+ 'valid_from': valid_from, -+ 'valid_until': valid_until, -+ 'device_limit': device_limit, -+ 'is_test': is_test -+ }) -+ -+ flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') -+ -+ except Exception as e: -+ conn.rollback() -+ logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") -+ flash('Fehler beim Erstellen der Lizenz!', 'error') -+ finally: -+ cur.close() -+ conn.close() -+ -+ # Preserve show_test parameter if present -+ redirect_url = "/create" -+ if request.args.get('show_test') == 'true': -+ redirect_url += "?show_test=true" -+ return redirect(redirect_url) -+ -+ # Unterstützung für vorausgewählten Kunden -+ preselected_customer_id = request.args.get('customer_id', type=int) -+ return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) -+ -+@app.route("/batch", methods=["GET", "POST"]) -+@login_required -+def batch_licenses(): -+ """Batch-Generierung mehrerer Lizenzen für einen Kunden""" -+ if request.method == "POST": -+ # Formulardaten -+ customer_id = request.form.get("customer_id") -+ license_type = request.form["license_type"] -+ quantity = int(request.form["quantity"]) -+ valid_from = request.form["valid_from"] -+ is_test = request.form.get("is_test") == "on" # Checkbox value -+ -+ # Berechne valid_until basierend auf Laufzeit -+ duration = int(request.form.get("duration", 1)) -+ duration_type = request.form.get("duration_type", "years") -+ -+ from datetime import datetime, timedelta -+ from dateutil.relativedelta import relativedelta -+ -+ start_date = datetime.strptime(valid_from, "%Y-%m-%d") -+ -+ if duration_type == "days": -+ end_date = start_date + timedelta(days=duration) -+ elif duration_type == "months": -+ end_date = start_date + relativedelta(months=duration) -+ else: # years -+ end_date = start_date + relativedelta(years=duration) -+ -+ # Ein Tag abziehen, da der Starttag mitgezählt wird -+ end_date = end_date - timedelta(days=1) -+ valid_until = end_date.strftime("%Y-%m-%d") -+ -+ # Resource counts -+ domain_count = int(request.form.get("domain_count", 1)) -+ ipv4_count = int(request.form.get("ipv4_count", 1)) -+ phone_count = int(request.form.get("phone_count", 1)) -+ device_limit = int(request.form.get("device_limit", 3)) -+ -+ # Sicherheitslimit -+ if quantity < 1 or quantity > 100: -+ flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') -+ return redirect(url_for('batch_licenses')) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Prüfe ob neuer Kunde oder bestehender -+ if customer_id == "new": -+ # Neuer Kunde -+ name = request.form.get("customer_name") -+ email = request.form.get("email") -+ -+ if not name: -+ flash('Kundenname ist erforderlich!', 'error') -+ return redirect(url_for('batch_licenses')) -+ -+ # Prüfe ob E-Mail bereits existiert -+ if email: -+ cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) -+ existing = cur.fetchone() -+ if existing: -+ flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') -+ return redirect(url_for('batch_licenses')) -+ -+ # Kunde einfügen (erbt Test-Status von Lizenz) -+ cur.execute(""" -+ INSERT INTO customers (name, email, is_test, created_at) -+ VALUES (%s, %s, %s, NOW()) -+ RETURNING id -+ """, (name, email, is_test)) -+ customer_id = cur.fetchone()[0] -+ -+ # Audit-Log für neuen Kunden -+ log_audit('CREATE', 'customer', customer_id, -+ new_values={'name': name, 'email': email, 'is_test': is_test}) -+ else: -+ # Bestehender Kunde - hole Infos -+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -+ customer_data = cur.fetchone() -+ if not customer_data: -+ flash('Kunde nicht gefunden!', 'error') -+ return redirect(url_for('batch_licenses')) -+ name = customer_data[0] -+ email = customer_data[1] -+ -+ # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren -+ if customer_data[2]: # is_test des Kunden -+ is_test = True -+ -+ # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch -+ total_domains_needed = domain_count * quantity -+ total_ipv4s_needed = ipv4_count * quantity -+ total_phones_needed = phone_count * quantity -+ -+ cur.execute(""" -+ SELECT -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones -+ """, (is_test, is_test, is_test)) -+ available = cur.fetchone() -+ -+ if available[0] < total_domains_needed: -+ flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') -+ return redirect(url_for('batch_licenses')) -+ if available[1] < total_ipv4s_needed: -+ flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') -+ return redirect(url_for('batch_licenses')) -+ if available[2] < total_phones_needed: -+ flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') -+ return redirect(url_for('batch_licenses')) -+ -+ # Lizenzen generieren und speichern -+ generated_licenses = [] -+ for i in range(quantity): -+ # Eindeutigen Key generieren -+ attempts = 0 -+ while attempts < 10: -+ license_key = generate_license_key(license_type) -+ cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) -+ if not cur.fetchone(): -+ break -+ attempts += 1 -+ -+ # Lizenz einfügen -+ cur.execute(""" -+ INSERT INTO licenses (license_key, customer_id, license_type, is_test, -+ valid_from, valid_until, is_active, -+ domain_count, ipv4_count, phone_count, device_limit) -+ VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) -+ RETURNING id -+ """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, -+ domain_count, ipv4_count, phone_count, device_limit)) -+ license_id = cur.fetchone()[0] -+ -+ # Ressourcen für diese Lizenz zuweisen -+ # Domains -+ if domain_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, domain_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ # IPv4s -+ if ipv4_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, ipv4_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ # Telefonnummern -+ if phone_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, phone_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ generated_licenses.append({ -+ 'id': license_id, -+ 'key': license_key, -+ 'type': license_type -+ }) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('CREATE_BATCH', 'license', -+ new_values={'customer': name, 'quantity': quantity, 'type': license_type}, -+ additional_info=f"Batch-Generierung von {quantity} Lizenzen") -+ -+ # Session für Export speichern -+ session['batch_export'] = { -+ 'customer': name, -+ 'email': email, -+ 'licenses': generated_licenses, -+ 'valid_from': valid_from, -+ 'valid_until': valid_until, -+ 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ } -+ -+ flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') -+ return render_template("batch_result.html", -+ customer=name, -+ email=email, -+ licenses=generated_licenses, -+ valid_from=valid_from, -+ valid_until=valid_until) -+ -+ except Exception as e: -+ conn.rollback() -+ logging.error(f"Fehler bei Batch-Generierung: {str(e)}") -+ flash('Fehler bei der Batch-Generierung!', 'error') -+ return redirect(url_for('batch_licenses')) -+ finally: -+ cur.close() -+ conn.close() -+ -+ # GET Request -+ return render_template("batch_form.html") -+ -+@app.route("/batch/export") -+@login_required -+def export_batch(): -+ """Exportiert die zuletzt generierten Batch-Lizenzen""" -+ batch_data = session.get('batch_export') -+ if not batch_data: -+ flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') -+ return redirect(url_for('batch_licenses')) -+ -+ # CSV generieren -+ output = io.StringIO() -+ output.write('\ufeff') # UTF-8 BOM für Excel -+ -+ # Header -+ output.write(f"Kunde: {batch_data['customer']}\n") -+ output.write(f"E-Mail: {batch_data['email']}\n") -+ output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") -+ output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") -+ output.write("\n") -+ output.write("Nr;Lizenzschlüssel;Typ\n") -+ -+ # Lizenzen -+ for i, license in enumerate(batch_data['licenses'], 1): -+ typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" -+ output.write(f"{i};{license['key']};{typ_text}\n") -+ -+ output.seek(0) -+ -+ # Audit-Log -+ log_audit('EXPORT', 'batch_licenses', -+ additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8-sig')), -+ mimetype='text/csv', -+ as_attachment=True, -+ download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" -+ ) -+ -+@app.route("/licenses") -+@login_required -+def licenses(): -+ # Redirect zur kombinierten Ansicht -+ return redirect("/customers-licenses") -+ -+@app.route("/license/edit/", methods=["GET", "POST"]) -+@login_required -+def edit_license(license_id): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ if request.method == "POST": -+ # Alte Werte für Audit-Log abrufen -+ cur.execute(""" -+ SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit -+ FROM licenses WHERE id = %s -+ """, (license_id,)) -+ old_license = cur.fetchone() -+ -+ # Update license -+ license_key = request.form["license_key"] -+ license_type = request.form["license_type"] -+ valid_from = request.form["valid_from"] -+ valid_until = request.form["valid_until"] -+ is_active = request.form.get("is_active") == "on" -+ is_test = request.form.get("is_test") == "on" -+ device_limit = int(request.form.get("device_limit", 3)) -+ -+ cur.execute(""" -+ UPDATE licenses -+ SET license_key = %s, license_type = %s, valid_from = %s, -+ valid_until = %s, is_active = %s, is_test = %s, device_limit = %s -+ WHERE id = %s -+ """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('UPDATE', 'license', license_id, -+ old_values={ -+ 'license_key': old_license[0], -+ 'license_type': old_license[1], -+ 'valid_from': str(old_license[2]), -+ 'valid_until': str(old_license[3]), -+ 'is_active': old_license[4], -+ 'is_test': old_license[5], -+ 'device_limit': old_license[6] -+ }, -+ new_values={ -+ 'license_key': license_key, -+ 'license_type': license_type, -+ 'valid_from': valid_from, -+ 'valid_until': valid_until, -+ 'is_active': is_active, -+ 'is_test': is_test, -+ 'device_limit': device_limit -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ # Redirect zurück zu customers-licenses mit beibehaltenen Parametern -+ redirect_url = "/customers-licenses" -+ -+ # Behalte show_test Parameter bei (aus Form oder GET-Parameter) -+ show_test = request.form.get('show_test') or request.args.get('show_test') -+ if show_test == 'true': -+ redirect_url += "?show_test=true" -+ -+ # Behalte customer_id bei wenn vorhanden -+ if request.referrer and 'customer_id=' in request.referrer: -+ import re -+ match = re.search(r'customer_id=(\d+)', request.referrer) -+ if match: -+ connector = "&" if "?" in redirect_url else "?" -+ redirect_url += f"{connector}customer_id={match.group(1)}" -+ -+ return redirect(redirect_url) -+ -+ # Get license data -+ cur.execute(""" -+ SELECT l.id, l.license_key, c.name, c.email, l.license_type, -+ l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ WHERE l.id = %s -+ """, (license_id,)) -+ -+ license = cur.fetchone() -+ cur.close() -+ conn.close() -+ -+ if not license: -+ return redirect("/licenses") -+ -+ return render_template("edit_license.html", license=license, username=session.get('username')) -+ -+@app.route("/license/delete/", methods=["POST"]) -+@login_required -+def delete_license(license_id): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Lizenzdetails für Audit-Log abrufen -+ cur.execute(""" -+ SELECT l.license_key, c.name, l.license_type -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ WHERE l.id = %s -+ """, (license_id,)) -+ license_info = cur.fetchone() -+ -+ cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ if license_info: -+ log_audit('DELETE', 'license', license_id, -+ old_values={ -+ 'license_key': license_info[0], -+ 'customer_name': license_info[1], -+ 'license_type': license_info[2] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return redirect("/licenses") -+ -+@app.route("/customers") -+@login_required -+def customers(): -+ # Redirect zur kombinierten Ansicht -+ return redirect("/customers-licenses") -+ -+@app.route("/customer/edit/", methods=["GET", "POST"]) -+@login_required -+def edit_customer(customer_id): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ if request.method == "POST": -+ # Alte Werte für Audit-Log abrufen -+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -+ old_customer = cur.fetchone() -+ -+ # Update customer -+ name = request.form["name"] -+ email = request.form["email"] -+ is_test = request.form.get("is_test") == "on" -+ -+ cur.execute(""" -+ UPDATE customers -+ SET name = %s, email = %s, is_test = %s -+ WHERE id = %s -+ """, (name, email, is_test, customer_id)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('UPDATE', 'customer', customer_id, -+ old_values={ -+ 'name': old_customer[0], -+ 'email': old_customer[1], -+ 'is_test': old_customer[2] -+ }, -+ new_values={ -+ 'name': name, -+ 'email': email, -+ 'is_test': is_test -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ # Redirect zurück zu customers-licenses mit beibehaltenen Parametern -+ redirect_url = "/customers-licenses" -+ -+ # Behalte show_test Parameter bei (aus Form oder GET-Parameter) -+ show_test = request.form.get('show_test') or request.args.get('show_test') -+ if show_test == 'true': -+ redirect_url += "?show_test=true" -+ -+ # Behalte customer_id bei (immer der aktuelle Kunde) -+ connector = "&" if "?" in redirect_url else "?" -+ redirect_url += f"{connector}customer_id={customer_id}" -+ -+ return redirect(redirect_url) -+ -+ # Get customer data with licenses -+ cur.execute(""" -+ SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s -+ """, (customer_id,)) -+ -+ customer = cur.fetchone() -+ if not customer: -+ cur.close() -+ conn.close() -+ return "Kunde nicht gefunden", 404 -+ -+ -+ # Get customer's licenses -+ cur.execute(""" -+ SELECT id, license_key, license_type, valid_from, valid_until, is_active -+ FROM licenses -+ WHERE customer_id = %s -+ ORDER BY valid_until DESC -+ """, (customer_id,)) -+ -+ licenses = cur.fetchall() -+ -+ cur.close() -+ conn.close() -+ -+ if not customer: -+ return redirect("/customers-licenses") -+ -+ return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) -+ -+@app.route("/customer/create", methods=["GET", "POST"]) -+@login_required -+def create_customer(): -+ """Erstellt einen neuen Kunden ohne Lizenz""" -+ if request.method == "POST": -+ name = request.form.get('name') -+ email = request.form.get('email') -+ is_test = request.form.get('is_test') == 'on' -+ -+ if not name or not email: -+ flash("Name und E-Mail sind Pflichtfelder!", "error") -+ return render_template("create_customer.html", username=session.get('username')) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Prüfen ob E-Mail bereits existiert -+ cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) -+ existing = cur.fetchone() -+ if existing: -+ flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") -+ return render_template("create_customer.html", username=session.get('username')) -+ -+ # Kunde erstellen -+ cur.execute(""" -+ INSERT INTO customers (name, email, created_at, is_test) -+ VALUES (%s, %s, %s, %s) RETURNING id -+ """, (name, email, datetime.now(), is_test)) -+ -+ customer_id = cur.fetchone()[0] -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('CREATE', 'customer', customer_id, -+ new_values={ -+ 'name': name, -+ 'email': email, -+ 'is_test': is_test -+ }) -+ -+ flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") -+ return redirect(f"/customer/edit/{customer_id}") -+ -+ except Exception as e: -+ conn.rollback() -+ flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") -+ return render_template("create_customer.html", username=session.get('username')) -+ finally: -+ cur.close() -+ conn.close() -+ -+ # GET Request - Formular anzeigen -+ return render_template("create_customer.html", username=session.get('username')) -+ -+@app.route("/customer/delete/", methods=["POST"]) -+@login_required -+def delete_customer(customer_id): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Prüfen ob Kunde Lizenzen hat -+ cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) -+ license_count = cur.fetchone()[0] -+ -+ if license_count > 0: -+ # Kunde hat Lizenzen - nicht löschen -+ cur.close() -+ conn.close() -+ return redirect("/customers") -+ -+ # Kundendetails für Audit-Log abrufen -+ cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) -+ customer_info = cur.fetchone() -+ -+ # Kunde löschen wenn keine Lizenzen vorhanden -+ cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ if customer_info: -+ log_audit('DELETE', 'customer', customer_id, -+ old_values={ -+ 'name': customer_info[0], -+ 'email': customer_info[1] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return redirect("/customers") -+ -+@app.route("/customers-licenses") -+@login_required -+def customers_licenses(): -+ """Kombinierte Ansicht für Kunden und deren Lizenzen""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) -+ show_test = request.args.get('show_test', 'false').lower() == 'true' -+ -+ query = """ -+ SELECT -+ c.id, -+ c.name, -+ c.email, -+ c.created_at, -+ COUNT(l.id) as total_licenses, -+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ """ -+ -+ if not show_test: -+ query += " WHERE c.is_test = FALSE" -+ -+ query += """ -+ GROUP BY c.id, c.name, c.email, c.created_at -+ ORDER BY c.name -+ """ -+ -+ cur.execute(query) -+ customers = cur.fetchall() -+ -+ # Hole ausgewählten Kunden nur wenn explizit in URL angegeben -+ selected_customer_id = request.args.get('customer_id', type=int) -+ licenses = [] -+ selected_customer = None -+ -+ if customers and selected_customer_id: -+ # Hole Daten des ausgewählten Kunden -+ for customer in customers: -+ if customer[0] == selected_customer_id: -+ selected_customer = customer -+ break -+ -+ # Hole Lizenzen des ausgewählten Kunden -+ if selected_customer: -+ cur.execute(""" -+ SELECT -+ l.id, -+ l.license_key, -+ l.license_type, -+ l.valid_from, -+ l.valid_until, -+ l.is_active, -+ CASE -+ WHEN l.is_active = FALSE THEN 'deaktiviert' -+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -+ ELSE 'aktiv' -+ END as status, -+ l.domain_count, -+ l.ipv4_count, -+ l.phone_count, -+ l.device_limit, -+ (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, -+ -- Actual resource counts -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count -+ FROM licenses l -+ WHERE l.customer_id = %s -+ ORDER BY l.created_at DESC, l.id DESC -+ """, (selected_customer_id,)) -+ licenses = cur.fetchall() -+ -+ cur.close() -+ conn.close() -+ -+ return render_template("customers_licenses.html", -+ customers=customers, -+ selected_customer=selected_customer, -+ selected_customer_id=selected_customer_id, -+ licenses=licenses, -+ show_test=show_test) -+ -+@app.route("/api/customer//licenses") -+@login_required -+def api_customer_licenses(customer_id): -+ """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Hole Lizenzen des Kunden -+ cur.execute(""" -+ SELECT -+ l.id, -+ l.license_key, -+ l.license_type, -+ l.valid_from, -+ l.valid_until, -+ l.is_active, -+ CASE -+ WHEN l.is_active = FALSE THEN 'deaktiviert' -+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -+ ELSE 'aktiv' -+ END as status, -+ l.domain_count, -+ l.ipv4_count, -+ l.phone_count, -+ l.device_limit, -+ (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, -+ -- Actual resource counts -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count -+ FROM licenses l -+ WHERE l.customer_id = %s -+ ORDER BY l.created_at DESC, l.id DESC -+ """, (customer_id,)) -+ -+ licenses = [] -+ for row in cur.fetchall(): -+ license_id = row[0] -+ -+ # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz -+ cur.execute(""" -+ SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at -+ FROM resource_pools rp -+ JOIN license_resources lr ON rp.id = lr.resource_id -+ WHERE lr.license_id = %s AND lr.is_active = true -+ ORDER BY rp.resource_type, rp.resource_value -+ """, (license_id,)) -+ -+ resources = { -+ 'domains': [], -+ 'ipv4s': [], -+ 'phones': [] -+ } -+ -+ for res_row in cur.fetchall(): -+ resource_info = { -+ 'id': res_row[0], -+ 'value': res_row[2], -+ 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' -+ } -+ -+ if res_row[1] == 'domain': -+ resources['domains'].append(resource_info) -+ elif res_row[1] == 'ipv4': -+ resources['ipv4s'].append(resource_info) -+ elif res_row[1] == 'phone': -+ resources['phones'].append(resource_info) -+ -+ licenses.append({ -+ 'id': row[0], -+ 'license_key': row[1], -+ 'license_type': row[2], -+ 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', -+ 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', -+ 'is_active': row[5], -+ 'status': row[6], -+ 'domain_count': row[7], # limit -+ 'ipv4_count': row[8], # limit -+ 'phone_count': row[9], # limit -+ 'device_limit': row[10], -+ 'active_devices': row[11], -+ 'actual_domain_count': row[12], # actual count -+ 'actual_ipv4_count': row[13], # actual count -+ 'actual_phone_count': row[14], # actual count -+ 'resources': resources -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'success': True, -+ 'licenses': licenses, -+ 'count': len(licenses) -+ }) -+ -+@app.route("/api/customer//quick-stats") -+@login_required -+def api_customer_quick_stats(customer_id): -+ """API-Endpoint für Schnellstatistiken eines Kunden""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Hole Kundenstatistiken -+ cur.execute(""" -+ SELECT -+ COUNT(l.id) as total_licenses, -+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, -+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon -+ FROM licenses l -+ WHERE l.customer_id = %s -+ """, (customer_id,)) -+ -+ stats = cur.fetchone() -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'success': True, -+ 'stats': { -+ 'total': stats[0], -+ 'active': stats[1], -+ 'expired': stats[2], -+ 'expiring_soon': stats[3] -+ } -+ }) -+ -+@app.route("/api/license//quick-edit", methods=['POST']) -+@login_required -+def api_license_quick_edit(license_id): -+ """API-Endpoint für schnelle Lizenz-Bearbeitung""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ data = request.get_json() -+ -+ # Hole alte Werte für Audit-Log -+ cur.execute(""" -+ SELECT is_active, valid_until, license_type -+ FROM licenses WHERE id = %s -+ """, (license_id,)) -+ old_values = cur.fetchone() -+ -+ if not old_values: -+ return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 -+ -+ # Update-Felder vorbereiten -+ updates = [] -+ params = [] -+ new_values = {} -+ -+ if 'is_active' in data: -+ updates.append("is_active = %s") -+ params.append(data['is_active']) -+ new_values['is_active'] = data['is_active'] -+ -+ if 'valid_until' in data: -+ updates.append("valid_until = %s") -+ params.append(data['valid_until']) -+ new_values['valid_until'] = data['valid_until'] -+ -+ if 'license_type' in data: -+ updates.append("license_type = %s") -+ params.append(data['license_type']) -+ new_values['license_type'] = data['license_type'] -+ -+ if updates: -+ params.append(license_id) -+ cur.execute(f""" -+ UPDATE licenses -+ SET {', '.join(updates)} -+ WHERE id = %s -+ """, params) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('UPDATE', 'license', license_id, -+ old_values={ -+ 'is_active': old_values[0], -+ 'valid_until': old_values[1].isoformat() if old_values[1] else None, -+ 'license_type': old_values[2] -+ }, -+ new_values=new_values) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True}) -+ -+ except Exception as e: -+ conn.rollback() -+ cur.close() -+ conn.close() -+ return jsonify({'success': False, 'error': str(e)}), 500 -+ -+@app.route("/api/license//resources") -+@login_required -+def api_license_resources(license_id): -+ """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz -+ cur.execute(""" -+ SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at -+ FROM resource_pools rp -+ JOIN license_resources lr ON rp.id = lr.resource_id -+ WHERE lr.license_id = %s AND lr.is_active = true -+ ORDER BY rp.resource_type, rp.resource_value -+ """, (license_id,)) -+ -+ resources = { -+ 'domains': [], -+ 'ipv4s': [], -+ 'phones': [] -+ } -+ -+ for row in cur.fetchall(): -+ resource_info = { -+ 'id': row[0], -+ 'value': row[2], -+ 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' -+ } -+ -+ if row[1] == 'domain': -+ resources['domains'].append(resource_info) -+ elif row[1] == 'ipv4': -+ resources['ipv4s'].append(resource_info) -+ elif row[1] == 'phone': -+ resources['phones'].append(resource_info) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'success': True, -+ 'resources': resources -+ }) -+ -+ except Exception as e: -+ cur.close() -+ conn.close() -+ return jsonify({'success': False, 'error': str(e)}), 500 -+ -+@app.route("/sessions") -+@login_required -+def sessions(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Sortierparameter -+ active_sort = request.args.get('active_sort', 'last_heartbeat') -+ active_order = request.args.get('active_order', 'desc') -+ ended_sort = request.args.get('ended_sort', 'ended_at') -+ ended_order = request.args.get('ended_order', 'desc') -+ -+ # Whitelist für erlaubte Sortierfelder - Aktive Sessions -+ active_sort_fields = { -+ 'customer': 'c.name', -+ 'license': 'l.license_key', -+ 'ip': 's.ip_address', -+ 'started': 's.started_at', -+ 'last_heartbeat': 's.last_heartbeat', -+ 'inactive': 'minutes_inactive' -+ } -+ -+ # Whitelist für erlaubte Sortierfelder - Beendete Sessions -+ ended_sort_fields = { -+ 'customer': 'c.name', -+ 'license': 'l.license_key', -+ 'ip': 's.ip_address', -+ 'started': 's.started_at', -+ 'ended_at': 's.ended_at', -+ 'duration': 'duration_minutes' -+ } -+ -+ # Validierung -+ if active_sort not in active_sort_fields: -+ active_sort = 'last_heartbeat' -+ if ended_sort not in ended_sort_fields: -+ ended_sort = 'ended_at' -+ if active_order not in ['asc', 'desc']: -+ active_order = 'desc' -+ if ended_order not in ['asc', 'desc']: -+ ended_order = 'desc' -+ -+ # Aktive Sessions abrufen -+ cur.execute(f""" -+ SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, -+ s.user_agent, s.started_at, s.last_heartbeat, -+ EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive -+ FROM sessions s -+ JOIN licenses l ON s.license_id = l.id -+ JOIN customers c ON l.customer_id = c.id -+ WHERE s.is_active = TRUE -+ ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} -+ """) -+ active_sessions = cur.fetchall() -+ -+ # Inaktive Sessions der letzten 24 Stunden -+ cur.execute(f""" -+ SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, -+ s.started_at, s.ended_at, -+ EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes -+ FROM sessions s -+ JOIN licenses l ON s.license_id = l.id -+ JOIN customers c ON l.customer_id = c.id -+ WHERE s.is_active = FALSE -+ AND s.ended_at > NOW() - INTERVAL '24 hours' -+ ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} -+ LIMIT 50 -+ """) -+ recent_sessions = cur.fetchall() -+ -+ cur.close() -+ conn.close() -+ -+ return render_template("sessions.html", -+ active_sessions=active_sessions, -+ recent_sessions=recent_sessions, -+ active_sort=active_sort, -+ active_order=active_order, -+ ended_sort=ended_sort, -+ ended_order=ended_order, -+ username=session.get('username')) -+ -+@app.route("/session/end/", methods=["POST"]) -+@login_required -+def end_session(session_id): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Session beenden -+ cur.execute(""" -+ UPDATE sessions -+ SET is_active = FALSE, ended_at = NOW() -+ WHERE id = %s AND is_active = TRUE -+ """, (session_id,)) -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ return redirect("/sessions") -+ -+@app.route("/export/licenses") -+@login_required -+def export_licenses(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) -+ include_test = request.args.get('include_test', 'false').lower() == 'true' -+ customer_id = request.args.get('customer_id', type=int) -+ -+ query = """ -+ SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, -+ l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, -+ CASE -+ WHEN l.is_active = FALSE THEN 'Deaktiviert' -+ WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' -+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' -+ ELSE 'Aktiv' -+ END as status -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ """ -+ -+ # Build WHERE clause -+ where_conditions = [] -+ params = [] -+ -+ if not include_test: -+ where_conditions.append("l.is_test = FALSE") -+ -+ if customer_id: -+ where_conditions.append("l.customer_id = %s") -+ params.append(customer_id) -+ -+ if where_conditions: -+ query += " WHERE " + " AND ".join(where_conditions) -+ -+ query += " ORDER BY l.id" -+ -+ cur.execute(query, params) -+ -+ # Spaltennamen -+ columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', -+ 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] -+ -+ # Daten in DataFrame -+ data = cur.fetchall() -+ df = pd.DataFrame(data, columns=columns) -+ -+ # Datumsformatierung -+ df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') -+ df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') -+ -+ # Typ und Aktiv Status anpassen -+ df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) -+ df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) -+ df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) -+ -+ cur.close() -+ conn.close() -+ -+ # Export Format -+ export_format = request.args.get('format', 'excel') -+ -+ # Audit-Log -+ log_audit('EXPORT', 'license', -+ additional_info=f"Export aller Lizenzen als {export_format.upper()}") -+ filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' -+ -+ if export_format == 'csv': -+ # CSV Export -+ output = io.StringIO() -+ df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') -+ output.seek(0) -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8-sig')), -+ mimetype='text/csv', -+ as_attachment=True, -+ download_name=f'{filename}.csv' -+ ) -+ else: -+ # Excel Export -+ output = io.BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, sheet_name='Lizenzen', index=False) -+ -+ # Formatierung -+ worksheet = writer.sheets['Lizenzen'] -+ for column in worksheet.columns: -+ max_length = 0 -+ column_letter = column[0].column_letter -+ for cell in column: -+ try: -+ if len(str(cell.value)) > max_length: -+ max_length = len(str(cell.value)) -+ except: -+ pass -+ adjusted_width = min(max_length + 2, 50) -+ worksheet.column_dimensions[column_letter].width = adjusted_width -+ -+ output.seek(0) -+ -+ return send_file( -+ output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx' -+ ) -+ -+@app.route("/export/audit") -+@login_required -+def export_audit(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Holen der Filter-Parameter -+ filter_user = request.args.get('user', '') -+ filter_action = request.args.get('action', '') -+ filter_entity = request.args.get('entity', '') -+ export_format = request.args.get('format', 'excel') -+ -+ # SQL Query mit Filtern -+ query = """ -+ SELECT id, timestamp, username, action, entity_type, entity_id, -+ old_values, new_values, ip_address, user_agent, additional_info -+ FROM audit_log -+ WHERE 1=1 -+ """ -+ params = [] -+ -+ if filter_user: -+ query += " AND username ILIKE %s" -+ params.append(f'%{filter_user}%') -+ -+ if filter_action: -+ query += " AND action = %s" -+ params.append(filter_action) -+ -+ if filter_entity: -+ query += " AND entity_type = %s" -+ params.append(filter_entity) -+ -+ query += " ORDER BY timestamp DESC" -+ -+ cur.execute(query, params) -+ audit_logs = cur.fetchall() -+ cur.close() -+ conn.close() -+ -+ # Daten für Export vorbereiten -+ data = [] -+ for log in audit_logs: -+ action_text = { -+ 'CREATE': 'Erstellt', -+ 'UPDATE': 'Bearbeitet', -+ 'DELETE': 'Gelöscht', -+ 'LOGIN': 'Anmeldung', -+ 'LOGOUT': 'Abmeldung', -+ 'AUTO_LOGOUT': 'Auto-Logout', -+ 'EXPORT': 'Export', -+ 'GENERATE_KEY': 'Key generiert', -+ 'CREATE_BATCH': 'Batch erstellt', -+ 'BACKUP': 'Backup erstellt', -+ 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', -+ 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', -+ 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', -+ 'LOGIN_BLOCKED': 'Login-Blockiert', -+ 'RESTORE': 'Wiederhergestellt', -+ 'PASSWORD_CHANGE': 'Passwort geändert', -+ '2FA_ENABLED': '2FA aktiviert', -+ '2FA_DISABLED': '2FA deaktiviert' -+ }.get(log[3], log[3]) -+ -+ data.append({ -+ 'ID': log[0], -+ 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), -+ 'Benutzer': log[2], -+ 'Aktion': action_text, -+ 'Entität': log[4], -+ 'Entität-ID': log[5] or '', -+ 'IP-Adresse': log[8] or '', -+ 'Zusatzinfo': log[10] or '' -+ }) -+ -+ # DataFrame erstellen -+ df = pd.DataFrame(data) -+ -+ # Timestamp für Dateiname -+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -+ filename = f'audit_log_export_{timestamp}' -+ -+ # Audit Log für Export -+ log_audit('EXPORT', 'audit_log', -+ additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") -+ -+ if export_format == 'csv': -+ # CSV Export -+ output = io.StringIO() -+ # UTF-8 BOM für Excel -+ output.write('\ufeff') -+ df.to_csv(output, index=False, sep=';', encoding='utf-8') -+ output.seek(0) -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8')), -+ mimetype='text/csv;charset=utf-8', -+ as_attachment=True, -+ download_name=f'{filename}.csv' -+ ) -+ else: -+ # Excel Export -+ output = BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, index=False, sheet_name='Audit Log') -+ -+ # Spaltenbreiten anpassen -+ worksheet = writer.sheets['Audit Log'] -+ for idx, col in enumerate(df.columns): -+ max_length = max( -+ df[col].astype(str).map(len).max(), -+ len(col) -+ ) + 2 -+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -+ -+ output.seek(0) -+ -+ return send_file( -+ output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx' -+ ) -+ -+@app.route("/export/customers") -+@login_required -+def export_customers(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Check if test data should be included -+ include_test = request.args.get('include_test', 'false').lower() == 'true' -+ -+ # Build query based on test data filter -+ if include_test: -+ # Include all customers -+ query = """ -+ SELECT c.id, c.name, c.email, c.created_at, c.is_test, -+ COUNT(l.id) as total_licenses, -+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ GROUP BY c.id, c.name, c.email, c.created_at, c.is_test -+ ORDER BY c.id -+ """ -+ else: -+ # Exclude test customers and test licenses -+ query = """ -+ SELECT c.id, c.name, c.email, c.created_at, c.is_test, -+ COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, -+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, -+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ WHERE c.is_test = FALSE -+ GROUP BY c.id, c.name, c.email, c.created_at, c.is_test -+ ORDER BY c.id -+ """ -+ -+ cur.execute(query) -+ -+ # Spaltennamen -+ columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', -+ 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] -+ -+ # Daten in DataFrame -+ data = cur.fetchall() -+ df = pd.DataFrame(data, columns=columns) -+ -+ # Datumsformatierung -+ df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') -+ -+ # Testdaten formatting -+ df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) -+ -+ cur.close() -+ conn.close() -+ -+ # Export Format -+ export_format = request.args.get('format', 'excel') -+ -+ # Audit-Log -+ log_audit('EXPORT', 'customer', -+ additional_info=f"Export aller Kunden als {export_format.upper()}") -+ filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' -+ -+ if export_format == 'csv': -+ # CSV Export -+ output = io.StringIO() -+ df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') -+ output.seek(0) -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8-sig')), -+ mimetype='text/csv', -+ as_attachment=True, -+ download_name=f'{filename}.csv' -+ ) -+ else: -+ # Excel Export -+ output = io.BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, sheet_name='Kunden', index=False) -+ -+ # Formatierung -+ worksheet = writer.sheets['Kunden'] -+ for column in worksheet.columns: -+ max_length = 0 -+ column_letter = column[0].column_letter -+ for cell in column: -+ try: -+ if len(str(cell.value)) > max_length: -+ max_length = len(str(cell.value)) -+ except: -+ pass -+ adjusted_width = min(max_length + 2, 50) -+ worksheet.column_dimensions[column_letter].width = adjusted_width -+ -+ output.seek(0) -+ -+ return send_file( -+ output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx' -+ ) -+ -+@app.route("/export/sessions") -+@login_required -+def export_sessions(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Holen des Session-Typs (active oder ended) -+ session_type = request.args.get('type', 'active') -+ export_format = request.args.get('format', 'excel') -+ -+ # Daten je nach Typ abrufen -+ if session_type == 'active': -+ # Aktive Lizenz-Sessions -+ cur.execute(""" -+ SELECT s.id, l.license_key, c.name as customer_name, s.session_id, -+ s.started_at, s.last_heartbeat, -+ EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, -+ s.ip_address, s.user_agent -+ FROM sessions s -+ JOIN licenses l ON s.license_id = l.id -+ JOIN customers c ON l.customer_id = c.id -+ WHERE s.is_active = true -+ ORDER BY s.last_heartbeat DESC -+ """) -+ sessions = cur.fetchall() -+ -+ # Daten für Export vorbereiten -+ data = [] -+ for sess in sessions: -+ duration = sess[6] -+ hours = duration // 3600 -+ minutes = (duration % 3600) // 60 -+ seconds = duration % 60 -+ -+ data.append({ -+ 'Session-ID': sess[0], -+ 'Lizenzschlüssel': sess[1], -+ 'Kunde': sess[2], -+ 'Session-ID (Tech)': sess[3], -+ 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), -+ 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), -+ 'Dauer': f"{hours}h {minutes}m {seconds}s", -+ 'IP-Adresse': sess[7], -+ 'Browser': sess[8] -+ }) -+ -+ sheet_name = 'Aktive Sessions' -+ filename_prefix = 'aktive_sessions' -+ else: -+ # Beendete Lizenz-Sessions -+ cur.execute(""" -+ SELECT s.id, l.license_key, c.name as customer_name, s.session_id, -+ s.started_at, s.ended_at, -+ EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, -+ s.ip_address, s.user_agent -+ FROM sessions s -+ JOIN licenses l ON s.license_id = l.id -+ JOIN customers c ON l.customer_id = c.id -+ WHERE s.is_active = false AND s.ended_at IS NOT NULL -+ ORDER BY s.ended_at DESC -+ LIMIT 1000 -+ """) -+ sessions = cur.fetchall() -+ -+ # Daten für Export vorbereiten -+ data = [] -+ for sess in sessions: -+ duration = sess[6] if sess[6] else 0 -+ hours = duration // 3600 -+ minutes = (duration % 3600) // 60 -+ seconds = duration % 60 -+ -+ data.append({ -+ 'Session-ID': sess[0], -+ 'Lizenzschlüssel': sess[1], -+ 'Kunde': sess[2], -+ 'Session-ID (Tech)': sess[3], -+ 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), -+ 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', -+ 'Dauer': f"{hours}h {minutes}m {seconds}s", -+ 'IP-Adresse': sess[7], -+ 'Browser': sess[8] -+ }) -+ -+ sheet_name = 'Beendete Sessions' -+ filename_prefix = 'beendete_sessions' -+ -+ cur.close() -+ conn.close() -+ -+ # DataFrame erstellen -+ df = pd.DataFrame(data) -+ -+ # Timestamp für Dateiname -+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -+ filename = f'{filename_prefix}_export_{timestamp}' -+ -+ # Audit Log für Export -+ log_audit('EXPORT', 'sessions', -+ additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") -+ -+ if export_format == 'csv': -+ # CSV Export -+ output = io.StringIO() -+ # UTF-8 BOM für Excel -+ output.write('\ufeff') -+ df.to_csv(output, index=False, sep=';', encoding='utf-8') -+ output.seek(0) -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8')), -+ mimetype='text/csv;charset=utf-8', -+ as_attachment=True, -+ download_name=f'{filename}.csv' -+ ) -+ else: -+ # Excel Export -+ output = BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, index=False, sheet_name=sheet_name) -+ -+ # Spaltenbreiten anpassen -+ worksheet = writer.sheets[sheet_name] -+ for idx, col in enumerate(df.columns): -+ max_length = max( -+ df[col].astype(str).map(len).max(), -+ len(col) -+ ) + 2 -+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -+ -+ output.seek(0) -+ -+ return send_file( -+ output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx' -+ ) -+ -+@app.route("/export/resources") -+@login_required -+def export_resources(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Holen der Filter-Parameter -+ filter_type = request.args.get('type', '') -+ filter_status = request.args.get('status', '') -+ search_query = request.args.get('search', '') -+ show_test = request.args.get('show_test', 'false').lower() == 'true' -+ export_format = request.args.get('format', 'excel') -+ -+ # SQL Query mit Filtern -+ query = """ -+ SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, -+ r.created_at, r.status_changed_at, -+ l.license_key, c.name as customer_name, c.email as customer_email, -+ l.license_type -+ FROM resource_pools r -+ LEFT JOIN licenses l ON r.allocated_to_license = l.id -+ LEFT JOIN customers c ON l.customer_id = c.id -+ WHERE 1=1 -+ """ -+ params = [] -+ -+ # Filter für Testdaten -+ if not show_test: -+ query += " AND (r.is_test = false OR r.is_test IS NULL)" -+ -+ # Filter für Ressourcentyp -+ if filter_type: -+ query += " AND r.resource_type = %s" -+ params.append(filter_type) -+ -+ # Filter für Status -+ if filter_status: -+ query += " AND r.status = %s" -+ params.append(filter_status) -+ -+ # Suchfilter -+ if search_query: -+ query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" -+ params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) -+ -+ query += " ORDER BY r.id DESC" -+ -+ cur.execute(query, params) -+ resources = cur.fetchall() -+ cur.close() -+ conn.close() -+ -+ # Daten für Export vorbereiten -+ data = [] -+ for res in resources: -+ status_text = { -+ 'available': 'Verfügbar', -+ 'allocated': 'Zugewiesen', -+ 'quarantine': 'Quarantäne' -+ }.get(res[3], res[3]) -+ -+ type_text = { -+ 'domain': 'Domain', -+ 'ipv4': 'IPv4', -+ 'phone': 'Telefon' -+ }.get(res[1], res[1]) -+ -+ data.append({ -+ 'ID': res[0], -+ 'Typ': type_text, -+ 'Ressource': res[2], -+ 'Status': status_text, -+ 'Lizenzschlüssel': res[7] or '', -+ 'Kunde': res[8] or '', -+ 'Kunden-Email': res[9] or '', -+ 'Lizenztyp': res[10] or '', -+ 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', -+ 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' -+ }) -+ -+ # DataFrame erstellen -+ df = pd.DataFrame(data) -+ -+ # Timestamp für Dateiname -+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -+ filename = f'resources_export_{timestamp}' -+ -+ # Audit Log für Export -+ log_audit('EXPORT', 'resources', -+ additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") -+ -+ if export_format == 'csv': -+ # CSV Export -+ output = io.StringIO() -+ # UTF-8 BOM für Excel -+ output.write('\ufeff') -+ df.to_csv(output, index=False, sep=';', encoding='utf-8') -+ output.seek(0) -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8')), -+ mimetype='text/csv;charset=utf-8', -+ as_attachment=True, -+ download_name=f'{filename}.csv' -+ ) -+ else: -+ # Excel Export -+ output = BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, index=False, sheet_name='Resources') -+ -+ # Spaltenbreiten anpassen -+ worksheet = writer.sheets['Resources'] -+ for idx, col in enumerate(df.columns): -+ max_length = max( -+ df[col].astype(str).map(len).max(), -+ len(col) -+ ) + 2 -+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -+ -+ output.seek(0) -+ -+ return send_file( -+ output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx' -+ ) -+ -+@app.route("/audit") -+@login_required -+def audit_log(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Parameter -+ filter_user = request.args.get('user', '').strip() -+ filter_action = request.args.get('action', '').strip() -+ filter_entity = request.args.get('entity', '').strip() -+ page = request.args.get('page', 1, type=int) -+ sort = request.args.get('sort', 'timestamp') -+ order = request.args.get('order', 'desc') -+ per_page = 50 -+ -+ # Whitelist für erlaubte Sortierfelder -+ allowed_sort_fields = { -+ 'timestamp': 'timestamp', -+ 'username': 'username', -+ 'action': 'action', -+ 'entity': 'entity_type', -+ 'ip': 'ip_address' -+ } -+ -+ # Validierung -+ if sort not in allowed_sort_fields: -+ sort = 'timestamp' -+ if order not in ['asc', 'desc']: -+ order = 'desc' -+ -+ sort_field = allowed_sort_fields[sort] -+ -+ # SQL Query mit optionalen Filtern -+ query = """ -+ SELECT id, timestamp, username, action, entity_type, entity_id, -+ old_values, new_values, ip_address, user_agent, additional_info -+ FROM audit_log -+ WHERE 1=1 -+ """ -+ -+ params = [] -+ -+ # Filter -+ if filter_user: -+ query += " AND LOWER(username) LIKE LOWER(%s)" -+ params.append(f'%{filter_user}%') -+ -+ if filter_action: -+ query += " AND action = %s" -+ params.append(filter_action) -+ -+ if filter_entity: -+ query += " AND entity_type = %s" -+ params.append(filter_entity) -+ -+ # Gesamtanzahl für Pagination -+ count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" -+ cur.execute(count_query, params) -+ total = cur.fetchone()[0] -+ -+ # Pagination -+ offset = (page - 1) * per_page -+ query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" -+ params.extend([per_page, offset]) -+ -+ cur.execute(query, params) -+ logs = cur.fetchall() -+ -+ # JSON-Werte parsen -+ parsed_logs = [] -+ for log in logs: -+ parsed_log = list(log) -+ # old_values und new_values sind bereits Dictionaries (JSONB) -+ # Keine Konvertierung nötig -+ parsed_logs.append(parsed_log) -+ -+ # Pagination Info -+ total_pages = (total + per_page - 1) // per_page -+ -+ cur.close() -+ conn.close() -+ -+ return render_template("audit_log.html", -+ logs=parsed_logs, -+ filter_user=filter_user, -+ filter_action=filter_action, -+ filter_entity=filter_entity, -+ page=page, -+ total_pages=total_pages, -+ total=total, -+ sort=sort, -+ order=order, -+ username=session.get('username')) -+ -+@app.route("/backups") -+@login_required -+def backups(): -+ """Zeigt die Backup-Historie an""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Letztes erfolgreiches Backup für Dashboard -+ cur.execute(""" -+ SELECT created_at, filesize, duration_seconds -+ FROM backup_history -+ WHERE status = 'success' -+ ORDER BY created_at DESC -+ LIMIT 1 -+ """) -+ last_backup = cur.fetchone() -+ -+ # Alle Backups abrufen -+ cur.execute(""" -+ SELECT id, filename, filesize, backup_type, status, error_message, -+ created_at, created_by, tables_count, records_count, -+ duration_seconds, is_encrypted -+ FROM backup_history -+ ORDER BY created_at DESC -+ """) -+ backups = cur.fetchall() -+ -+ cur.close() -+ conn.close() -+ -+ return render_template("backups.html", -+ backups=backups, -+ last_backup=last_backup, -+ username=session.get('username')) -+ -+@app.route("/backup/create", methods=["POST"]) -+@login_required -+def create_backup_route(): -+ """Erstellt ein manuelles Backup""" -+ username = session.get('username') -+ success, result = create_backup(backup_type="manual", created_by=username) -+ -+ if success: -+ return jsonify({ -+ 'success': True, -+ 'message': f'Backup erfolgreich erstellt: {result}' -+ }) -+ else: -+ return jsonify({ -+ 'success': False, -+ 'message': f'Backup fehlgeschlagen: {result}' -+ }), 500 -+ -+@app.route("/backup/restore/", methods=["POST"]) -+@login_required -+def restore_backup_route(backup_id): -+ """Stellt ein Backup wieder her""" -+ encryption_key = request.form.get('encryption_key') -+ -+ success, message = restore_backup(backup_id, encryption_key) -+ -+ if success: -+ return jsonify({ -+ 'success': True, -+ 'message': message -+ }) -+ else: -+ return jsonify({ -+ 'success': False, -+ 'message': message -+ }), 500 -+ -+@app.route("/backup/download/") -+@login_required -+def download_backup(backup_id): -+ """Lädt eine Backup-Datei herunter""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ cur.execute(""" -+ SELECT filename, filepath -+ FROM backup_history -+ WHERE id = %s -+ """, (backup_id,)) -+ backup_info = cur.fetchone() -+ -+ cur.close() -+ conn.close() -+ -+ if not backup_info: -+ return "Backup nicht gefunden", 404 -+ -+ filename, filepath = backup_info -+ filepath = Path(filepath) -+ -+ if not filepath.exists(): -+ return "Backup-Datei nicht gefunden", 404 -+ -+ # Audit-Log -+ log_audit('DOWNLOAD', 'backup', backup_id, -+ additional_info=f"Backup heruntergeladen: {filename}") -+ -+ return send_file(filepath, as_attachment=True, download_name=filename) -+ -+@app.route("/backup/delete/", methods=["DELETE"]) -+@login_required -+def delete_backup(backup_id): -+ """Löscht ein Backup""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Backup-Informationen abrufen -+ cur.execute(""" -+ SELECT filename, filepath -+ FROM backup_history -+ WHERE id = %s -+ """, (backup_id,)) -+ backup_info = cur.fetchone() -+ -+ if not backup_info: -+ return jsonify({ -+ 'success': False, -+ 'message': 'Backup nicht gefunden' -+ }), 404 -+ -+ filename, filepath = backup_info -+ filepath = Path(filepath) -+ -+ # Datei löschen, wenn sie existiert -+ if filepath.exists(): -+ filepath.unlink() -+ -+ # Aus Datenbank löschen -+ cur.execute(""" -+ DELETE FROM backup_history -+ WHERE id = %s -+ """, (backup_id,)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('DELETE', 'backup', backup_id, -+ additional_info=f"Backup gelöscht: {filename}") -+ -+ return jsonify({ -+ 'success': True, -+ 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' -+ }) -+ -+ except Exception as e: -+ conn.rollback() -+ return jsonify({ -+ 'success': False, -+ 'message': f'Fehler beim Löschen des Backups: {str(e)}' -+ }), 500 -+ finally: -+ cur.close() -+ conn.close() -+ -+@app.route("/security/blocked-ips") -+@login_required -+def blocked_ips(): -+ """Zeigt alle gesperrten IPs an""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ cur.execute(""" -+ SELECT -+ ip_address, -+ attempt_count, -+ first_attempt, -+ last_attempt, -+ blocked_until, -+ last_username_tried, -+ last_error_message -+ FROM login_attempts -+ WHERE blocked_until IS NOT NULL -+ ORDER BY blocked_until DESC -+ """) -+ -+ blocked_ips_list = [] -+ for ip in cur.fetchall(): -+ blocked_ips_list.append({ -+ 'ip_address': ip[0], -+ 'attempt_count': ip[1], -+ 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), -+ 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), -+ 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), -+ 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), -+ 'last_username': ip[5], -+ 'last_error': ip[6] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return render_template("blocked_ips.html", -+ blocked_ips=blocked_ips_list, -+ username=session.get('username')) -+ -+@app.route("/security/unblock-ip", methods=["POST"]) -+@login_required -+def unblock_ip(): -+ """Entsperrt eine IP-Adresse""" -+ ip_address = request.form.get('ip_address') -+ -+ if ip_address: -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ cur.execute(""" -+ UPDATE login_attempts -+ SET blocked_until = NULL -+ WHERE ip_address = %s -+ """, (ip_address,)) -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ # Audit-Log -+ log_audit('UNBLOCK_IP', 'security', -+ additional_info=f"IP {ip_address} manuell entsperrt") -+ -+ return redirect(url_for('blocked_ips')) -+ -+@app.route("/security/clear-attempts", methods=["POST"]) -+@login_required -+def clear_attempts(): -+ """Löscht alle Login-Versuche für eine IP""" -+ ip_address = request.form.get('ip_address') -+ -+ if ip_address: -+ reset_login_attempts(ip_address) -+ -+ # Audit-Log -+ log_audit('CLEAR_ATTEMPTS', 'security', -+ additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") -+ -+ return redirect(url_for('blocked_ips')) -+ -+# API Endpoints for License Management -+@app.route("/api/license//toggle", methods=["POST"]) -+@login_required -+def toggle_license_api(license_id): -+ """Toggle license active status via API""" -+ try: -+ data = request.get_json() -+ is_active = data.get('is_active', False) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Update license status -+ cur.execute(""" -+ UPDATE licenses -+ SET is_active = %s -+ WHERE id = %s -+ """, (is_active, license_id)) -+ -+ conn.commit() -+ -+ # Log the action -+ log_audit('UPDATE', 'license', license_id, -+ new_values={'is_active': is_active}, -+ additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) -+ except Exception as e: -+ return jsonify({'success': False, 'message': str(e)}), 500 -+ -+@app.route("/api/licenses/bulk-activate", methods=["POST"]) -+@login_required -+def bulk_activate_licenses(): -+ """Activate multiple licenses at once""" -+ try: -+ data = request.get_json() -+ license_ids = data.get('ids', []) -+ -+ if not license_ids: -+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Update all selected licenses (nur Live-Daten) -+ cur.execute(""" -+ UPDATE licenses -+ SET is_active = TRUE -+ WHERE id = ANY(%s) AND is_test = FALSE -+ """, (license_ids,)) -+ -+ affected_rows = cur.rowcount -+ conn.commit() -+ -+ # Log the bulk action -+ log_audit('BULK_UPDATE', 'licenses', None, -+ new_values={'is_active': True, 'count': affected_rows}, -+ additional_info=f"{affected_rows} Lizenzen aktiviert") -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) -+ except Exception as e: -+ return jsonify({'success': False, 'message': str(e)}), 500 -+ -+@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -+@login_required -+def bulk_deactivate_licenses(): -+ """Deactivate multiple licenses at once""" -+ try: -+ data = request.get_json() -+ license_ids = data.get('ids', []) -+ -+ if not license_ids: -+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Update all selected licenses (nur Live-Daten) -+ cur.execute(""" -+ UPDATE licenses -+ SET is_active = FALSE -+ WHERE id = ANY(%s) AND is_test = FALSE -+ """, (license_ids,)) -+ -+ affected_rows = cur.rowcount -+ conn.commit() -+ -+ # Log the bulk action -+ log_audit('BULK_UPDATE', 'licenses', None, -+ new_values={'is_active': False, 'count': affected_rows}, -+ additional_info=f"{affected_rows} Lizenzen deaktiviert") -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) -+ except Exception as e: -+ return jsonify({'success': False, 'message': str(e)}), 500 -+ -+@app.route("/api/license//devices") -+@login_required -+def get_license_devices(license_id): -+ """Hole alle registrierten Geräte einer Lizenz""" -+ try: -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Prüfe ob Lizenz existiert und hole device_limit -+ cur.execute(""" -+ SELECT device_limit FROM licenses WHERE id = %s -+ """, (license_id,)) -+ license_data = cur.fetchone() -+ -+ if not license_data: -+ return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 -+ -+ device_limit = license_data[0] -+ -+ # Hole alle Geräte für diese Lizenz -+ cur.execute(""" -+ SELECT id, hardware_id, device_name, operating_system, -+ first_seen, last_seen, is_active, ip_address -+ FROM device_registrations -+ WHERE license_id = %s -+ ORDER BY is_active DESC, last_seen DESC -+ """, (license_id,)) -+ -+ devices = [] -+ for row in cur.fetchall(): -+ devices.append({ -+ 'id': row[0], -+ 'hardware_id': row[1], -+ 'device_name': row[2] or 'Unbekanntes Gerät', -+ 'operating_system': row[3] or 'Unbekannt', -+ 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', -+ 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', -+ 'is_active': row[6], -+ 'ip_address': row[7] or '-' -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'success': True, -+ 'devices': devices, -+ 'device_limit': device_limit, -+ 'active_count': sum(1 for d in devices if d['is_active']) -+ }) -+ -+ except Exception as e: -+ logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") -+ return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 -+ -+@app.route("/api/license//register-device", methods=["POST"]) -+def register_device(license_id): -+ """Registriere ein neues Gerät für eine Lizenz""" -+ try: -+ data = request.get_json() -+ hardware_id = data.get('hardware_id') -+ device_name = data.get('device_name', '') -+ operating_system = data.get('operating_system', '') -+ -+ if not hardware_id: -+ return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Prüfe ob Lizenz existiert und aktiv ist -+ cur.execute(""" -+ SELECT device_limit, is_active, valid_until -+ FROM licenses -+ WHERE id = %s -+ """, (license_id,)) -+ license_data = cur.fetchone() -+ -+ if not license_data: -+ return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 -+ -+ device_limit, is_active, valid_until = license_data -+ -+ # Prüfe ob Lizenz aktiv und gültig ist -+ if not is_active: -+ return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 -+ -+ if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): -+ return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 -+ -+ # Prüfe ob Gerät bereits registriert ist -+ cur.execute(""" -+ SELECT id, is_active FROM device_registrations -+ WHERE license_id = %s AND hardware_id = %s -+ """, (license_id, hardware_id)) -+ existing_device = cur.fetchone() -+ -+ if existing_device: -+ device_id, is_device_active = existing_device -+ if is_device_active: -+ # Gerät ist bereits aktiv, update last_seen -+ cur.execute(""" -+ UPDATE device_registrations -+ SET last_seen = CURRENT_TIMESTAMP, -+ ip_address = %s, -+ user_agent = %s -+ WHERE id = %s -+ """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -+ conn.commit() -+ return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) -+ else: -+ # Gerät war deaktiviert, prüfe ob wir es reaktivieren können -+ cur.execute(""" -+ SELECT COUNT(*) FROM device_registrations -+ WHERE license_id = %s AND is_active = TRUE -+ """, (license_id,)) -+ active_count = cur.fetchone()[0] -+ -+ if active_count >= device_limit: -+ return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 -+ -+ # Reaktiviere das Gerät -+ cur.execute(""" -+ UPDATE device_registrations -+ SET is_active = TRUE, -+ last_seen = CURRENT_TIMESTAMP, -+ deactivated_at = NULL, -+ deactivated_by = NULL, -+ ip_address = %s, -+ user_agent = %s -+ WHERE id = %s -+ """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -+ conn.commit() -+ return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) -+ -+ # Neues Gerät - prüfe Gerätelimit -+ cur.execute(""" -+ SELECT COUNT(*) FROM device_registrations -+ WHERE license_id = %s AND is_active = TRUE -+ """, (license_id,)) -+ active_count = cur.fetchone()[0] -+ -+ if active_count >= device_limit: -+ return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 -+ -+ # Registriere neues Gerät -+ cur.execute(""" -+ INSERT INTO device_registrations -+ (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) -+ VALUES (%s, %s, %s, %s, %s, %s) -+ RETURNING id -+ """, (license_id, hardware_id, device_name, operating_system, -+ get_client_ip(), request.headers.get('User-Agent', ''))) -+ device_id = cur.fetchone()[0] -+ -+ conn.commit() -+ -+ # Audit Log -+ log_audit('DEVICE_REGISTER', 'device', device_id, -+ new_values={'license_id': license_id, 'hardware_id': hardware_id}) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) -+ -+ except Exception as e: -+ logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") -+ return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 -+ -+@app.route("/api/license//deactivate-device/", methods=["POST"]) -+@login_required -+def deactivate_device(license_id, device_id): -+ """Deaktiviere ein registriertes Gerät""" -+ try: -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Prüfe ob das Gerät zu dieser Lizenz gehört -+ cur.execute(""" -+ SELECT id FROM device_registrations -+ WHERE id = %s AND license_id = %s AND is_active = TRUE -+ """, (device_id, license_id)) -+ -+ if not cur.fetchone(): -+ return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 -+ -+ # Deaktiviere das Gerät -+ cur.execute(""" -+ UPDATE device_registrations -+ SET is_active = FALSE, -+ deactivated_at = CURRENT_TIMESTAMP, -+ deactivated_by = %s -+ WHERE id = %s -+ """, (session['username'], device_id)) -+ -+ conn.commit() -+ -+ # Audit Log -+ log_audit('DEVICE_DEACTIVATE', 'device', device_id, -+ old_values={'is_active': True}, -+ new_values={'is_active': False}) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) -+ -+ except Exception as e: -+ logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") -+ return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 -+ -+@app.route("/api/licenses/bulk-delete", methods=["POST"]) -+@login_required -+def bulk_delete_licenses(): -+ """Delete multiple licenses at once""" -+ try: -+ data = request.get_json() -+ license_ids = data.get('ids', []) -+ -+ if not license_ids: -+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Get license info for audit log (nur Live-Daten) -+ cur.execute(""" -+ SELECT license_key -+ FROM licenses -+ WHERE id = ANY(%s) AND is_test = FALSE -+ """, (license_ids,)) -+ license_keys = [row[0] for row in cur.fetchall()] -+ -+ # Delete all selected licenses (nur Live-Daten) -+ cur.execute(""" -+ DELETE FROM licenses -+ WHERE id = ANY(%s) AND is_test = FALSE -+ """, (license_ids,)) -+ -+ affected_rows = cur.rowcount -+ conn.commit() -+ -+ # Log the bulk action -+ log_audit('BULK_DELETE', 'licenses', None, -+ old_values={'license_keys': license_keys, 'count': affected_rows}, -+ additional_info=f"{affected_rows} Lizenzen gelöscht") -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) -+ except Exception as e: -+ return jsonify({'success': False, 'message': str(e)}), 500 -+ -+# ===================== RESOURCE POOL MANAGEMENT ===================== -+ -+@app.route('/resources') -+@login_required -+def resources(): -+ """Resource Pool Hauptübersicht""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) -+ show_test = request.args.get('show_test', 'false').lower() == 'true' -+ -+ # Statistiken abrufen -+ cur.execute(""" -+ SELECT -+ resource_type, -+ COUNT(*) FILTER (WHERE status = 'available') as available, -+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -+ COUNT(*) as total -+ FROM resource_pools -+ WHERE is_test = %s -+ GROUP BY resource_type -+ """, (show_test,)) -+ -+ stats = {} -+ for row in cur.fetchall(): -+ stats[row[0]] = { -+ 'available': row[1], -+ 'allocated': row[2], -+ 'quarantine': row[3], -+ 'total': row[4], -+ 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) -+ } -+ -+ # Letzte Aktivitäten (gefiltert nach Test/Live) -+ cur.execute(""" -+ SELECT -+ rh.action, -+ rh.action_by, -+ rh.action_at, -+ rp.resource_type, -+ rp.resource_value, -+ rh.details -+ FROM resource_history rh -+ JOIN resource_pools rp ON rh.resource_id = rp.id -+ WHERE rp.is_test = %s -+ ORDER BY rh.action_at DESC -+ LIMIT 10 -+ """, (show_test,)) -+ recent_activities = cur.fetchall() -+ -+ # Ressourcen-Liste mit Pagination -+ page = request.args.get('page', 1, type=int) -+ per_page = 50 -+ offset = (page - 1) * per_page -+ -+ resource_type = request.args.get('type', '') -+ status_filter = request.args.get('status', '') -+ search = request.args.get('search', '') -+ -+ # Sortierung -+ sort_by = request.args.get('sort', 'id') -+ sort_order = request.args.get('order', 'desc') -+ -+ # Base Query -+ query = """ -+ SELECT -+ rp.id, -+ rp.resource_type, -+ rp.resource_value, -+ rp.status, -+ rp.allocated_to_license, -+ l.license_key, -+ c.name as customer_name, -+ rp.status_changed_at, -+ rp.quarantine_reason, -+ rp.quarantine_until, -+ c.id as customer_id -+ FROM resource_pools rp -+ LEFT JOIN licenses l ON rp.allocated_to_license = l.id -+ LEFT JOIN customers c ON l.customer_id = c.id -+ WHERE rp.is_test = %s -+ """ -+ params = [show_test] -+ -+ if resource_type: -+ query += " AND rp.resource_type = %s" -+ params.append(resource_type) -+ -+ if status_filter: -+ query += " AND rp.status = %s" -+ params.append(status_filter) -+ -+ if search: -+ query += " AND rp.resource_value ILIKE %s" -+ params.append(f'%{search}%') -+ -+ # Count total -+ count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" -+ cur.execute(count_query, params) -+ total = cur.fetchone()[0] -+ total_pages = (total + per_page - 1) // per_page -+ -+ # Get paginated results with dynamic sorting -+ sort_column_map = { -+ 'id': 'rp.id', -+ 'type': 'rp.resource_type', -+ 'resource': 'rp.resource_value', -+ 'status': 'rp.status', -+ 'assigned': 'c.name', -+ 'changed': 'rp.status_changed_at' -+ } -+ -+ sort_column = sort_column_map.get(sort_by, 'rp.id') -+ sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' -+ -+ query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" -+ params.extend([per_page, offset]) -+ -+ cur.execute(query, params) -+ resources = cur.fetchall() -+ -+ cur.close() -+ conn.close() -+ -+ return render_template('resources.html', -+ stats=stats, -+ resources=resources, -+ recent_activities=recent_activities, -+ page=page, -+ total_pages=total_pages, -+ total=total, -+ resource_type=resource_type, -+ status_filter=status_filter, -+ search=search, -+ show_test=show_test, -+ sort_by=sort_by, -+ sort_order=sort_order, -+ datetime=datetime, -+ timedelta=timedelta) -+ -+@app.route('/resources/add', methods=['GET', 'POST']) -+@login_required -+def add_resources(): -+ """Ressourcen zum Pool hinzufügen""" -+ # Hole show_test Parameter für die Anzeige -+ show_test = request.args.get('show_test', 'false').lower() == 'true' -+ -+ if request.method == 'POST': -+ resource_type = request.form.get('resource_type') -+ resources_text = request.form.get('resources_text', '') -+ is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten -+ -+ # Parse resources (one per line) -+ resources = [r.strip() for r in resources_text.split('\n') if r.strip()] -+ -+ if not resources: -+ flash('Keine Ressourcen angegeben', 'error') -+ return redirect(url_for('add_resources', show_test=show_test)) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ added = 0 -+ duplicates = 0 -+ -+ for resource_value in resources: -+ try: -+ cur.execute(""" -+ INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) -+ VALUES (%s, %s, %s, %s) -+ ON CONFLICT (resource_type, resource_value) DO NOTHING -+ """, (resource_type, resource_value, session['username'], is_test)) -+ -+ if cur.rowcount > 0: -+ added += 1 -+ # Get the inserted ID -+ cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", -+ (resource_type, resource_value)) -+ resource_id = cur.fetchone()[0] -+ -+ # Log in history -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, action, action_by, ip_address) -+ VALUES (%s, 'created', %s, %s) -+ """, (resource_id, session['username'], get_client_ip())) -+ else: -+ duplicates += 1 -+ -+ except Exception as e: -+ app.logger.error(f"Error adding resource {resource_value}: {e}") -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ log_audit('CREATE', 'resource_pool', None, -+ new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, -+ additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") -+ -+ flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') -+ return redirect(url_for('resources', show_test=show_test)) -+ -+ return render_template('add_resources.html', show_test=show_test) -+ -+@app.route('/resources/quarantine/', methods=['POST']) -+@login_required -+def quarantine_resource(resource_id): -+ """Ressource in Quarantäne setzen""" -+ reason = request.form.get('reason', 'review') -+ until_date = request.form.get('until_date') -+ notes = request.form.get('notes', '') -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Get current resource info -+ cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) -+ resource = cur.fetchone() -+ -+ if not resource: -+ flash('Ressource nicht gefunden', 'error') -+ return redirect(url_for('resources')) -+ -+ old_status = resource[2] -+ -+ # Update resource -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'quarantine', -+ quarantine_reason = %s, -+ quarantine_until = %s, -+ notes = %s, -+ status_changed_at = CURRENT_TIMESTAMP, -+ status_changed_by = %s -+ WHERE id = %s -+ """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) -+ -+ # Log in history -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) -+ VALUES (%s, 'quarantined', %s, %s, %s) -+ """, (resource_id, session['username'], get_client_ip(), -+ Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ log_audit('UPDATE', 'resource', resource_id, -+ old_values={'status': old_status}, -+ new_values={'status': 'quarantine', 'reason': reason}, -+ additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") -+ -+ flash('Ressource in Quarantäne gesetzt', 'success') -+ -+ # Redirect mit allen aktuellen Filtern -+ return redirect(url_for('resources', -+ show_test=request.args.get('show_test', request.form.get('show_test', 'false')), -+ type=request.args.get('type', request.form.get('type', '')), -+ status=request.args.get('status', request.form.get('status', '')), -+ search=request.args.get('search', request.form.get('search', '')))) -+ -+@app.route('/resources/release', methods=['POST']) -+@login_required -+def release_resources(): -+ """Ressourcen aus Quarantäne freigeben""" -+ resource_ids = request.form.getlist('resource_ids') -+ -+ if not resource_ids: -+ flash('Keine Ressourcen ausgewählt', 'error') -+ return redirect(url_for('resources')) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ released = 0 -+ for resource_id in resource_ids: -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'available', -+ quarantine_reason = NULL, -+ quarantine_until = NULL, -+ allocated_to_license = NULL, -+ status_changed_at = CURRENT_TIMESTAMP, -+ status_changed_by = %s -+ WHERE id = %s AND status = 'quarantine' -+ """, (session['username'], resource_id)) -+ -+ if cur.rowcount > 0: -+ released += 1 -+ # Log in history -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, action, action_by, ip_address) -+ VALUES (%s, 'released', %s, %s) -+ """, (resource_id, session['username'], get_client_ip())) -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ log_audit('UPDATE', 'resource_pool', None, -+ new_values={'released': released}, -+ additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") -+ -+ flash(f'{released} Ressourcen freigegeben', 'success') -+ -+ # Redirect mit allen aktuellen Filtern -+ return redirect(url_for('resources', -+ show_test=request.args.get('show_test', request.form.get('show_test', 'false')), -+ type=request.args.get('type', request.form.get('type', '')), -+ status=request.args.get('status', request.form.get('status', '')), -+ search=request.args.get('search', request.form.get('search', '')))) -+ -+@app.route('/api/resources/allocate', methods=['POST']) -+@login_required -+def allocate_resources_api(): -+ """API für Ressourcen-Zuweisung bei Lizenzerstellung""" -+ data = request.json -+ license_id = data.get('license_id') -+ domain_count = data.get('domain_count', 1) -+ ipv4_count = data.get('ipv4_count', 1) -+ phone_count = data.get('phone_count', 1) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ allocated = {'domains': [], 'ipv4s': [], 'phones': []} -+ -+ # Allocate domains -+ if domain_count > 0: -+ cur.execute(""" -+ SELECT id, resource_value FROM resource_pools -+ WHERE resource_type = 'domain' AND status = 'available' -+ LIMIT %s FOR UPDATE -+ """, (domain_count,)) -+ domains = cur.fetchall() -+ -+ if len(domains) < domain_count: -+ raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") -+ -+ for domain_id, domain_value in domains: -+ # Update resource status -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', -+ allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, -+ status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], domain_id)) -+ -+ # Create assignment -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, domain_id, session['username'])) -+ -+ # Log history -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (domain_id, license_id, session['username'], get_client_ip())) -+ -+ allocated['domains'].append(domain_value) -+ -+ # Allocate IPv4s (similar logic) -+ if ipv4_count > 0: -+ cur.execute(""" -+ SELECT id, resource_value FROM resource_pools -+ WHERE resource_type = 'ipv4' AND status = 'available' -+ LIMIT %s FOR UPDATE -+ """, (ipv4_count,)) -+ ipv4s = cur.fetchall() -+ -+ if len(ipv4s) < ipv4_count: -+ raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") -+ -+ for ipv4_id, ipv4_value in ipv4s: -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', -+ allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, -+ status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], ipv4_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, ipv4_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (ipv4_id, license_id, session['username'], get_client_ip())) -+ -+ allocated['ipv4s'].append(ipv4_value) -+ -+ # Allocate phones (similar logic) -+ if phone_count > 0: -+ cur.execute(""" -+ SELECT id, resource_value FROM resource_pools -+ WHERE resource_type = 'phone' AND status = 'available' -+ LIMIT %s FOR UPDATE -+ """, (phone_count,)) -+ phones = cur.fetchall() -+ -+ if len(phones) < phone_count: -+ raise ValueError(f"Nicht genügend Telefonnummern verfügbar") -+ -+ for phone_id, phone_value in phones: -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', -+ allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, -+ status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], phone_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, phone_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (phone_id, license_id, session['username'], get_client_ip())) -+ -+ allocated['phones'].append(phone_value) -+ -+ # Update license resource counts -+ cur.execute(""" -+ UPDATE licenses -+ SET domain_count = %s, -+ ipv4_count = %s, -+ phone_count = %s -+ WHERE id = %s -+ """, (domain_count, ipv4_count, phone_count, license_id)) -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'success': True, -+ 'allocated': allocated -+ }) -+ -+ except Exception as e: -+ conn.rollback() -+ cur.close() -+ conn.close() -+ return jsonify({ -+ 'success': False, -+ 'error': str(e) -+ }), 400 -+ -+@app.route('/api/resources/check-availability', methods=['GET']) -+@login_required -+def check_resource_availability(): -+ """Prüft verfügbare Ressourcen""" -+ resource_type = request.args.get('type', '') -+ count = request.args.get('count', 10, type=int) -+ show_test = request.args.get('show_test', 'false').lower() == 'true' -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ if resource_type: -+ # Spezifische Ressourcen für einen Typ -+ cur.execute(""" -+ SELECT id, resource_value -+ FROM resource_pools -+ WHERE status = 'available' -+ AND resource_type = %s -+ AND is_test = %s -+ ORDER BY resource_value -+ LIMIT %s -+ """, (resource_type, show_test, count)) -+ -+ resources = [] -+ for row in cur.fetchall(): -+ resources.append({ -+ 'id': row[0], -+ 'value': row[1] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'available': resources, -+ 'type': resource_type, -+ 'count': len(resources) -+ }) -+ else: -+ # Zusammenfassung aller Typen -+ cur.execute(""" -+ SELECT -+ resource_type, -+ COUNT(*) as available -+ FROM resource_pools -+ WHERE status = 'available' -+ AND is_test = %s -+ GROUP BY resource_type -+ """, (show_test,)) -+ -+ availability = {} -+ for row in cur.fetchall(): -+ availability[row[0]] = row[1] -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify(availability) -+ -+@app.route('/api/global-search', methods=['GET']) -+@login_required -+def global_search(): -+ """Global search API endpoint for searching customers and licenses""" -+ query = request.args.get('q', '').strip() -+ -+ if not query or len(query) < 2: -+ return jsonify({'customers': [], 'licenses': []}) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Search pattern with wildcards -+ search_pattern = f'%{query}%' -+ -+ # Search customers -+ cur.execute(""" -+ SELECT id, name, email, company_name -+ FROM customers -+ WHERE (LOWER(name) LIKE LOWER(%s) -+ OR LOWER(email) LIKE LOWER(%s) -+ OR LOWER(company_name) LIKE LOWER(%s)) -+ AND is_test = FALSE -+ ORDER BY name -+ LIMIT 5 -+ """, (search_pattern, search_pattern, search_pattern)) -+ -+ customers = [] -+ for row in cur.fetchall(): -+ customers.append({ -+ 'id': row[0], -+ 'name': row[1], -+ 'email': row[2], -+ 'company_name': row[3] -+ }) -+ -+ # Search licenses -+ cur.execute(""" -+ SELECT l.id, l.license_key, c.name as customer_name -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ WHERE LOWER(l.license_key) LIKE LOWER(%s) -+ AND l.is_test = FALSE -+ ORDER BY l.created_at DESC -+ LIMIT 5 -+ """, (search_pattern,)) -+ -+ licenses = [] -+ for row in cur.fetchall(): -+ licenses.append({ -+ 'id': row[0], -+ 'license_key': row[1], -+ 'customer_name': row[2] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'customers': customers, -+ 'licenses': licenses -+ }) -+ -+@app.route('/resources/history/') -+@login_required -+def resource_history(resource_id): -+ """Zeigt die komplette Historie einer Ressource""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Get complete resource info using named columns -+ cur.execute(""" -+ SELECT id, resource_type, resource_value, status, allocated_to_license, -+ status_changed_at, status_changed_by, quarantine_reason, -+ quarantine_until, created_at, notes -+ FROM resource_pools -+ WHERE id = %s -+ """, (resource_id,)) -+ row = cur.fetchone() -+ -+ if not row: -+ flash('Ressource nicht gefunden', 'error') -+ return redirect(url_for('resources')) -+ -+ # Create resource object with named attributes -+ resource = { -+ 'id': row[0], -+ 'resource_type': row[1], -+ 'resource_value': row[2], -+ 'status': row[3], -+ 'allocated_to_license': row[4], -+ 'status_changed_at': row[5], -+ 'status_changed_by': row[6], -+ 'quarantine_reason': row[7], -+ 'quarantine_until': row[8], -+ 'created_at': row[9], -+ 'notes': row[10] -+ } -+ -+ # Get license info if allocated -+ license_info = None -+ if resource['allocated_to_license']: -+ cur.execute("SELECT license_key FROM licenses WHERE id = %s", -+ (resource['allocated_to_license'],)) -+ lic = cur.fetchone() -+ if lic: -+ license_info = {'license_key': lic[0]} -+ -+ # Get history with named columns -+ cur.execute(""" -+ SELECT -+ rh.action, -+ rh.action_by, -+ rh.action_at, -+ rh.details, -+ rh.license_id, -+ rh.ip_address -+ FROM resource_history rh -+ WHERE rh.resource_id = %s -+ ORDER BY rh.action_at DESC -+ """, (resource_id,)) -+ -+ history = [] -+ for row in cur.fetchall(): -+ history.append({ -+ 'action': row[0], -+ 'action_by': row[1], -+ 'action_at': row[2], -+ 'details': row[3], -+ 'license_id': row[4], -+ 'ip_address': row[5] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ # Convert to object-like for template -+ class ResourceObj: -+ def __init__(self, data): -+ for key, value in data.items(): -+ setattr(self, key, value) -+ -+ resource_obj = ResourceObj(resource) -+ history_objs = [ResourceObj(h) for h in history] -+ -+ return render_template('resource_history.html', -+ resource=resource_obj, -+ license_info=license_info, -+ history=history_objs) -+ -+@app.route('/resources/metrics') -+@login_required -+def resources_metrics(): -+ """Dashboard für Resource Metrics und Reports""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Overall stats with fallback values -+ cur.execute(""" -+ SELECT -+ COUNT(DISTINCT resource_id) as total_resources, -+ COALESCE(AVG(performance_score), 0) as avg_performance, -+ COALESCE(SUM(cost), 0) as total_cost, -+ COALESCE(SUM(revenue), 0) as total_revenue, -+ COALESCE(SUM(issues_count), 0) as total_issues -+ FROM resource_metrics -+ WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' -+ """) -+ row = cur.fetchone() -+ -+ # Calculate ROI -+ roi = 0 -+ if row[2] > 0: # if total_cost > 0 -+ roi = row[3] / row[2] # revenue / cost -+ -+ stats = { -+ 'total_resources': row[0] or 0, -+ 'avg_performance': row[1] or 0, -+ 'total_cost': row[2] or 0, -+ 'total_revenue': row[3] or 0, -+ 'total_issues': row[4] or 0, -+ 'roi': roi -+ } -+ -+ # Performance by type -+ cur.execute(""" -+ SELECT -+ rp.resource_type, -+ COALESCE(AVG(rm.performance_score), 0) as avg_score, -+ COUNT(DISTINCT rp.id) as resource_count -+ FROM resource_pools rp -+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -+ GROUP BY rp.resource_type -+ ORDER BY rp.resource_type -+ """) -+ performance_by_type = cur.fetchall() -+ -+ # Utilization data -+ cur.execute(""" -+ SELECT -+ resource_type, -+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -+ COUNT(*) as total, -+ ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent -+ FROM resource_pools -+ GROUP BY resource_type -+ """) -+ utilization_rows = cur.fetchall() -+ utilization_data = [ -+ { -+ 'type': row[0].upper(), -+ 'allocated': row[1], -+ 'total': row[2], -+ 'allocated_percent': row[3] -+ } -+ for row in utilization_rows -+ ] -+ -+ # Top performing resources -+ cur.execute(""" -+ SELECT -+ rp.id, -+ rp.resource_type, -+ rp.resource_value, -+ COALESCE(AVG(rm.performance_score), 0) as avg_score, -+ COALESCE(SUM(rm.revenue), 0) as total_revenue, -+ COALESCE(SUM(rm.cost), 1) as total_cost, -+ CASE -+ WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 -+ ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) -+ END as roi -+ FROM resource_pools rp -+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -+ WHERE rp.status != 'quarantine' -+ GROUP BY rp.id, rp.resource_type, rp.resource_value -+ HAVING AVG(rm.performance_score) IS NOT NULL -+ ORDER BY avg_score DESC -+ LIMIT 10 -+ """) -+ top_rows = cur.fetchall() -+ top_performers = [ -+ { -+ 'id': row[0], -+ 'resource_type': row[1], -+ 'resource_value': row[2], -+ 'avg_score': row[3], -+ 'roi': row[6] -+ } -+ for row in top_rows -+ ] -+ -+ # Resources with issues -+ cur.execute(""" -+ SELECT -+ rp.id, -+ rp.resource_type, -+ rp.resource_value, -+ rp.status, -+ COALESCE(SUM(rm.issues_count), 0) as total_issues -+ FROM resource_pools rp -+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -+ WHERE rm.issues_count > 0 OR rp.status = 'quarantine' -+ GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status -+ HAVING SUM(rm.issues_count) > 0 -+ ORDER BY total_issues DESC -+ LIMIT 10 -+ """) -+ problem_rows = cur.fetchall() -+ problem_resources = [ -+ { -+ 'id': row[0], -+ 'resource_type': row[1], -+ 'resource_value': row[2], -+ 'status': row[3], -+ 'total_issues': row[4] -+ } -+ for row in problem_rows -+ ] -+ -+ # Daily metrics for trend chart (last 30 days) -+ cur.execute(""" -+ SELECT -+ metric_date, -+ COALESCE(AVG(performance_score), 0) as avg_performance, -+ COALESCE(SUM(issues_count), 0) as total_issues -+ FROM resource_metrics -+ WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' -+ GROUP BY metric_date -+ ORDER BY metric_date -+ """) -+ daily_rows = cur.fetchall() -+ daily_metrics = [ -+ { -+ 'date': row[0].strftime('%d.%m'), -+ 'performance': float(row[1]), -+ 'issues': int(row[2]) -+ } -+ for row in daily_rows -+ ] -+ -+ cur.close() -+ conn.close() -+ -+ return render_template('resource_metrics.html', -+ stats=stats, -+ performance_by_type=performance_by_type, -+ utilization_data=utilization_data, -+ top_performers=top_performers, -+ problem_resources=problem_resources, -+ daily_metrics=daily_metrics) -+ -+@app.route('/resources/report', methods=['GET']) -+@login_required -+def resources_report(): -+ """Generiert Ressourcen-Reports oder zeigt Report-Formular""" -+ # Prüfe ob Download angefordert wurde -+ if request.args.get('download') == 'true': -+ report_type = request.args.get('type', 'usage') -+ format_type = request.args.get('format', 'excel') -+ date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) -+ date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ if report_type == 'usage': -+ # Auslastungsreport -+ query = """ -+ SELECT -+ rp.resource_type, -+ rp.resource_value, -+ rp.status, -+ COUNT(DISTINCT rh.license_id) as unique_licenses, -+ COUNT(rh.id) as total_allocations, -+ MIN(rh.action_at) as first_used, -+ MAX(rh.action_at) as last_used -+ FROM resource_pools rp -+ LEFT JOIN resource_history rh ON rp.id = rh.resource_id -+ AND rh.action = 'allocated' -+ AND rh.action_at BETWEEN %s AND %s -+ GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status -+ ORDER BY rp.resource_type, total_allocations DESC -+ """ -+ cur.execute(query, (date_from, date_to)) -+ columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] -+ -+ elif report_type == 'performance': -+ # Performance-Report -+ query = """ -+ SELECT -+ rp.resource_type, -+ rp.resource_value, -+ AVG(rm.performance_score) as avg_performance, -+ SUM(rm.usage_count) as total_usage, -+ SUM(rm.revenue) as total_revenue, -+ SUM(rm.cost) as total_cost, -+ SUM(rm.revenue - rm.cost) as profit, -+ SUM(rm.issues_count) as total_issues -+ FROM resource_pools rp -+ JOIN resource_metrics rm ON rp.id = rm.resource_id -+ WHERE rm.metric_date BETWEEN %s AND %s -+ GROUP BY rp.id, rp.resource_type, rp.resource_value -+ ORDER BY profit DESC -+ """ -+ cur.execute(query, (date_from, date_to)) -+ columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] -+ -+ elif report_type == 'compliance': -+ # Compliance-Report -+ query = """ -+ SELECT -+ rh.action_at, -+ rh.action, -+ rh.action_by, -+ rp.resource_type, -+ rp.resource_value, -+ l.license_key, -+ c.name as customer_name, -+ rh.ip_address -+ FROM resource_history rh -+ JOIN resource_pools rp ON rh.resource_id = rp.id -+ LEFT JOIN licenses l ON rh.license_id = l.id -+ LEFT JOIN customers c ON l.customer_id = c.id -+ WHERE rh.action_at BETWEEN %s AND %s -+ ORDER BY rh.action_at DESC -+ """ -+ cur.execute(query, (date_from, date_to)) -+ columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] -+ -+ else: # inventory report -+ # Inventar-Report -+ query = """ -+ SELECT -+ resource_type, -+ COUNT(*) FILTER (WHERE status = 'available') as available, -+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -+ COUNT(*) as total -+ FROM resource_pools -+ GROUP BY resource_type -+ ORDER BY resource_type -+ """ -+ cur.execute(query) -+ columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] -+ -+ # Convert to DataFrame -+ data = cur.fetchall() -+ df = pd.DataFrame(data, columns=columns) -+ -+ cur.close() -+ conn.close() -+ -+ # Generate file -+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -+ filename = f"resource_report_{report_type}_{timestamp}" -+ -+ if format_type == 'excel': -+ output = io.BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, sheet_name='Report', index=False) -+ -+ # Auto-adjust columns width -+ worksheet = writer.sheets['Report'] -+ for column in worksheet.columns: -+ max_length = 0 -+ column = [cell for cell in column] -+ for cell in column: -+ try: -+ if len(str(cell.value)) > max_length: -+ max_length = len(str(cell.value)) -+ except: -+ pass -+ adjusted_width = (max_length + 2) -+ worksheet.column_dimensions[column[0].column_letter].width = adjusted_width -+ -+ output.seek(0) -+ -+ log_audit('EXPORT', 'resource_report', None, -+ new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, -+ additional_info=f"Resource Report {report_type} exportiert") -+ -+ return send_file(output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx') -+ -+ else: # CSV -+ output = io.StringIO() -+ df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') -+ output.seek(0) -+ -+ log_audit('EXPORT', 'resource_report', None, -+ new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, -+ additional_info=f"Resource Report {report_type} exportiert") -+ -+ return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), -+ mimetype='text/csv', -+ as_attachment=True, -+ download_name=f'{filename}.csv') -+ -+ # Wenn kein Download, zeige Report-Formular -+ return render_template('resource_report.html', -+ datetime=datetime, -+ timedelta=timedelta, -+ username=session.get('username')) -+ -+if __name__ == "__main__": -+ app.run(host="0.0.0.0", port=5000) -diff --git a/v2_adminpanel/app.py.old b/v2_adminpanel/app.py.old -index 3849500..34c3f29 100644 ---- a/v2_adminpanel/app.py.old -+++ b/v2_adminpanel/app.py.old -@@ -1,5021 +1,5021 @@ --import os --import time --import json --import logging --import requests --from io import BytesIO --from datetime import datetime, timedelta --from zoneinfo import ZoneInfo --from pathlib import Path -- --from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash --from flask_session import Session --from werkzeug.middleware.proxy_fix import ProxyFix --from apscheduler.schedulers.background import BackgroundScheduler --import pandas as pd --from psycopg2.extras import Json -- --# Import our new modules --import config --from db import get_connection, get_db_connection, get_db_cursor, execute_query --from auth.decorators import login_required --from auth.password import hash_password, verify_password --from auth.two_factor import ( -- generate_totp_secret, generate_qr_code, verify_totp, -- generate_backup_codes, hash_backup_code, verify_backup_code --) --from auth.rate_limiting import ( -- get_client_ip, check_ip_blocked, record_failed_attempt, -- reset_login_attempts, get_login_attempts --) --from utils.audit import log_audit --from utils.license import generate_license_key, validate_license_key --from utils.backup import create_backup, restore_backup, get_or_create_encryption_key --from utils.export import ( -- create_excel_export, format_datetime_for_export, -- prepare_license_export_data, prepare_customer_export_data, -- prepare_session_export_data, prepare_audit_export_data --) -- --app = Flask(__name__) --# Load configuration from config module --app.config['SECRET_KEY'] = config.SECRET_KEY --app.config['SESSION_TYPE'] = config.SESSION_TYPE --app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII --app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE --app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME --app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY --app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE --app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE --app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME --app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST --Session(app) -- --# ProxyFix für korrekte IP-Adressen hinter Nginx --app.wsgi_app = ProxyFix( -- app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 --) -- --# Configuration is now loaded from config module -- --# Scheduler für automatische Backups --scheduler = BackgroundScheduler() --scheduler.start() -- --# Logging konfigurieren --logging.basicConfig(level=logging.INFO) -- -- --# Login decorator --def login_required(f): -- @wraps(f) -- def decorated_function(*args, **kwargs): -- if 'logged_in' not in session: -- return redirect(url_for('login')) -- -- # Prüfe ob Session abgelaufen ist -- 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 -- app.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 abgelaufen - Logout -- username = session.get('username', 'unbekannt') -- app.logger.info(f"Session timeout for user {username} - auto logout") -- # Audit-Log für automatischen Logout (vor 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('login')) -- -- # Aktivität NICHT automatisch aktualisieren -- # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) -- return f(*args, **kwargs) -- return decorated_function -- --# DB-Verbindung mit UTF-8 Encoding --def get_connection(): -- 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"), -- options='-c client_encoding=UTF8' -- ) -- conn.set_client_encoding('UTF8') -- return conn -- --# User Authentication Helper Functions --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')) -- --def get_user_by_username(username): -- """Get user from database by username""" -- conn = get_connection() -- cur = conn.cursor() -- try: -- cur.execute(""" -- SELECT id, username, password_hash, email, totp_secret, totp_enabled, -- backup_codes, last_password_change, failed_2fa_attempts -- FROM users WHERE username = %s -- """, (username,)) -- user = cur.fetchone() -- if user: -- return { -- 'id': user[0], -- 'username': user[1], -- 'password_hash': user[2], -- 'email': user[3], -- 'totp_secret': user[4], -- 'totp_enabled': user[5], -- 'backup_codes': user[6], -- 'last_password_change': user[7], -- 'failed_2fa_attempts': user[8] -- } -- return None -- finally: -- cur.close() -- conn.close() -- --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 -- --# Audit-Log-Funktion --def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): -- """Protokolliert Änderungen im Audit-Log""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- username = session.get('username', 'system') -- ip_address = get_client_ip() if request else None -- user_agent = request.headers.get('User-Agent') if request else None -- -- # Debug logging -- app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") -- -- # Konvertiere Dictionaries zu JSONB -- old_json = Json(old_values) if old_values else None -- new_json = Json(new_values) if new_values else None -- -- cur.execute(""" -- INSERT INTO audit_log -- (username, action, entity_type, entity_id, old_values, new_values, -- ip_address, user_agent, additional_info) -- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) -- """, (username, action, entity_type, entity_id, old_json, new_json, -- ip_address, user_agent, additional_info)) -- -- conn.commit() -- except Exception as e: -- print(f"Audit log error: {e}") -- conn.rollback() -- finally: -- cur.close() -- conn.close() -- --# Verschlüsselungs-Funktionen --def get_or_create_encryption_key(): -- """Holt oder erstellt einen Verschlüsselungsschlüssel""" -- key_file = BACKUP_DIR / ".backup_key" -- -- # Versuche Key aus Umgebungsvariable zu lesen -- env_key = os.getenv("BACKUP_ENCRYPTION_KEY") -- if env_key: -- try: -- # Validiere den Key -- Fernet(env_key.encode()) -- return env_key.encode() -- except: -- pass -- -- # Wenn kein gültiger Key in ENV, prüfe Datei -- if key_file.exists(): -- return key_file.read_bytes() -- -- # Erstelle neuen Key -- key = Fernet.generate_key() -- key_file.write_bytes(key) -- logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt") -- return key -- --# Backup-Funktionen --def create_backup(backup_type="manual", created_by=None): -- """Erstellt ein verschlüsseltes Backup der Datenbank""" -- start_time = time.time() -- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") -- filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" -- filepath = BACKUP_DIR / filename -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Backup-Eintrag erstellen -- cur.execute(""" -- INSERT INTO backup_history -- (filename, filepath, backup_type, status, created_by, is_encrypted) -- VALUES (%s, %s, %s, %s, %s, %s) -- RETURNING id -- """, (filename, str(filepath), backup_type, 'in_progress', -- created_by or 'system', True)) -- backup_id = cur.fetchone()[0] -- conn.commit() -- -- try: -- # PostgreSQL Dump erstellen -- dump_command = [ -- 'pg_dump', -- '-h', os.getenv("POSTGRES_HOST", "postgres"), -- '-p', os.getenv("POSTGRES_PORT", "5432"), -- '-U', os.getenv("POSTGRES_USER"), -- '-d', os.getenv("POSTGRES_DB"), -- '--no-password', -- '--verbose' -- ] -- -- # PGPASSWORD setzen -- env = os.environ.copy() -- env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") -- -- # Dump ausführen -- result = subprocess.run(dump_command, capture_output=True, text=True, env=env) -- -- if result.returncode != 0: -- raise Exception(f"pg_dump failed: {result.stderr}") -- -- dump_data = result.stdout.encode('utf-8') -- -- # Komprimieren -- compressed_data = gzip.compress(dump_data) -- -- # Verschlüsseln -- key = get_or_create_encryption_key() -- f = Fernet(key) -- encrypted_data = f.encrypt(compressed_data) -- -- # Speichern -- filepath.write_bytes(encrypted_data) -- -- # Statistiken sammeln -- cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") -- tables_count = cur.fetchone()[0] -- -- cur.execute(""" -- SELECT SUM(n_live_tup) -- FROM pg_stat_user_tables -- """) -- records_count = cur.fetchone()[0] or 0 -- -- duration = time.time() - start_time -- filesize = filepath.stat().st_size -- -- # Backup-Eintrag aktualisieren -- cur.execute(""" -- UPDATE backup_history -- SET status = %s, filesize = %s, tables_count = %s, -- records_count = %s, duration_seconds = %s -- WHERE id = %s -- """, ('success', filesize, tables_count, records_count, duration, backup_id)) -- -- conn.commit() -- -- # Audit-Log -- log_audit('BACKUP', 'database', backup_id, -- additional_info=f"Backup erstellt: {filename} ({filesize} bytes)") -- -- # E-Mail-Benachrichtigung (wenn konfiguriert) -- send_backup_notification(True, filename, filesize, duration) -- -- logging.info(f"Backup erfolgreich erstellt: {filename}") -- return True, filename -- -- except Exception as e: -- # Fehler protokollieren -- cur.execute(""" -- UPDATE backup_history -- SET status = %s, error_message = %s, duration_seconds = %s -- WHERE id = %s -- """, ('failed', str(e), time.time() - start_time, backup_id)) -- conn.commit() -- -- logging.error(f"Backup fehlgeschlagen: {e}") -- send_backup_notification(False, filename, error=str(e)) -- -- return False, str(e) -- -- finally: -- cur.close() -- conn.close() -- --def restore_backup(backup_id, encryption_key=None): -- """Stellt ein Backup wieder her""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Backup-Info abrufen -- cur.execute(""" -- SELECT filename, filepath, is_encrypted -- FROM backup_history -- WHERE id = %s -- """, (backup_id,)) -- backup_info = cur.fetchone() -- -- if not backup_info: -- raise Exception("Backup nicht gefunden") -- -- filename, filepath, is_encrypted = backup_info -- filepath = Path(filepath) -- -- if not filepath.exists(): -- raise Exception("Backup-Datei nicht gefunden") -- -- # Datei lesen -- encrypted_data = filepath.read_bytes() -- -- # Entschlüsseln -- if is_encrypted: -- key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() -- try: -- f = Fernet(key) -- compressed_data = f.decrypt(encrypted_data) -- except: -- raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?") -- else: -- compressed_data = encrypted_data -- -- # Dekomprimieren -- dump_data = gzip.decompress(compressed_data) -- sql_commands = dump_data.decode('utf-8') -- -- # Bestehende Verbindungen schließen -- cur.close() -- conn.close() -- -- # Datenbank wiederherstellen -- restore_command = [ -- 'psql', -- '-h', os.getenv("POSTGRES_HOST", "postgres"), -- '-p', os.getenv("POSTGRES_PORT", "5432"), -- '-U', os.getenv("POSTGRES_USER"), -- '-d', os.getenv("POSTGRES_DB"), -- '--no-password' -- ] -- -- env = os.environ.copy() -- env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") -- -- result = subprocess.run(restore_command, input=sql_commands, -- capture_output=True, text=True, env=env) -- -- if result.returncode != 0: -- raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}") -- -- # Audit-Log (neue Verbindung) -- log_audit('RESTORE', 'database', backup_id, -- additional_info=f"Backup wiederhergestellt: {filename}") -- -- return True, "Backup erfolgreich wiederhergestellt" -- -- except Exception as e: -- logging.error(f"Wiederherstellung fehlgeschlagen: {e}") -- return False, str(e) -- --def send_backup_notification(success, filename, filesize=None, duration=None, error=None): -- """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)""" -- if not os.getenv("EMAIL_ENABLED", "false").lower() == "true": -- return -- -- # E-Mail-Funktion vorbereitet aber deaktiviert -- # TODO: Implementieren wenn E-Mail-Server konfiguriert ist -- logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}") -- --# Scheduled Backup Job --def scheduled_backup(): -- """Führt ein geplantes Backup aus""" -- logging.info("Starte geplantes Backup...") -- create_backup(backup_type="scheduled", created_by="scheduler") -- --# Scheduler konfigurieren - täglich um 3:00 Uhr --scheduler.add_job( -- scheduled_backup, -- 'cron', -- hour=3, -- minute=0, -- id='daily_backup', -- replace_existing=True --) -- --# Rate-Limiting Funktionen --def get_client_ip(): -- """Ermittelt die echte IP-Adresse des Clients""" -- # Debug logging -- app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") -- -- # Try X-Real-IP first (set by nginx) -- if request.headers.get('X-Real-IP'): -- return request.headers.get('X-Real-IP') -- # Then X-Forwarded-For -- elif request.headers.get('X-Forwarded-For'): -- # X-Forwarded-For can contain multiple IPs, take the first one -- return request.headers.get('X-Forwarded-For').split(',')[0].strip() -- # Fallback to remote_addr -- else: -- return request.remote_addr -- --def check_ip_blocked(ip_address): -- """Prüft ob eine IP-Adresse gesperrt ist""" -- conn = get_connection() -- cur = conn.cursor() -- -- cur.execute(""" -- SELECT blocked_until FROM login_attempts -- WHERE ip_address = %s AND blocked_until IS NOT NULL -- """, (ip_address,)) -- -- result = cur.fetchone() -- cur.close() -- conn.close() -- -- 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): -- """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Random Fehlermeldung -- error_message = random.choice(FAIL_MESSAGES) -- -- try: -- # Prüfen ob IP bereits existiert -- cur.execute(""" -- SELECT attempt_count FROM login_attempts -- WHERE ip_address = %s -- """, (ip_address,)) -- -- result = cur.fetchone() -- -- if result: -- # Update bestehenden Eintrag -- 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) -- # E-Mail-Benachrichtigung (wenn aktiviert) -- if os.getenv("EMAIL_ENABLED", "false").lower() == "true": -- 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: -- # Neuen Eintrag erstellen -- 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: -- print(f"Rate limiting error: {e}") -- conn.rollback() -- finally: -- cur.close() -- conn.close() -- -- return error_message -- --def reset_login_attempts(ip_address): -- """Setzt die Login-Versuche für eine IP zurück""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- cur.execute(""" -- DELETE FROM login_attempts -- WHERE ip_address = %s -- """, (ip_address,)) -- conn.commit() -- except Exception as e: -- print(f"Reset attempts error: {e}") -- conn.rollback() -- finally: -- cur.close() -- conn.close() -- --def get_login_attempts(ip_address): -- """Gibt die Anzahl der Login-Versuche für eine IP zurück""" -- conn = get_connection() -- cur = conn.cursor() -- -- cur.execute(""" -- SELECT attempt_count FROM login_attempts -- WHERE ip_address = %s -- """, (ip_address,)) -- -- result = cur.fetchone() -- cur.close() -- conn.close() -- -- return result[0] if result else 0 -- --def send_security_alert_email(ip_address, username, attempt_count): -- """Sendet eine Sicherheitswarnung per E-Mail""" -- 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: E-Mail-Versand implementieren wenn SMTP konfiguriert -- logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") -- print(f"E-Mail würde gesendet: {subject}") -- --def verify_recaptcha(response): -- """Verifiziert die reCAPTCHA v2 Response mit Google""" -- secret_key = os.getenv('RECAPTCHA_SECRET_KEY') -- -- # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) -- if not secret_key: -- logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") -- return True -- -- # Verifizierung bei Google -- try: -- verify_url = 'https://www.google.com/recaptcha/api/siteverify' -- data = { -- 'secret': secret_key, -- 'response': response -- } -- -- # Timeout für Request setzen -- r = requests.post(verify_url, data=data, timeout=5) -- result = r.json() -- -- # Log für Debugging -- if not result.get('success'): -- logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") -- -- return result.get('success', False) -- -- except requests.exceptions.RequestException as e: -- logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") -- # Bei Netzwerkfehlern CAPTCHA als bestanden werten -- return True -- except Exception as e: -- logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") -- return False -- --def generate_license_key(license_type='full'): -- """ -- Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ -- -- AF = Account Factory (Produktkennung) -- F/T = F für Fullversion, T für Testversion -- YYYY = Jahr -- MM = Monat -- XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen -- """ -- # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) -- chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' -- -- # Datum-Teil -- now = datetime.now(ZoneInfo("Europe/Berlin")) -- date_part = now.strftime('%Y%m') -- type_char = 'F' if license_type == 'full' else 'T' -- -- # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) -- parts = [] -- for _ in range(3): -- part = ''.join(secrets.choice(chars) for _ in range(4)) -- parts.append(part) -- -- # Key zusammensetzen -- key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" -- -- return key -- --def validate_license_key(key): -- """ -- Validiert das License Key Format -- Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ -- """ -- if not key: -- return False -- -- # Pattern für das neue Format -- # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen -- pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' -- -- # Großbuchstaben für Vergleich -- return bool(re.match(pattern, key.upper())) -- --@app.route("/login", methods=["GET", "POST"]) --def login(): -- # Timing-Attack Schutz - Start Zeit merken -- start_time = time.time() -- -- # IP-Adresse ermitteln -- ip_address = get_client_ip() -- -- # Prüfen ob IP gesperrt ist -- is_blocked, blocked_until = check_ip_blocked(ip_address) -- if is_blocked: -- time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 -- error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." -- return render_template("login.html", error=error_msg, error_type="blocked") -- -- # Anzahl bisheriger Versuche -- attempt_count = get_login_attempts(ip_address) -- -- if request.method == "POST": -- username = request.form.get("username") -- password = request.form.get("password") -- captcha_response = request.form.get("g-recaptcha-response") -- -- # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind -- recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') -- if attempt_count >= 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, 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, 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 -- admin1_user = os.getenv("ADMIN1_USERNAME") -- admin1_pass = os.getenv("ADMIN1_PASSWORD") -- admin2_user = os.getenv("ADMIN2_USERNAME") -- admin2_pass = os.getenv("ADMIN2_PASSWORD") -- -- if ((username == admin1_user and password == admin1_pass) or -- (username == admin2_user and password == admin2_pass)): -- login_success = True -- -- # Timing-Attack Schutz - Mindestens 1 Sekunde warten -- elapsed = time.time() - start_time -- if elapsed < 1.0: -- time.sleep(1.0 - elapsed) -- -- if login_success: -- # Erfolgreicher Login -- if needs_2fa: -- # Store temporary session for 2FA verification -- session['temp_username'] = username -- session['temp_user_id'] = user['id'] -- session['awaiting_2fa'] = True -- return redirect(url_for('verify_2fa')) -- else: -- # Complete login without 2FA -- session.permanent = True # Aktiviert das Timeout -- session['logged_in'] = True -- session['username'] = username -- session['user_id'] = user['id'] if user else None -- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- reset_login_attempts(ip_address) -- log_audit('LOGIN_SUCCESS', 'user', -- additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") -- return redirect(url_for('dashboard')) -- else: -- # Fehlgeschlagener Login -- error_message = record_failed_attempt(ip_address, username) -- new_attempt_count = get_login_attempts(ip_address) -- -- # Prüfen ob jetzt gesperrt -- is_now_blocked, _ = check_ip_blocked(ip_address) -- if is_now_blocked: -- log_audit('LOGIN_BLOCKED', 'security', -- additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") -- -- return render_template("login.html", -- error=error_message, -- show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), -- error_type="failed", -- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), -- recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) -- -- # GET Request -- return render_template("login.html", -- show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), -- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), -- recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) -- --@app.route("/logout") --def logout(): -- username = session.get('username', 'unknown') -- log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") -- session.pop('logged_in', None) -- session.pop('username', None) -- session.pop('user_id', None) -- session.pop('temp_username', None) -- session.pop('temp_user_id', None) -- session.pop('awaiting_2fa', None) -- return redirect(url_for('login')) -- --@app.route("/verify-2fa", methods=["GET", "POST"]) --def verify_2fa(): -- if not session.get('awaiting_2fa'): -- return redirect(url_for('login')) -- -- if request.method == "POST": -- token = request.form.get('token', '').replace(' ', '') -- username = session.get('temp_username') -- user_id = session.get('temp_user_id') -- -- if not username or not user_id: -- flash('Session expired. Please login again.', 'error') -- return redirect(url_for('login')) -- -- user = get_user_by_username(username) -- if not user: -- flash('User not found.', 'error') -- return redirect(url_for('login')) -- -- # Check if it's a backup code -- if len(token) == 8 and token.isupper(): -- # Try backup code -- backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] -- if verify_backup_code(token, backup_codes): -- # Remove used backup code -- code_hash = hash_backup_code(token) -- backup_codes.remove(code_hash) -- -- conn = get_connection() -- cur = conn.cursor() -- cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", -- (json.dumps(backup_codes), user_id)) -- conn.commit() -- cur.close() -- conn.close() -- -- # Complete login -- session.permanent = True -- session['logged_in'] = True -- session['username'] = username -- session['user_id'] = user_id -- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- session.pop('temp_username', None) -- session.pop('temp_user_id', None) -- session.pop('awaiting_2fa', None) -- -- flash('Login successful using backup code. Please generate new backup codes.', 'warning') -- log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") -- return redirect(url_for('dashboard')) -- else: -- # Try TOTP token -- if verify_totp(user['totp_secret'], token): -- # Complete login -- session.permanent = True -- session['logged_in'] = True -- session['username'] = username -- session['user_id'] = user_id -- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- session.pop('temp_username', None) -- session.pop('temp_user_id', None) -- session.pop('awaiting_2fa', None) -- -- log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") -- return redirect(url_for('dashboard')) -- -- # Failed verification -- conn = get_connection() -- cur = conn.cursor() -- cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", -- (datetime.now(), user_id)) -- conn.commit() -- cur.close() -- conn.close() -- -- flash('Invalid authentication code. Please try again.', 'error') -- log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") -- -- return render_template('verify_2fa.html') -- --@app.route("/profile") --@login_required --def profile(): -- user = get_user_by_username(session['username']) -- if not user: -- # For environment-based users, redirect with message -- flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') -- return redirect(url_for('dashboard')) -- return render_template('profile.html', user=user) -- --@app.route("/profile/change-password", methods=["POST"]) --@login_required --def change_password(): -- current_password = request.form.get('current_password') -- new_password = request.form.get('new_password') -- confirm_password = request.form.get('confirm_password') -- -- user = get_user_by_username(session['username']) -- -- # Verify current password -- if not verify_password(current_password, user['password_hash']): -- flash('Current password is incorrect.', 'error') -- return redirect(url_for('profile')) -- -- # Check new password -- if new_password != confirm_password: -- flash('New passwords do not match.', 'error') -- return redirect(url_for('profile')) -- -- if len(new_password) < 8: -- flash('Password must be at least 8 characters long.', 'error') -- return redirect(url_for('profile')) -- -- # Update password -- new_hash = hash_password(new_password) -- conn = get_connection() -- cur = conn.cursor() -- cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", -- (new_hash, datetime.now(), user['id'])) -- conn.commit() -- cur.close() -- conn.close() -- -- log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], -- additional_info="Password changed successfully") -- flash('Password changed successfully.', 'success') -- return redirect(url_for('profile')) -- --@app.route("/profile/setup-2fa") --@login_required --def setup_2fa(): -- user = get_user_by_username(session['username']) -- -- if user['totp_enabled']: -- flash('2FA is already enabled for your account.', 'info') -- return redirect(url_for('profile')) -- -- # Generate new TOTP secret -- totp_secret = generate_totp_secret() -- session['temp_totp_secret'] = totp_secret -- -- # Generate QR code -- qr_code = generate_qr_code(user['username'], totp_secret) -- -- return render_template('setup_2fa.html', -- totp_secret=totp_secret, -- qr_code=qr_code) -- --@app.route("/profile/enable-2fa", methods=["POST"]) --@login_required --def enable_2fa(): -- token = request.form.get('token', '').replace(' ', '') -- totp_secret = session.get('temp_totp_secret') -- -- if not totp_secret: -- flash('2FA setup session expired. Please try again.', 'error') -- return redirect(url_for('setup_2fa')) -- -- # Verify the token -- if not verify_totp(totp_secret, token): -- flash('Invalid authentication code. Please try again.', 'error') -- return redirect(url_for('setup_2fa')) -- -- # Generate backup codes -- backup_codes = generate_backup_codes() -- hashed_codes = [hash_backup_code(code) for code in backup_codes] -- -- # Enable 2FA -- conn = get_connection() -- cur = conn.cursor() -- cur.execute(""" -- UPDATE users -- SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s -- WHERE username = %s -- """, (totp_secret, json.dumps(hashed_codes), session['username'])) -- conn.commit() -- cur.close() -- conn.close() -- -- session.pop('temp_totp_secret', None) -- -- log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") -- -- # Show backup codes -- return render_template('backup_codes.html', backup_codes=backup_codes) -- --@app.route("/profile/disable-2fa", methods=["POST"]) --@login_required --def disable_2fa(): -- password = request.form.get('password') -- user = get_user_by_username(session['username']) -- -- # Verify password -- if not verify_password(password, user['password_hash']): -- flash('Incorrect password.', 'error') -- return redirect(url_for('profile')) -- -- # Disable 2FA -- conn = get_connection() -- cur = conn.cursor() -- cur.execute(""" -- UPDATE users -- SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL -- WHERE username = %s -- """, (session['username'],)) -- conn.commit() -- cur.close() -- conn.close() -- -- log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") -- flash('2FA has been disabled for your account.', 'success') -- return redirect(url_for('profile')) -- --@app.route("/heartbeat", methods=['POST']) --@login_required --def heartbeat(): -- """Endpoint für Session Keep-Alive - aktualisiert last_activity""" -- # Aktualisiere last_activity nur wenn explizit angefordert -- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- # Force session save -- session.modified = True -- -- return jsonify({ -- 'status': 'ok', -- 'last_activity': session['last_activity'], -- 'username': session.get('username') -- }) -- --@app.route("/api/generate-license-key", methods=['POST']) --@login_required --def api_generate_key(): -- """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" -- try: -- # Lizenztyp aus Request holen (default: full) -- data = request.get_json() or {} -- license_type = data.get('type', 'full') -- -- # Key generieren -- key = generate_license_key(license_type) -- -- # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) -- conn = get_connection() -- cur = conn.cursor() -- -- # Wiederhole bis eindeutiger Key gefunden -- attempts = 0 -- while attempts < 10: # Max 10 Versuche -- cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) -- if not cur.fetchone(): -- break # Key ist eindeutig -- key = generate_license_key(license_type) -- attempts += 1 -- -- cur.close() -- conn.close() -- -- # Log für Audit -- log_audit('GENERATE_KEY', 'license', -- additional_info={'type': license_type, 'key': key}) -- -- return jsonify({ -- 'success': True, -- 'key': key, -- 'type': license_type -- }) -- -- except Exception as e: -- logging.error(f"Fehler bei Key-Generierung: {str(e)}") -- return jsonify({ -- 'success': False, -- 'error': 'Fehler bei der Key-Generierung' -- }), 500 -- --@app.route("/api/customers", methods=['GET']) --@login_required --def api_customers(): -- """API Endpoint für die Kundensuche mit Select2""" -- try: -- # Suchparameter -- search = request.args.get('q', '').strip() -- page = request.args.get('page', 1, type=int) -- per_page = 20 -- customer_id = request.args.get('id', type=int) -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Einzelnen Kunden per ID abrufen -- if customer_id: -- cur.execute(""" -- SELECT c.id, c.name, c.email, -- COUNT(l.id) as license_count -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- WHERE c.id = %s -- GROUP BY c.id, c.name, c.email -- """, (customer_id,)) -- -- customer = cur.fetchone() -- results = [] -- if customer: -- results.append({ -- 'id': customer[0], -- 'text': f"{customer[1]} ({customer[2]})", -- 'name': customer[1], -- 'email': customer[2], -- 'license_count': customer[3] -- }) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'results': results, -- 'pagination': {'more': False} -- }) -- -- # SQL Query mit optionaler Suche -- elif search: -- cur.execute(""" -- SELECT c.id, c.name, c.email, -- COUNT(l.id) as license_count -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- WHERE LOWER(c.name) LIKE LOWER(%s) -- OR LOWER(c.email) LIKE LOWER(%s) -- GROUP BY c.id, c.name, c.email -- ORDER BY c.name -- LIMIT %s OFFSET %s -- """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) -- else: -- cur.execute(""" -- SELECT c.id, c.name, c.email, -- COUNT(l.id) as license_count -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- GROUP BY c.id, c.name, c.email -- ORDER BY c.name -- LIMIT %s OFFSET %s -- """, (per_page, (page - 1) * per_page)) -- -- customers = cur.fetchall() -- -- # Format für Select2 -- results = [] -- for customer in customers: -- results.append({ -- 'id': customer[0], -- 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", -- 'name': customer[1], -- 'email': customer[2], -- 'license_count': customer[3] -- }) -- -- # Gesamtanzahl für Pagination -- if search: -- cur.execute(""" -- SELECT COUNT(*) FROM customers -- WHERE LOWER(name) LIKE LOWER(%s) -- OR LOWER(email) LIKE LOWER(%s) -- """, (f'%{search}%', f'%{search}%')) -- else: -- cur.execute("SELECT COUNT(*) FROM customers") -- -- total_count = cur.fetchone()[0] -- -- cur.close() -- conn.close() -- -- # Select2 Response Format -- return jsonify({ -- 'results': results, -- 'pagination': { -- 'more': (page * per_page) < total_count -- } -- }) -- -- except Exception as e: -- logging.error(f"Fehler bei Kundensuche: {str(e)}") -- return jsonify({ -- 'results': [], -- 'error': 'Fehler bei der Kundensuche' -- }), 500 -- --@app.route("/") --@login_required --def dashboard(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Statistiken abrufen -- # Gesamtanzahl Kunden (ohne Testdaten) -- cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") -- total_customers = cur.fetchone()[0] -- -- # Gesamtanzahl Lizenzen (ohne Testdaten) -- cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") -- total_licenses = cur.fetchone()[0] -- -- # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) -- cur.execute(""" -- SELECT COUNT(*) FROM licenses -- WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE -- """) -- active_licenses = cur.fetchone()[0] -- -- # Aktive Sessions -- cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") -- active_sessions_count = cur.fetchone()[0] -- -- # Abgelaufene Lizenzen (ohne Testdaten) -- cur.execute(""" -- SELECT COUNT(*) FROM licenses -- WHERE valid_until < CURRENT_DATE AND is_test = FALSE -- """) -- expired_licenses = cur.fetchone()[0] -- -- # Deaktivierte Lizenzen (ohne Testdaten) -- cur.execute(""" -- SELECT COUNT(*) FROM licenses -- WHERE is_active = FALSE AND is_test = FALSE -- """) -- inactive_licenses = cur.fetchone()[0] -- -- # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) -- cur.execute(""" -- SELECT COUNT(*) FROM licenses -- WHERE valid_until >= CURRENT_DATE -- AND valid_until < CURRENT_DATE + INTERVAL '30 days' -- AND is_active = TRUE -- AND is_test = FALSE -- """) -- expiring_soon = cur.fetchone()[0] -- -- # Testlizenzen vs Vollversionen (ohne Testdaten) -- cur.execute(""" -- SELECT license_type, COUNT(*) -- FROM licenses -- WHERE is_test = FALSE -- GROUP BY license_type -- """) -- license_types = dict(cur.fetchall()) -- -- # Anzahl Testdaten -- cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") -- test_data_count = cur.fetchone()[0] -- -- # Anzahl Test-Kunden -- cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") -- test_customers_count = cur.fetchone()[0] -- -- # Anzahl Test-Ressourcen -- cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") -- test_resources_count = cur.fetchone()[0] -- -- # Letzte 5 erstellten Lizenzen (ohne Testdaten) -- cur.execute(""" -- SELECT l.id, l.license_key, c.name, l.valid_until, -- CASE -- WHEN l.is_active = FALSE THEN 'deaktiviert' -- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -- ELSE 'aktiv' -- END as status -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- WHERE l.is_test = FALSE -- ORDER BY l.id DESC -- LIMIT 5 -- """) -- recent_licenses = cur.fetchall() -- -- # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) -- cur.execute(""" -- SELECT l.id, l.license_key, c.name, l.valid_until, -- l.valid_until - CURRENT_DATE as days_left -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- WHERE l.valid_until >= CURRENT_DATE -- AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' -- AND l.is_active = TRUE -- AND l.is_test = FALSE -- ORDER BY l.valid_until -- LIMIT 10 -- """) -- expiring_licenses = cur.fetchall() -- -- # Letztes Backup -- cur.execute(""" -- SELECT created_at, filesize, duration_seconds, backup_type, status -- FROM backup_history -- ORDER BY created_at DESC -- LIMIT 1 -- """) -- last_backup_info = cur.fetchone() -- -- # Sicherheitsstatistiken -- # Gesperrte IPs -- cur.execute(""" -- SELECT COUNT(*) FROM login_attempts -- WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP -- """) -- blocked_ips_count = cur.fetchone()[0] -- -- # Fehlversuche heute -- cur.execute(""" -- SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts -- WHERE last_attempt::date = CURRENT_DATE -- """) -- failed_attempts_today = cur.fetchone()[0] -- -- # Letzte 5 Sicherheitsereignisse -- cur.execute(""" -- SELECT -- la.ip_address, -- la.attempt_count, -- la.last_attempt, -- la.blocked_until, -- la.last_username_tried, -- la.last_error_message -- FROM login_attempts la -- ORDER BY la.last_attempt DESC -- LIMIT 5 -- """) -- recent_security_events = [] -- for event in cur.fetchall(): -- recent_security_events.append({ -- 'ip_address': event[0], -- 'attempt_count': event[1], -- 'last_attempt': event[2].strftime('%d.%m %H:%M'), -- 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, -- 'username_tried': event[4], -- 'error_message': event[5] -- }) -- -- # Sicherheitslevel berechnen -- if blocked_ips_count > 5 or failed_attempts_today > 50: -- security_level = 'danger' -- security_level_text = 'KRITISCH' -- elif blocked_ips_count > 2 or failed_attempts_today > 20: -- security_level = 'warning' -- security_level_text = 'ERHÖHT' -- else: -- security_level = 'success' -- security_level_text = 'NORMAL' -- -- # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) -- cur.execute(""" -- SELECT -- resource_type, -- COUNT(*) FILTER (WHERE status = 'available') as available, -- COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -- COUNT(*) as total -- FROM resource_pools -- WHERE is_test = FALSE -- GROUP BY resource_type -- """) -- -- resource_stats = {} -- resource_warning = None -- -- for row in cur.fetchall(): -- available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) -- resource_stats[row[0]] = { -- 'available': row[1], -- 'allocated': row[2], -- 'quarantine': row[3], -- 'total': row[4], -- 'available_percent': available_percent, -- 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' -- } -- -- # Warnung bei niedrigem Bestand -- if row[1] < 50: -- if not resource_warning: -- resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" -- else: -- resource_warning += f" | {row[0].upper()}: {row[1]}" -- -- cur.close() -- conn.close() -- -- stats = { -- 'total_customers': total_customers, -- 'total_licenses': total_licenses, -- 'active_licenses': active_licenses, -- 'expired_licenses': expired_licenses, -- 'inactive_licenses': inactive_licenses, -- 'expiring_soon': expiring_soon, -- 'full_licenses': license_types.get('full', 0), -- 'test_licenses': license_types.get('test', 0), -- 'test_data_count': test_data_count, -- 'test_customers_count': test_customers_count, -- 'test_resources_count': test_resources_count, -- 'recent_licenses': recent_licenses, -- 'expiring_licenses': expiring_licenses, -- 'active_sessions': active_sessions_count, -- 'last_backup': last_backup_info, -- # Sicherheitsstatistiken -- 'blocked_ips_count': blocked_ips_count, -- 'failed_attempts_today': failed_attempts_today, -- 'recent_security_events': recent_security_events, -- 'security_level': security_level, -- 'security_level_text': security_level_text, -- 'resource_stats': resource_stats -- } -- -- return render_template("dashboard.html", -- stats=stats, -- resource_stats=resource_stats, -- resource_warning=resource_warning, -- username=session.get('username')) -- --@app.route("/create", methods=["GET", "POST"]) --@login_required --def create_license(): -- if request.method == "POST": -- customer_id = request.form.get("customer_id") -- license_key = request.form["license_key"].upper() # Immer Großbuchstaben -- license_type = request.form["license_type"] -- valid_from = request.form["valid_from"] -- is_test = request.form.get("is_test") == "on" # Checkbox value -- -- # Berechne valid_until basierend auf Laufzeit -- duration = int(request.form.get("duration", 1)) -- duration_type = request.form.get("duration_type", "years") -- -- from datetime import datetime, timedelta -- from dateutil.relativedelta import relativedelta -- -- start_date = datetime.strptime(valid_from, "%Y-%m-%d") -- -- if duration_type == "days": -- end_date = start_date + timedelta(days=duration) -- elif duration_type == "months": -- end_date = start_date + relativedelta(months=duration) -- else: # years -- end_date = start_date + relativedelta(years=duration) -- -- # Ein Tag abziehen, da der Starttag mitgezählt wird -- end_date = end_date - timedelta(days=1) -- valid_until = end_date.strftime("%Y-%m-%d") -- -- # Validiere License Key Format -- if not validate_license_key(license_key): -- flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') -- return redirect(url_for('create_license')) -- -- # Resource counts -- domain_count = int(request.form.get("domain_count", 1)) -- ipv4_count = int(request.form.get("ipv4_count", 1)) -- phone_count = int(request.form.get("phone_count", 1)) -- device_limit = int(request.form.get("device_limit", 3)) -- -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Prüfe ob neuer Kunde oder bestehender -- if customer_id == "new": -- # Neuer Kunde -- name = request.form.get("customer_name") -- email = request.form.get("email") -- -- if not name: -- flash('Kundenname ist erforderlich!', 'error') -- return redirect(url_for('create_license')) -- -- # Prüfe ob E-Mail bereits existiert -- if email: -- cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) -- existing = cur.fetchone() -- if existing: -- flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') -- return redirect(url_for('create_license')) -- -- # Kunde einfügen (erbt Test-Status von Lizenz) -- cur.execute(""" -- INSERT INTO customers (name, email, is_test, created_at) -- VALUES (%s, %s, %s, NOW()) -- RETURNING id -- """, (name, email, is_test)) -- customer_id = cur.fetchone()[0] -- customer_info = {'name': name, 'email': email, 'is_test': is_test} -- -- # Audit-Log für neuen Kunden -- log_audit('CREATE', 'customer', customer_id, -- new_values={'name': name, 'email': email, 'is_test': is_test}) -- else: -- # Bestehender Kunde - hole Infos für Audit-Log -- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -- customer_data = cur.fetchone() -- if not customer_data: -- flash('Kunde nicht gefunden!', 'error') -- return redirect(url_for('create_license')) -- customer_info = {'name': customer_data[0], 'email': customer_data[1]} -- -- # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren -- if customer_data[2]: # is_test des Kunden -- is_test = True -- -- # Lizenz hinzufügen -- cur.execute(""" -- INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, -- domain_count, ipv4_count, phone_count, device_limit, is_test) -- VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) -- RETURNING id -- """, (license_key, customer_id, license_type, valid_from, valid_until, -- domain_count, ipv4_count, phone_count, device_limit, is_test)) -- license_id = cur.fetchone()[0] -- -- # Ressourcen zuweisen -- try: -- # Prüfe Verfügbarkeit -- cur.execute(""" -- SELECT -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones -- """, (is_test, is_test, is_test)) -- available = cur.fetchone() -- -- if available[0] < domain_count: -- raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") -- if available[1] < ipv4_count: -- raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") -- if available[2] < phone_count: -- raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") -- -- # Domains zuweisen -- if domain_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, domain_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- # IPv4s zuweisen -- if ipv4_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, ipv4_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- # Telefonnummern zuweisen -- if phone_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, phone_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- except ValueError as e: -- conn.rollback() -- flash(str(e), 'error') -- return redirect(url_for('create_license')) -- -- conn.commit() -- -- # Audit-Log -- log_audit('CREATE', 'license', license_id, -- new_values={ -- 'license_key': license_key, -- 'customer_name': customer_info['name'], -- 'customer_email': customer_info['email'], -- 'license_type': license_type, -- 'valid_from': valid_from, -- 'valid_until': valid_until, -- 'device_limit': device_limit, -- 'is_test': is_test -- }) -- -- flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') -- -- except Exception as e: -- conn.rollback() -- logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") -- flash('Fehler beim Erstellen der Lizenz!', 'error') -- finally: -- cur.close() -- conn.close() -- -- # Preserve show_test parameter if present -- redirect_url = "/create" -- if request.args.get('show_test') == 'true': -- redirect_url += "?show_test=true" -- return redirect(redirect_url) -- -- # Unterstützung für vorausgewählten Kunden -- preselected_customer_id = request.args.get('customer_id', type=int) -- return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) -- --@app.route("/batch", methods=["GET", "POST"]) --@login_required --def batch_licenses(): -- """Batch-Generierung mehrerer Lizenzen für einen Kunden""" -- if request.method == "POST": -- # Formulardaten -- customer_id = request.form.get("customer_id") -- license_type = request.form["license_type"] -- quantity = int(request.form["quantity"]) -- valid_from = request.form["valid_from"] -- is_test = request.form.get("is_test") == "on" # Checkbox value -- -- # Berechne valid_until basierend auf Laufzeit -- duration = int(request.form.get("duration", 1)) -- duration_type = request.form.get("duration_type", "years") -- -- from datetime import datetime, timedelta -- from dateutil.relativedelta import relativedelta -- -- start_date = datetime.strptime(valid_from, "%Y-%m-%d") -- -- if duration_type == "days": -- end_date = start_date + timedelta(days=duration) -- elif duration_type == "months": -- end_date = start_date + relativedelta(months=duration) -- else: # years -- end_date = start_date + relativedelta(years=duration) -- -- # Ein Tag abziehen, da der Starttag mitgezählt wird -- end_date = end_date - timedelta(days=1) -- valid_until = end_date.strftime("%Y-%m-%d") -- -- # Resource counts -- domain_count = int(request.form.get("domain_count", 1)) -- ipv4_count = int(request.form.get("ipv4_count", 1)) -- phone_count = int(request.form.get("phone_count", 1)) -- device_limit = int(request.form.get("device_limit", 3)) -- -- # Sicherheitslimit -- if quantity < 1 or quantity > 100: -- flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') -- return redirect(url_for('batch_licenses')) -- -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Prüfe ob neuer Kunde oder bestehender -- if customer_id == "new": -- # Neuer Kunde -- name = request.form.get("customer_name") -- email = request.form.get("email") -- -- if not name: -- flash('Kundenname ist erforderlich!', 'error') -- return redirect(url_for('batch_licenses')) -- -- # Prüfe ob E-Mail bereits existiert -- if email: -- cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) -- existing = cur.fetchone() -- if existing: -- flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') -- return redirect(url_for('batch_licenses')) -- -- # Kunde einfügen (erbt Test-Status von Lizenz) -- cur.execute(""" -- INSERT INTO customers (name, email, is_test, created_at) -- VALUES (%s, %s, %s, NOW()) -- RETURNING id -- """, (name, email, is_test)) -- customer_id = cur.fetchone()[0] -- -- # Audit-Log für neuen Kunden -- log_audit('CREATE', 'customer', customer_id, -- new_values={'name': name, 'email': email, 'is_test': is_test}) -- else: -- # Bestehender Kunde - hole Infos -- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -- customer_data = cur.fetchone() -- if not customer_data: -- flash('Kunde nicht gefunden!', 'error') -- return redirect(url_for('batch_licenses')) -- name = customer_data[0] -- email = customer_data[1] -- -- # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren -- if customer_data[2]: # is_test des Kunden -- is_test = True -- -- # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch -- total_domains_needed = domain_count * quantity -- total_ipv4s_needed = ipv4_count * quantity -- total_phones_needed = phone_count * quantity -- -- cur.execute(""" -- SELECT -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, -- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones -- """, (is_test, is_test, is_test)) -- available = cur.fetchone() -- -- if available[0] < total_domains_needed: -- flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') -- return redirect(url_for('batch_licenses')) -- if available[1] < total_ipv4s_needed: -- flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') -- return redirect(url_for('batch_licenses')) -- if available[2] < total_phones_needed: -- flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') -- return redirect(url_for('batch_licenses')) -- -- # Lizenzen generieren und speichern -- generated_licenses = [] -- for i in range(quantity): -- # Eindeutigen Key generieren -- attempts = 0 -- while attempts < 10: -- license_key = generate_license_key(license_type) -- cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) -- if not cur.fetchone(): -- break -- attempts += 1 -- -- # Lizenz einfügen -- cur.execute(""" -- INSERT INTO licenses (license_key, customer_id, license_type, is_test, -- valid_from, valid_until, is_active, -- domain_count, ipv4_count, phone_count, device_limit) -- VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) -- RETURNING id -- """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, -- domain_count, ipv4_count, phone_count, device_limit)) -- license_id = cur.fetchone()[0] -- -- # Ressourcen für diese Lizenz zuweisen -- # Domains -- if domain_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, domain_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- # IPv4s -- if ipv4_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, ipv4_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- # Telefonnummern -- if phone_count > 0: -- cur.execute(""" -- SELECT id FROM resource_pools -- WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s -- LIMIT %s FOR UPDATE -- """, (is_test, phone_count)) -- for (resource_id,) in cur.fetchall(): -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], resource_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, resource_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (resource_id, license_id, session['username'], get_client_ip())) -- -- generated_licenses.append({ -- 'id': license_id, -- 'key': license_key, -- 'type': license_type -- }) -- -- conn.commit() -- -- # Audit-Log -- log_audit('CREATE_BATCH', 'license', -- new_values={'customer': name, 'quantity': quantity, 'type': license_type}, -- additional_info=f"Batch-Generierung von {quantity} Lizenzen") -- -- # Session für Export speichern -- session['batch_export'] = { -- 'customer': name, -- 'email': email, -- 'licenses': generated_licenses, -- 'valid_from': valid_from, -- 'valid_until': valid_until, -- 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -- } -- -- flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') -- return render_template("batch_result.html", -- customer=name, -- email=email, -- licenses=generated_licenses, -- valid_from=valid_from, -- valid_until=valid_until) -- -- except Exception as e: -- conn.rollback() -- logging.error(f"Fehler bei Batch-Generierung: {str(e)}") -- flash('Fehler bei der Batch-Generierung!', 'error') -- return redirect(url_for('batch_licenses')) -- finally: -- cur.close() -- conn.close() -- -- # GET Request -- return render_template("batch_form.html") -- --@app.route("/batch/export") --@login_required --def export_batch(): -- """Exportiert die zuletzt generierten Batch-Lizenzen""" -- batch_data = session.get('batch_export') -- if not batch_data: -- flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') -- return redirect(url_for('batch_licenses')) -- -- # CSV generieren -- output = io.StringIO() -- output.write('\ufeff') # UTF-8 BOM für Excel -- -- # Header -- output.write(f"Kunde: {batch_data['customer']}\n") -- output.write(f"E-Mail: {batch_data['email']}\n") -- output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") -- output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") -- output.write("\n") -- output.write("Nr;Lizenzschlüssel;Typ\n") -- -- # Lizenzen -- for i, license in enumerate(batch_data['licenses'], 1): -- typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" -- output.write(f"{i};{license['key']};{typ_text}\n") -- -- output.seek(0) -- -- # Audit-Log -- log_audit('EXPORT', 'batch_licenses', -- additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8-sig')), -- mimetype='text/csv', -- as_attachment=True, -- download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" -- ) -- --@app.route("/licenses") --@login_required --def licenses(): -- # Redirect zur kombinierten Ansicht -- return redirect("/customers-licenses") -- --@app.route("/license/edit/", methods=["GET", "POST"]) --@login_required --def edit_license(license_id): -- conn = get_connection() -- cur = conn.cursor() -- -- if request.method == "POST": -- # Alte Werte für Audit-Log abrufen -- cur.execute(""" -- SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit -- FROM licenses WHERE id = %s -- """, (license_id,)) -- old_license = cur.fetchone() -- -- # Update license -- license_key = request.form["license_key"] -- license_type = request.form["license_type"] -- valid_from = request.form["valid_from"] -- valid_until = request.form["valid_until"] -- is_active = request.form.get("is_active") == "on" -- is_test = request.form.get("is_test") == "on" -- device_limit = int(request.form.get("device_limit", 3)) -- -- cur.execute(""" -- UPDATE licenses -- SET license_key = %s, license_type = %s, valid_from = %s, -- valid_until = %s, is_active = %s, is_test = %s, device_limit = %s -- WHERE id = %s -- """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) -- -- conn.commit() -- -- # Audit-Log -- log_audit('UPDATE', 'license', license_id, -- old_values={ -- 'license_key': old_license[0], -- 'license_type': old_license[1], -- 'valid_from': str(old_license[2]), -- 'valid_until': str(old_license[3]), -- 'is_active': old_license[4], -- 'is_test': old_license[5], -- 'device_limit': old_license[6] -- }, -- new_values={ -- 'license_key': license_key, -- 'license_type': license_type, -- 'valid_from': valid_from, -- 'valid_until': valid_until, -- 'is_active': is_active, -- 'is_test': is_test, -- 'device_limit': device_limit -- }) -- -- cur.close() -- conn.close() -- -- # Redirect zurück zu customers-licenses mit beibehaltenen Parametern -- redirect_url = "/customers-licenses" -- -- # Behalte show_test Parameter bei (aus Form oder GET-Parameter) -- show_test = request.form.get('show_test') or request.args.get('show_test') -- if show_test == 'true': -- redirect_url += "?show_test=true" -- -- # Behalte customer_id bei wenn vorhanden -- if request.referrer and 'customer_id=' in request.referrer: -- import re -- match = re.search(r'customer_id=(\d+)', request.referrer) -- if match: -- connector = "&" if "?" in redirect_url else "?" -- redirect_url += f"{connector}customer_id={match.group(1)}" -- -- return redirect(redirect_url) -- -- # Get license data -- cur.execute(""" -- SELECT l.id, l.license_key, c.name, c.email, l.license_type, -- l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- WHERE l.id = %s -- """, (license_id,)) -- -- license = cur.fetchone() -- cur.close() -- conn.close() -- -- if not license: -- return redirect("/licenses") -- -- return render_template("edit_license.html", license=license, username=session.get('username')) -- --@app.route("/license/delete/", methods=["POST"]) --@login_required --def delete_license(license_id): -- conn = get_connection() -- cur = conn.cursor() -- -- # Lizenzdetails für Audit-Log abrufen -- cur.execute(""" -- SELECT l.license_key, c.name, l.license_type -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- WHERE l.id = %s -- """, (license_id,)) -- license_info = cur.fetchone() -- -- cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) -- -- conn.commit() -- -- # Audit-Log -- if license_info: -- log_audit('DELETE', 'license', license_id, -- old_values={ -- 'license_key': license_info[0], -- 'customer_name': license_info[1], -- 'license_type': license_info[2] -- }) -- -- cur.close() -- conn.close() -- -- return redirect("/licenses") -- --@app.route("/customers") --@login_required --def customers(): -- # Redirect zur kombinierten Ansicht -- return redirect("/customers-licenses") -- --@app.route("/customer/edit/", methods=["GET", "POST"]) --@login_required --def edit_customer(customer_id): -- conn = get_connection() -- cur = conn.cursor() -- -- if request.method == "POST": -- # Alte Werte für Audit-Log abrufen -- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -- old_customer = cur.fetchone() -- -- # Update customer -- name = request.form["name"] -- email = request.form["email"] -- is_test = request.form.get("is_test") == "on" -- -- cur.execute(""" -- UPDATE customers -- SET name = %s, email = %s, is_test = %s -- WHERE id = %s -- """, (name, email, is_test, customer_id)) -- -- conn.commit() -- -- # Audit-Log -- log_audit('UPDATE', 'customer', customer_id, -- old_values={ -- 'name': old_customer[0], -- 'email': old_customer[1], -- 'is_test': old_customer[2] -- }, -- new_values={ -- 'name': name, -- 'email': email, -- 'is_test': is_test -- }) -- -- cur.close() -- conn.close() -- -- # Redirect zurück zu customers-licenses mit beibehaltenen Parametern -- redirect_url = "/customers-licenses" -- -- # Behalte show_test Parameter bei (aus Form oder GET-Parameter) -- show_test = request.form.get('show_test') or request.args.get('show_test') -- if show_test == 'true': -- redirect_url += "?show_test=true" -- -- # Behalte customer_id bei (immer der aktuelle Kunde) -- connector = "&" if "?" in redirect_url else "?" -- redirect_url += f"{connector}customer_id={customer_id}" -- -- return redirect(redirect_url) -- -- # Get customer data with licenses -- cur.execute(""" -- SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s -- """, (customer_id,)) -- -- customer = cur.fetchone() -- if not customer: -- cur.close() -- conn.close() -- return "Kunde nicht gefunden", 404 -- -- -- # Get customer's licenses -- cur.execute(""" -- SELECT id, license_key, license_type, valid_from, valid_until, is_active -- FROM licenses -- WHERE customer_id = %s -- ORDER BY valid_until DESC -- """, (customer_id,)) -- -- licenses = cur.fetchall() -- -- cur.close() -- conn.close() -- -- if not customer: -- return redirect("/customers-licenses") -- -- return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) -- --@app.route("/customer/create", methods=["GET", "POST"]) --@login_required --def create_customer(): -- """Erstellt einen neuen Kunden ohne Lizenz""" -- if request.method == "POST": -- name = request.form.get('name') -- email = request.form.get('email') -- is_test = request.form.get('is_test') == 'on' -- -- if not name or not email: -- flash("Name und E-Mail sind Pflichtfelder!", "error") -- return render_template("create_customer.html", username=session.get('username')) -- -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Prüfen ob E-Mail bereits existiert -- cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) -- existing = cur.fetchone() -- if existing: -- flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") -- return render_template("create_customer.html", username=session.get('username')) -- -- # Kunde erstellen -- cur.execute(""" -- INSERT INTO customers (name, email, created_at, is_test) -- VALUES (%s, %s, %s, %s) RETURNING id -- """, (name, email, datetime.now(), is_test)) -- -- customer_id = cur.fetchone()[0] -- conn.commit() -- -- # Audit-Log -- log_audit('CREATE', 'customer', customer_id, -- new_values={ -- 'name': name, -- 'email': email, -- 'is_test': is_test -- }) -- -- flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") -- return redirect(f"/customer/edit/{customer_id}") -- -- except Exception as e: -- conn.rollback() -- flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") -- return render_template("create_customer.html", username=session.get('username')) -- finally: -- cur.close() -- conn.close() -- -- # GET Request - Formular anzeigen -- return render_template("create_customer.html", username=session.get('username')) -- --@app.route("/customer/delete/", methods=["POST"]) --@login_required --def delete_customer(customer_id): -- conn = get_connection() -- cur = conn.cursor() -- -- # Prüfen ob Kunde Lizenzen hat -- cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) -- license_count = cur.fetchone()[0] -- -- if license_count > 0: -- # Kunde hat Lizenzen - nicht löschen -- cur.close() -- conn.close() -- return redirect("/customers") -- -- # Kundendetails für Audit-Log abrufen -- cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) -- customer_info = cur.fetchone() -- -- # Kunde löschen wenn keine Lizenzen vorhanden -- cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) -- -- conn.commit() -- -- # Audit-Log -- if customer_info: -- log_audit('DELETE', 'customer', customer_id, -- old_values={ -- 'name': customer_info[0], -- 'email': customer_info[1] -- }) -- -- cur.close() -- conn.close() -- -- return redirect("/customers") -- --@app.route("/customers-licenses") --@login_required --def customers_licenses(): -- """Kombinierte Ansicht für Kunden und deren Lizenzen""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) -- show_test = request.args.get('show_test', 'false').lower() == 'true' -- -- query = """ -- SELECT -- c.id, -- c.name, -- c.email, -- c.created_at, -- COUNT(l.id) as total_licenses, -- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- """ -- -- if not show_test: -- query += " WHERE c.is_test = FALSE" -- -- query += """ -- GROUP BY c.id, c.name, c.email, c.created_at -- ORDER BY c.name -- """ -- -- cur.execute(query) -- customers = cur.fetchall() -- -- # Hole ausgewählten Kunden nur wenn explizit in URL angegeben -- selected_customer_id = request.args.get('customer_id', type=int) -- licenses = [] -- selected_customer = None -- -- if customers and selected_customer_id: -- # Hole Daten des ausgewählten Kunden -- for customer in customers: -- if customer[0] == selected_customer_id: -- selected_customer = customer -- break -- -- # Hole Lizenzen des ausgewählten Kunden -- if selected_customer: -- cur.execute(""" -- SELECT -- l.id, -- l.license_key, -- l.license_type, -- l.valid_from, -- l.valid_until, -- l.is_active, -- CASE -- WHEN l.is_active = FALSE THEN 'deaktiviert' -- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -- ELSE 'aktiv' -- END as status, -- l.domain_count, -- l.ipv4_count, -- l.phone_count, -- l.device_limit, -- (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, -- -- Actual resource counts -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count -- FROM licenses l -- WHERE l.customer_id = %s -- ORDER BY l.created_at DESC, l.id DESC -- """, (selected_customer_id,)) -- licenses = cur.fetchall() -- -- cur.close() -- conn.close() -- -- return render_template("customers_licenses.html", -- customers=customers, -- selected_customer=selected_customer, -- selected_customer_id=selected_customer_id, -- licenses=licenses, -- show_test=show_test) -- --@app.route("/api/customer//licenses") --@login_required --def api_customer_licenses(customer_id): -- """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Hole Lizenzen des Kunden -- cur.execute(""" -- SELECT -- l.id, -- l.license_key, -- l.license_type, -- l.valid_from, -- l.valid_until, -- l.is_active, -- CASE -- WHEN l.is_active = FALSE THEN 'deaktiviert' -- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -- ELSE 'aktiv' -- END as status, -- l.domain_count, -- l.ipv4_count, -- l.phone_count, -- l.device_limit, -- (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, -- -- Actual resource counts -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, -- (SELECT COUNT(*) FROM license_resources lr -- JOIN resource_pools rp ON lr.resource_id = rp.id -- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count -- FROM licenses l -- WHERE l.customer_id = %s -- ORDER BY l.created_at DESC, l.id DESC -- """, (customer_id,)) -- -- licenses = [] -- for row in cur.fetchall(): -- license_id = row[0] -- -- # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz -- cur.execute(""" -- SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at -- FROM resource_pools rp -- JOIN license_resources lr ON rp.id = lr.resource_id -- WHERE lr.license_id = %s AND lr.is_active = true -- ORDER BY rp.resource_type, rp.resource_value -- """, (license_id,)) -- -- resources = { -- 'domains': [], -- 'ipv4s': [], -- 'phones': [] -- } -- -- for res_row in cur.fetchall(): -- resource_info = { -- 'id': res_row[0], -- 'value': res_row[2], -- 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' -- } -- -- if res_row[1] == 'domain': -- resources['domains'].append(resource_info) -- elif res_row[1] == 'ipv4': -- resources['ipv4s'].append(resource_info) -- elif res_row[1] == 'phone': -- resources['phones'].append(resource_info) -- -- licenses.append({ -- 'id': row[0], -- 'license_key': row[1], -- 'license_type': row[2], -- 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', -- 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', -- 'is_active': row[5], -- 'status': row[6], -- 'domain_count': row[7], # limit -- 'ipv4_count': row[8], # limit -- 'phone_count': row[9], # limit -- 'device_limit': row[10], -- 'active_devices': row[11], -- 'actual_domain_count': row[12], # actual count -- 'actual_ipv4_count': row[13], # actual count -- 'actual_phone_count': row[14], # actual count -- 'resources': resources -- }) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'success': True, -- 'licenses': licenses, -- 'count': len(licenses) -- }) -- --@app.route("/api/customer//quick-stats") --@login_required --def api_customer_quick_stats(customer_id): -- """API-Endpoint für Schnellstatistiken eines Kunden""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Hole Kundenstatistiken -- cur.execute(""" -- SELECT -- COUNT(l.id) as total_licenses, -- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, -- COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon -- FROM licenses l -- WHERE l.customer_id = %s -- """, (customer_id,)) -- -- stats = cur.fetchone() -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'success': True, -- 'stats': { -- 'total': stats[0], -- 'active': stats[1], -- 'expired': stats[2], -- 'expiring_soon': stats[3] -- } -- }) -- --@app.route("/api/license//quick-edit", methods=['POST']) --@login_required --def api_license_quick_edit(license_id): -- """API-Endpoint für schnelle Lizenz-Bearbeitung""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- data = request.get_json() -- -- # Hole alte Werte für Audit-Log -- cur.execute(""" -- SELECT is_active, valid_until, license_type -- FROM licenses WHERE id = %s -- """, (license_id,)) -- old_values = cur.fetchone() -- -- if not old_values: -- return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 -- -- # Update-Felder vorbereiten -- updates = [] -- params = [] -- new_values = {} -- -- if 'is_active' in data: -- updates.append("is_active = %s") -- params.append(data['is_active']) -- new_values['is_active'] = data['is_active'] -- -- if 'valid_until' in data: -- updates.append("valid_until = %s") -- params.append(data['valid_until']) -- new_values['valid_until'] = data['valid_until'] -- -- if 'license_type' in data: -- updates.append("license_type = %s") -- params.append(data['license_type']) -- new_values['license_type'] = data['license_type'] -- -- if updates: -- params.append(license_id) -- cur.execute(f""" -- UPDATE licenses -- SET {', '.join(updates)} -- WHERE id = %s -- """, params) -- -- conn.commit() -- -- # Audit-Log -- log_audit('UPDATE', 'license', license_id, -- old_values={ -- 'is_active': old_values[0], -- 'valid_until': old_values[1].isoformat() if old_values[1] else None, -- 'license_type': old_values[2] -- }, -- new_values=new_values) -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True}) -- -- except Exception as e: -- conn.rollback() -- cur.close() -- conn.close() -- return jsonify({'success': False, 'error': str(e)}), 500 -- --@app.route("/api/license//resources") --@login_required --def api_license_resources(license_id): -- """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz -- cur.execute(""" -- SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at -- FROM resource_pools rp -- JOIN license_resources lr ON rp.id = lr.resource_id -- WHERE lr.license_id = %s AND lr.is_active = true -- ORDER BY rp.resource_type, rp.resource_value -- """, (license_id,)) -- -- resources = { -- 'domains': [], -- 'ipv4s': [], -- 'phones': [] -- } -- -- for row in cur.fetchall(): -- resource_info = { -- 'id': row[0], -- 'value': row[2], -- 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' -- } -- -- if row[1] == 'domain': -- resources['domains'].append(resource_info) -- elif row[1] == 'ipv4': -- resources['ipv4s'].append(resource_info) -- elif row[1] == 'phone': -- resources['phones'].append(resource_info) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'success': True, -- 'resources': resources -- }) -- -- except Exception as e: -- cur.close() -- conn.close() -- return jsonify({'success': False, 'error': str(e)}), 500 -- --@app.route("/sessions") --@login_required --def sessions(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Sortierparameter -- active_sort = request.args.get('active_sort', 'last_heartbeat') -- active_order = request.args.get('active_order', 'desc') -- ended_sort = request.args.get('ended_sort', 'ended_at') -- ended_order = request.args.get('ended_order', 'desc') -- -- # Whitelist für erlaubte Sortierfelder - Aktive Sessions -- active_sort_fields = { -- 'customer': 'c.name', -- 'license': 'l.license_key', -- 'ip': 's.ip_address', -- 'started': 's.started_at', -- 'last_heartbeat': 's.last_heartbeat', -- 'inactive': 'minutes_inactive' -- } -- -- # Whitelist für erlaubte Sortierfelder - Beendete Sessions -- ended_sort_fields = { -- 'customer': 'c.name', -- 'license': 'l.license_key', -- 'ip': 's.ip_address', -- 'started': 's.started_at', -- 'ended_at': 's.ended_at', -- 'duration': 'duration_minutes' -- } -- -- # Validierung -- if active_sort not in active_sort_fields: -- active_sort = 'last_heartbeat' -- if ended_sort not in ended_sort_fields: -- ended_sort = 'ended_at' -- if active_order not in ['asc', 'desc']: -- active_order = 'desc' -- if ended_order not in ['asc', 'desc']: -- ended_order = 'desc' -- -- # Aktive Sessions abrufen -- cur.execute(f""" -- SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, -- s.user_agent, s.started_at, s.last_heartbeat, -- EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive -- FROM sessions s -- JOIN licenses l ON s.license_id = l.id -- JOIN customers c ON l.customer_id = c.id -- WHERE s.is_active = TRUE -- ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} -- """) -- active_sessions = cur.fetchall() -- -- # Inaktive Sessions der letzten 24 Stunden -- cur.execute(f""" -- SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, -- s.started_at, s.ended_at, -- EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes -- FROM sessions s -- JOIN licenses l ON s.license_id = l.id -- JOIN customers c ON l.customer_id = c.id -- WHERE s.is_active = FALSE -- AND s.ended_at > NOW() - INTERVAL '24 hours' -- ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} -- LIMIT 50 -- """) -- recent_sessions = cur.fetchall() -- -- cur.close() -- conn.close() -- -- return render_template("sessions.html", -- active_sessions=active_sessions, -- recent_sessions=recent_sessions, -- active_sort=active_sort, -- active_order=active_order, -- ended_sort=ended_sort, -- ended_order=ended_order, -- username=session.get('username')) -- --@app.route("/session/end/", methods=["POST"]) --@login_required --def end_session(session_id): -- conn = get_connection() -- cur = conn.cursor() -- -- # Session beenden -- cur.execute(""" -- UPDATE sessions -- SET is_active = FALSE, ended_at = NOW() -- WHERE id = %s AND is_active = TRUE -- """, (session_id,)) -- -- conn.commit() -- cur.close() -- conn.close() -- -- return redirect("/sessions") -- --@app.route("/export/licenses") --@login_required --def export_licenses(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) -- include_test = request.args.get('include_test', 'false').lower() == 'true' -- customer_id = request.args.get('customer_id', type=int) -- -- query = """ -- SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, -- l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, -- CASE -- WHEN l.is_active = FALSE THEN 'Deaktiviert' -- WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' -- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' -- ELSE 'Aktiv' -- END as status -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- """ -- -- # Build WHERE clause -- where_conditions = [] -- params = [] -- -- if not include_test: -- where_conditions.append("l.is_test = FALSE") -- -- if customer_id: -- where_conditions.append("l.customer_id = %s") -- params.append(customer_id) -- -- if where_conditions: -- query += " WHERE " + " AND ".join(where_conditions) -- -- query += " ORDER BY l.id" -- -- cur.execute(query, params) -- -- # Spaltennamen -- columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', -- 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] -- -- # Daten in DataFrame -- data = cur.fetchall() -- df = pd.DataFrame(data, columns=columns) -- -- # Datumsformatierung -- df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') -- df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') -- -- # Typ und Aktiv Status anpassen -- df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) -- df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) -- df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) -- -- cur.close() -- conn.close() -- -- # Export Format -- export_format = request.args.get('format', 'excel') -- -- # Audit-Log -- log_audit('EXPORT', 'license', -- additional_info=f"Export aller Lizenzen als {export_format.upper()}") -- filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' -- -- if export_format == 'csv': -- # CSV Export -- output = io.StringIO() -- df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') -- output.seek(0) -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8-sig')), -- mimetype='text/csv', -- as_attachment=True, -- download_name=f'{filename}.csv' -- ) -- else: -- # Excel Export -- output = io.BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, sheet_name='Lizenzen', index=False) -- -- # Formatierung -- worksheet = writer.sheets['Lizenzen'] -- for column in worksheet.columns: -- max_length = 0 -- column_letter = column[0].column_letter -- for cell in column: -- try: -- if len(str(cell.value)) > max_length: -- max_length = len(str(cell.value)) -- except: -- pass -- adjusted_width = min(max_length + 2, 50) -- worksheet.column_dimensions[column_letter].width = adjusted_width -- -- output.seek(0) -- -- return send_file( -- output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx' -- ) -- --@app.route("/export/audit") --@login_required --def export_audit(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Holen der Filter-Parameter -- filter_user = request.args.get('user', '') -- filter_action = request.args.get('action', '') -- filter_entity = request.args.get('entity', '') -- export_format = request.args.get('format', 'excel') -- -- # SQL Query mit Filtern -- query = """ -- SELECT id, timestamp, username, action, entity_type, entity_id, -- old_values, new_values, ip_address, user_agent, additional_info -- FROM audit_log -- WHERE 1=1 -- """ -- params = [] -- -- if filter_user: -- query += " AND username ILIKE %s" -- params.append(f'%{filter_user}%') -- -- if filter_action: -- query += " AND action = %s" -- params.append(filter_action) -- -- if filter_entity: -- query += " AND entity_type = %s" -- params.append(filter_entity) -- -- query += " ORDER BY timestamp DESC" -- -- cur.execute(query, params) -- audit_logs = cur.fetchall() -- cur.close() -- conn.close() -- -- # Daten für Export vorbereiten -- data = [] -- for log in audit_logs: -- action_text = { -- 'CREATE': 'Erstellt', -- 'UPDATE': 'Bearbeitet', -- 'DELETE': 'Gelöscht', -- 'LOGIN': 'Anmeldung', -- 'LOGOUT': 'Abmeldung', -- 'AUTO_LOGOUT': 'Auto-Logout', -- 'EXPORT': 'Export', -- 'GENERATE_KEY': 'Key generiert', -- 'CREATE_BATCH': 'Batch erstellt', -- 'BACKUP': 'Backup erstellt', -- 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', -- 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', -- 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', -- 'LOGIN_BLOCKED': 'Login-Blockiert', -- 'RESTORE': 'Wiederhergestellt', -- 'PASSWORD_CHANGE': 'Passwort geändert', -- '2FA_ENABLED': '2FA aktiviert', -- '2FA_DISABLED': '2FA deaktiviert' -- }.get(log[3], log[3]) -- -- data.append({ -- 'ID': log[0], -- 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), -- 'Benutzer': log[2], -- 'Aktion': action_text, -- 'Entität': log[4], -- 'Entität-ID': log[5] or '', -- 'IP-Adresse': log[8] or '', -- 'Zusatzinfo': log[10] or '' -- }) -- -- # DataFrame erstellen -- df = pd.DataFrame(data) -- -- # Timestamp für Dateiname -- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -- filename = f'audit_log_export_{timestamp}' -- -- # Audit Log für Export -- log_audit('EXPORT', 'audit_log', -- additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") -- -- if export_format == 'csv': -- # CSV Export -- output = io.StringIO() -- # UTF-8 BOM für Excel -- output.write('\ufeff') -- df.to_csv(output, index=False, sep=';', encoding='utf-8') -- output.seek(0) -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8')), -- mimetype='text/csv;charset=utf-8', -- as_attachment=True, -- download_name=f'{filename}.csv' -- ) -- else: -- # Excel Export -- output = BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, index=False, sheet_name='Audit Log') -- -- # Spaltenbreiten anpassen -- worksheet = writer.sheets['Audit Log'] -- for idx, col in enumerate(df.columns): -- max_length = max( -- df[col].astype(str).map(len).max(), -- len(col) -- ) + 2 -- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -- -- output.seek(0) -- -- return send_file( -- output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx' -- ) -- --@app.route("/export/customers") --@login_required --def export_customers(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Check if test data should be included -- include_test = request.args.get('include_test', 'false').lower() == 'true' -- -- # Build query based on test data filter -- if include_test: -- # Include all customers -- query = """ -- SELECT c.id, c.name, c.email, c.created_at, c.is_test, -- COUNT(l.id) as total_licenses, -- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- GROUP BY c.id, c.name, c.email, c.created_at, c.is_test -- ORDER BY c.id -- """ -- else: -- # Exclude test customers and test licenses -- query = """ -- SELECT c.id, c.name, c.email, c.created_at, c.is_test, -- COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, -- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, -- COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses -- FROM customers c -- LEFT JOIN licenses l ON c.id = l.customer_id -- WHERE c.is_test = FALSE -- GROUP BY c.id, c.name, c.email, c.created_at, c.is_test -- ORDER BY c.id -- """ -- -- cur.execute(query) -- -- # Spaltennamen -- columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', -- 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] -- -- # Daten in DataFrame -- data = cur.fetchall() -- df = pd.DataFrame(data, columns=columns) -- -- # Datumsformatierung -- df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') -- -- # Testdaten formatting -- df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) -- -- cur.close() -- conn.close() -- -- # Export Format -- export_format = request.args.get('format', 'excel') -- -- # Audit-Log -- log_audit('EXPORT', 'customer', -- additional_info=f"Export aller Kunden als {export_format.upper()}") -- filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' -- -- if export_format == 'csv': -- # CSV Export -- output = io.StringIO() -- df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') -- output.seek(0) -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8-sig')), -- mimetype='text/csv', -- as_attachment=True, -- download_name=f'{filename}.csv' -- ) -- else: -- # Excel Export -- output = io.BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, sheet_name='Kunden', index=False) -- -- # Formatierung -- worksheet = writer.sheets['Kunden'] -- for column in worksheet.columns: -- max_length = 0 -- column_letter = column[0].column_letter -- for cell in column: -- try: -- if len(str(cell.value)) > max_length: -- max_length = len(str(cell.value)) -- except: -- pass -- adjusted_width = min(max_length + 2, 50) -- worksheet.column_dimensions[column_letter].width = adjusted_width -- -- output.seek(0) -- -- return send_file( -- output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx' -- ) -- --@app.route("/export/sessions") --@login_required --def export_sessions(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Holen des Session-Typs (active oder ended) -- session_type = request.args.get('type', 'active') -- export_format = request.args.get('format', 'excel') -- -- # Daten je nach Typ abrufen -- if session_type == 'active': -- # Aktive Lizenz-Sessions -- cur.execute(""" -- SELECT s.id, l.license_key, c.name as customer_name, s.session_id, -- s.started_at, s.last_heartbeat, -- EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, -- s.ip_address, s.user_agent -- FROM sessions s -- JOIN licenses l ON s.license_id = l.id -- JOIN customers c ON l.customer_id = c.id -- WHERE s.is_active = true -- ORDER BY s.last_heartbeat DESC -- """) -- sessions = cur.fetchall() -- -- # Daten für Export vorbereiten -- data = [] -- for sess in sessions: -- duration = sess[6] -- hours = duration // 3600 -- minutes = (duration % 3600) // 60 -- seconds = duration % 60 -- -- data.append({ -- 'Session-ID': sess[0], -- 'Lizenzschlüssel': sess[1], -- 'Kunde': sess[2], -- 'Session-ID (Tech)': sess[3], -- 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), -- 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), -- 'Dauer': f"{hours}h {minutes}m {seconds}s", -- 'IP-Adresse': sess[7], -- 'Browser': sess[8] -- }) -- -- sheet_name = 'Aktive Sessions' -- filename_prefix = 'aktive_sessions' -- else: -- # Beendete Lizenz-Sessions -- cur.execute(""" -- SELECT s.id, l.license_key, c.name as customer_name, s.session_id, -- s.started_at, s.ended_at, -- EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, -- s.ip_address, s.user_agent -- FROM sessions s -- JOIN licenses l ON s.license_id = l.id -- JOIN customers c ON l.customer_id = c.id -- WHERE s.is_active = false AND s.ended_at IS NOT NULL -- ORDER BY s.ended_at DESC -- LIMIT 1000 -- """) -- sessions = cur.fetchall() -- -- # Daten für Export vorbereiten -- data = [] -- for sess in sessions: -- duration = sess[6] if sess[6] else 0 -- hours = duration // 3600 -- minutes = (duration % 3600) // 60 -- seconds = duration % 60 -- -- data.append({ -- 'Session-ID': sess[0], -- 'Lizenzschlüssel': sess[1], -- 'Kunde': sess[2], -- 'Session-ID (Tech)': sess[3], -- 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), -- 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', -- 'Dauer': f"{hours}h {minutes}m {seconds}s", -- 'IP-Adresse': sess[7], -- 'Browser': sess[8] -- }) -- -- sheet_name = 'Beendete Sessions' -- filename_prefix = 'beendete_sessions' -- -- cur.close() -- conn.close() -- -- # DataFrame erstellen -- df = pd.DataFrame(data) -- -- # Timestamp für Dateiname -- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -- filename = f'{filename_prefix}_export_{timestamp}' -- -- # Audit Log für Export -- log_audit('EXPORT', 'sessions', -- additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") -- -- if export_format == 'csv': -- # CSV Export -- output = io.StringIO() -- # UTF-8 BOM für Excel -- output.write('\ufeff') -- df.to_csv(output, index=False, sep=';', encoding='utf-8') -- output.seek(0) -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8')), -- mimetype='text/csv;charset=utf-8', -- as_attachment=True, -- download_name=f'{filename}.csv' -- ) -- else: -- # Excel Export -- output = BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, index=False, sheet_name=sheet_name) -- -- # Spaltenbreiten anpassen -- worksheet = writer.sheets[sheet_name] -- for idx, col in enumerate(df.columns): -- max_length = max( -- df[col].astype(str).map(len).max(), -- len(col) -- ) + 2 -- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -- -- output.seek(0) -- -- return send_file( -- output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx' -- ) -- --@app.route("/export/resources") --@login_required --def export_resources(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Holen der Filter-Parameter -- filter_type = request.args.get('type', '') -- filter_status = request.args.get('status', '') -- search_query = request.args.get('search', '') -- show_test = request.args.get('show_test', 'false').lower() == 'true' -- export_format = request.args.get('format', 'excel') -- -- # SQL Query mit Filtern -- query = """ -- SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, -- r.created_at, r.status_changed_at, -- l.license_key, c.name as customer_name, c.email as customer_email, -- l.license_type -- FROM resource_pools r -- LEFT JOIN licenses l ON r.allocated_to_license = l.id -- LEFT JOIN customers c ON l.customer_id = c.id -- WHERE 1=1 -- """ -- params = [] -- -- # Filter für Testdaten -- if not show_test: -- query += " AND (r.is_test = false OR r.is_test IS NULL)" -- -- # Filter für Ressourcentyp -- if filter_type: -- query += " AND r.resource_type = %s" -- params.append(filter_type) -- -- # Filter für Status -- if filter_status: -- query += " AND r.status = %s" -- params.append(filter_status) -- -- # Suchfilter -- if search_query: -- query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" -- params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) -- -- query += " ORDER BY r.id DESC" -- -- cur.execute(query, params) -- resources = cur.fetchall() -- cur.close() -- conn.close() -- -- # Daten für Export vorbereiten -- data = [] -- for res in resources: -- status_text = { -- 'available': 'Verfügbar', -- 'allocated': 'Zugewiesen', -- 'quarantine': 'Quarantäne' -- }.get(res[3], res[3]) -- -- type_text = { -- 'domain': 'Domain', -- 'ipv4': 'IPv4', -- 'phone': 'Telefon' -- }.get(res[1], res[1]) -- -- data.append({ -- 'ID': res[0], -- 'Typ': type_text, -- 'Ressource': res[2], -- 'Status': status_text, -- 'Lizenzschlüssel': res[7] or '', -- 'Kunde': res[8] or '', -- 'Kunden-Email': res[9] or '', -- 'Lizenztyp': res[10] or '', -- 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', -- 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' -- }) -- -- # DataFrame erstellen -- df = pd.DataFrame(data) -- -- # Timestamp für Dateiname -- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -- filename = f'resources_export_{timestamp}' -- -- # Audit Log für Export -- log_audit('EXPORT', 'resources', -- additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") -- -- if export_format == 'csv': -- # CSV Export -- output = io.StringIO() -- # UTF-8 BOM für Excel -- output.write('\ufeff') -- df.to_csv(output, index=False, sep=';', encoding='utf-8') -- output.seek(0) -- -- return send_file( -- io.BytesIO(output.getvalue().encode('utf-8')), -- mimetype='text/csv;charset=utf-8', -- as_attachment=True, -- download_name=f'{filename}.csv' -- ) -- else: -- # Excel Export -- output = BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, index=False, sheet_name='Resources') -- -- # Spaltenbreiten anpassen -- worksheet = writer.sheets['Resources'] -- for idx, col in enumerate(df.columns): -- max_length = max( -- df[col].astype(str).map(len).max(), -- len(col) -- ) + 2 -- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -- -- output.seek(0) -- -- return send_file( -- output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx' -- ) -- --@app.route("/audit") --@login_required --def audit_log(): -- conn = get_connection() -- cur = conn.cursor() -- -- # Parameter -- filter_user = request.args.get('user', '').strip() -- filter_action = request.args.get('action', '').strip() -- filter_entity = request.args.get('entity', '').strip() -- page = request.args.get('page', 1, type=int) -- sort = request.args.get('sort', 'timestamp') -- order = request.args.get('order', 'desc') -- per_page = 50 -- -- # Whitelist für erlaubte Sortierfelder -- allowed_sort_fields = { -- 'timestamp': 'timestamp', -- 'username': 'username', -- 'action': 'action', -- 'entity': 'entity_type', -- 'ip': 'ip_address' -- } -- -- # Validierung -- if sort not in allowed_sort_fields: -- sort = 'timestamp' -- if order not in ['asc', 'desc']: -- order = 'desc' -- -- sort_field = allowed_sort_fields[sort] -- -- # SQL Query mit optionalen Filtern -- query = """ -- SELECT id, timestamp, username, action, entity_type, entity_id, -- old_values, new_values, ip_address, user_agent, additional_info -- FROM audit_log -- WHERE 1=1 -- """ -- -- params = [] -- -- # Filter -- if filter_user: -- query += " AND LOWER(username) LIKE LOWER(%s)" -- params.append(f'%{filter_user}%') -- -- if filter_action: -- query += " AND action = %s" -- params.append(filter_action) -- -- if filter_entity: -- query += " AND entity_type = %s" -- params.append(filter_entity) -- -- # Gesamtanzahl für Pagination -- count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" -- cur.execute(count_query, params) -- total = cur.fetchone()[0] -- -- # Pagination -- offset = (page - 1) * per_page -- query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" -- params.extend([per_page, offset]) -- -- cur.execute(query, params) -- logs = cur.fetchall() -- -- # JSON-Werte parsen -- parsed_logs = [] -- for log in logs: -- parsed_log = list(log) -- # old_values und new_values sind bereits Dictionaries (JSONB) -- # Keine Konvertierung nötig -- parsed_logs.append(parsed_log) -- -- # Pagination Info -- total_pages = (total + per_page - 1) // per_page -- -- cur.close() -- conn.close() -- -- return render_template("audit_log.html", -- logs=parsed_logs, -- filter_user=filter_user, -- filter_action=filter_action, -- filter_entity=filter_entity, -- page=page, -- total_pages=total_pages, -- total=total, -- sort=sort, -- order=order, -- username=session.get('username')) -- --@app.route("/backups") --@login_required --def backups(): -- """Zeigt die Backup-Historie an""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Letztes erfolgreiches Backup für Dashboard -- cur.execute(""" -- SELECT created_at, filesize, duration_seconds -- FROM backup_history -- WHERE status = 'success' -- ORDER BY created_at DESC -- LIMIT 1 -- """) -- last_backup = cur.fetchone() -- -- # Alle Backups abrufen -- cur.execute(""" -- SELECT id, filename, filesize, backup_type, status, error_message, -- created_at, created_by, tables_count, records_count, -- duration_seconds, is_encrypted -- FROM backup_history -- ORDER BY created_at DESC -- """) -- backups = cur.fetchall() -- -- cur.close() -- conn.close() -- -- return render_template("backups.html", -- backups=backups, -- last_backup=last_backup, -- username=session.get('username')) -- --@app.route("/backup/create", methods=["POST"]) --@login_required --def create_backup_route(): -- """Erstellt ein manuelles Backup""" -- username = session.get('username') -- success, result = create_backup(backup_type="manual", created_by=username) -- -- if success: -- return jsonify({ -- 'success': True, -- 'message': f'Backup erfolgreich erstellt: {result}' -- }) -- else: -- return jsonify({ -- 'success': False, -- 'message': f'Backup fehlgeschlagen: {result}' -- }), 500 -- --@app.route("/backup/restore/", methods=["POST"]) --@login_required --def restore_backup_route(backup_id): -- """Stellt ein Backup wieder her""" -- encryption_key = request.form.get('encryption_key') -- -- success, message = restore_backup(backup_id, encryption_key) -- -- if success: -- return jsonify({ -- 'success': True, -- 'message': message -- }) -- else: -- return jsonify({ -- 'success': False, -- 'message': message -- }), 500 -- --@app.route("/backup/download/") --@login_required --def download_backup(backup_id): -- """Lädt eine Backup-Datei herunter""" -- conn = get_connection() -- cur = conn.cursor() -- -- cur.execute(""" -- SELECT filename, filepath -- FROM backup_history -- WHERE id = %s -- """, (backup_id,)) -- backup_info = cur.fetchone() -- -- cur.close() -- conn.close() -- -- if not backup_info: -- return "Backup nicht gefunden", 404 -- -- filename, filepath = backup_info -- filepath = Path(filepath) -- -- if not filepath.exists(): -- return "Backup-Datei nicht gefunden", 404 -- -- # Audit-Log -- log_audit('DOWNLOAD', 'backup', backup_id, -- additional_info=f"Backup heruntergeladen: {filename}") -- -- return send_file(filepath, as_attachment=True, download_name=filename) -- --@app.route("/backup/delete/", methods=["DELETE"]) --@login_required --def delete_backup(backup_id): -- """Löscht ein Backup""" -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- # Backup-Informationen abrufen -- cur.execute(""" -- SELECT filename, filepath -- FROM backup_history -- WHERE id = %s -- """, (backup_id,)) -- backup_info = cur.fetchone() -- -- if not backup_info: -- return jsonify({ -- 'success': False, -- 'message': 'Backup nicht gefunden' -- }), 404 -- -- filename, filepath = backup_info -- filepath = Path(filepath) -- -- # Datei löschen, wenn sie existiert -- if filepath.exists(): -- filepath.unlink() -- -- # Aus Datenbank löschen -- cur.execute(""" -- DELETE FROM backup_history -- WHERE id = %s -- """, (backup_id,)) -- -- conn.commit() -- -- # Audit-Log -- log_audit('DELETE', 'backup', backup_id, -- additional_info=f"Backup gelöscht: {filename}") -- -- return jsonify({ -- 'success': True, -- 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' -- }) -- -- except Exception as e: -- conn.rollback() -- return jsonify({ -- 'success': False, -- 'message': f'Fehler beim Löschen des Backups: {str(e)}' -- }), 500 -- finally: -- cur.close() -- conn.close() -- --@app.route("/security/blocked-ips") --@login_required --def blocked_ips(): -- """Zeigt alle gesperrten IPs an""" -- conn = get_connection() -- cur = conn.cursor() -- -- cur.execute(""" -- SELECT -- ip_address, -- attempt_count, -- first_attempt, -- last_attempt, -- blocked_until, -- last_username_tried, -- last_error_message -- FROM login_attempts -- WHERE blocked_until IS NOT NULL -- ORDER BY blocked_until DESC -- """) -- -- blocked_ips_list = [] -- for ip in cur.fetchall(): -- blocked_ips_list.append({ -- 'ip_address': ip[0], -- 'attempt_count': ip[1], -- 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), -- 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), -- 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), -- 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), -- 'last_username': ip[5], -- 'last_error': ip[6] -- }) -- -- cur.close() -- conn.close() -- -- return render_template("blocked_ips.html", -- blocked_ips=blocked_ips_list, -- username=session.get('username')) -- --@app.route("/security/unblock-ip", methods=["POST"]) --@login_required --def unblock_ip(): -- """Entsperrt eine IP-Adresse""" -- ip_address = request.form.get('ip_address') -- -- if ip_address: -- conn = get_connection() -- cur = conn.cursor() -- -- cur.execute(""" -- UPDATE login_attempts -- SET blocked_until = NULL -- WHERE ip_address = %s -- """, (ip_address,)) -- -- conn.commit() -- cur.close() -- conn.close() -- -- # Audit-Log -- log_audit('UNBLOCK_IP', 'security', -- additional_info=f"IP {ip_address} manuell entsperrt") -- -- return redirect(url_for('blocked_ips')) -- --@app.route("/security/clear-attempts", methods=["POST"]) --@login_required --def clear_attempts(): -- """Löscht alle Login-Versuche für eine IP""" -- ip_address = request.form.get('ip_address') -- -- if ip_address: -- reset_login_attempts(ip_address) -- -- # Audit-Log -- log_audit('CLEAR_ATTEMPTS', 'security', -- additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") -- -- return redirect(url_for('blocked_ips')) -- --# API Endpoints for License Management --@app.route("/api/license//toggle", methods=["POST"]) --@login_required --def toggle_license_api(license_id): -- """Toggle license active status via API""" -- try: -- data = request.get_json() -- is_active = data.get('is_active', False) -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Update license status -- cur.execute(""" -- UPDATE licenses -- SET is_active = %s -- WHERE id = %s -- """, (is_active, license_id)) -- -- conn.commit() -- -- # Log the action -- log_audit('UPDATE', 'license', license_id, -- new_values={'is_active': is_active}, -- additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) -- except Exception as e: -- return jsonify({'success': False, 'message': str(e)}), 500 -- --@app.route("/api/licenses/bulk-activate", methods=["POST"]) --@login_required --def bulk_activate_licenses(): -- """Activate multiple licenses at once""" -- try: -- data = request.get_json() -- license_ids = data.get('ids', []) -- -- if not license_ids: -- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Update all selected licenses (nur Live-Daten) -- cur.execute(""" -- UPDATE licenses -- SET is_active = TRUE -- WHERE id = ANY(%s) AND is_test = FALSE -- """, (license_ids,)) -- -- affected_rows = cur.rowcount -- conn.commit() -- -- # Log the bulk action -- log_audit('BULK_UPDATE', 'licenses', None, -- new_values={'is_active': True, 'count': affected_rows}, -- additional_info=f"{affected_rows} Lizenzen aktiviert") -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) -- except Exception as e: -- return jsonify({'success': False, 'message': str(e)}), 500 -- --@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) --@login_required --def bulk_deactivate_licenses(): -- """Deactivate multiple licenses at once""" -- try: -- data = request.get_json() -- license_ids = data.get('ids', []) -- -- if not license_ids: -- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Update all selected licenses (nur Live-Daten) -- cur.execute(""" -- UPDATE licenses -- SET is_active = FALSE -- WHERE id = ANY(%s) AND is_test = FALSE -- """, (license_ids,)) -- -- affected_rows = cur.rowcount -- conn.commit() -- -- # Log the bulk action -- log_audit('BULK_UPDATE', 'licenses', None, -- new_values={'is_active': False, 'count': affected_rows}, -- additional_info=f"{affected_rows} Lizenzen deaktiviert") -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) -- except Exception as e: -- return jsonify({'success': False, 'message': str(e)}), 500 -- --@app.route("/api/license//devices") --@login_required --def get_license_devices(license_id): -- """Hole alle registrierten Geräte einer Lizenz""" -- try: -- conn = get_connection() -- cur = conn.cursor() -- -- # Prüfe ob Lizenz existiert und hole device_limit -- cur.execute(""" -- SELECT device_limit FROM licenses WHERE id = %s -- """, (license_id,)) -- license_data = cur.fetchone() -- -- if not license_data: -- return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 -- -- device_limit = license_data[0] -- -- # Hole alle Geräte für diese Lizenz -- cur.execute(""" -- SELECT id, hardware_id, device_name, operating_system, -- first_seen, last_seen, is_active, ip_address -- FROM device_registrations -- WHERE license_id = %s -- ORDER BY is_active DESC, last_seen DESC -- """, (license_id,)) -- -- devices = [] -- for row in cur.fetchall(): -- devices.append({ -- 'id': row[0], -- 'hardware_id': row[1], -- 'device_name': row[2] or 'Unbekanntes Gerät', -- 'operating_system': row[3] or 'Unbekannt', -- 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', -- 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', -- 'is_active': row[6], -- 'ip_address': row[7] or '-' -- }) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'success': True, -- 'devices': devices, -- 'device_limit': device_limit, -- 'active_count': sum(1 for d in devices if d['is_active']) -- }) -- -- except Exception as e: -- logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") -- return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 -- --@app.route("/api/license//register-device", methods=["POST"]) --def register_device(license_id): -- """Registriere ein neues Gerät für eine Lizenz""" -- try: -- data = request.get_json() -- hardware_id = data.get('hardware_id') -- device_name = data.get('device_name', '') -- operating_system = data.get('operating_system', '') -- -- if not hardware_id: -- return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Prüfe ob Lizenz existiert und aktiv ist -- cur.execute(""" -- SELECT device_limit, is_active, valid_until -- FROM licenses -- WHERE id = %s -- """, (license_id,)) -- license_data = cur.fetchone() -- -- if not license_data: -- return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 -- -- device_limit, is_active, valid_until = license_data -- -- # Prüfe ob Lizenz aktiv und gültig ist -- if not is_active: -- return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 -- -- if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): -- return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 -- -- # Prüfe ob Gerät bereits registriert ist -- cur.execute(""" -- SELECT id, is_active FROM device_registrations -- WHERE license_id = %s AND hardware_id = %s -- """, (license_id, hardware_id)) -- existing_device = cur.fetchone() -- -- if existing_device: -- device_id, is_device_active = existing_device -- if is_device_active: -- # Gerät ist bereits aktiv, update last_seen -- cur.execute(""" -- UPDATE device_registrations -- SET last_seen = CURRENT_TIMESTAMP, -- ip_address = %s, -- user_agent = %s -- WHERE id = %s -- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -- conn.commit() -- return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) -- else: -- # Gerät war deaktiviert, prüfe ob wir es reaktivieren können -- cur.execute(""" -- SELECT COUNT(*) FROM device_registrations -- WHERE license_id = %s AND is_active = TRUE -- """, (license_id,)) -- active_count = cur.fetchone()[0] -- -- if active_count >= device_limit: -- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 -- -- # Reaktiviere das Gerät -- cur.execute(""" -- UPDATE device_registrations -- SET is_active = TRUE, -- last_seen = CURRENT_TIMESTAMP, -- deactivated_at = NULL, -- deactivated_by = NULL, -- ip_address = %s, -- user_agent = %s -- WHERE id = %s -- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -- conn.commit() -- return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) -- -- # Neues Gerät - prüfe Gerätelimit -- cur.execute(""" -- SELECT COUNT(*) FROM device_registrations -- WHERE license_id = %s AND is_active = TRUE -- """, (license_id,)) -- active_count = cur.fetchone()[0] -- -- if active_count >= device_limit: -- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 -- -- # Registriere neues Gerät -- cur.execute(""" -- INSERT INTO device_registrations -- (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) -- VALUES (%s, %s, %s, %s, %s, %s) -- RETURNING id -- """, (license_id, hardware_id, device_name, operating_system, -- get_client_ip(), request.headers.get('User-Agent', ''))) -- device_id = cur.fetchone()[0] -- -- conn.commit() -- -- # Audit Log -- log_audit('DEVICE_REGISTER', 'device', device_id, -- new_values={'license_id': license_id, 'hardware_id': hardware_id}) -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) -- -- except Exception as e: -- logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") -- return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 -- --@app.route("/api/license//deactivate-device/", methods=["POST"]) --@login_required --def deactivate_device(license_id, device_id): -- """Deaktiviere ein registriertes Gerät""" -- try: -- conn = get_connection() -- cur = conn.cursor() -- -- # Prüfe ob das Gerät zu dieser Lizenz gehört -- cur.execute(""" -- SELECT id FROM device_registrations -- WHERE id = %s AND license_id = %s AND is_active = TRUE -- """, (device_id, license_id)) -- -- if not cur.fetchone(): -- return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 -- -- # Deaktiviere das Gerät -- cur.execute(""" -- UPDATE device_registrations -- SET is_active = FALSE, -- deactivated_at = CURRENT_TIMESTAMP, -- deactivated_by = %s -- WHERE id = %s -- """, (session['username'], device_id)) -- -- conn.commit() -- -- # Audit Log -- log_audit('DEVICE_DEACTIVATE', 'device', device_id, -- old_values={'is_active': True}, -- new_values={'is_active': False}) -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) -- -- except Exception as e: -- logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") -- return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 -- --@app.route("/api/licenses/bulk-delete", methods=["POST"]) --@login_required --def bulk_delete_licenses(): -- """Delete multiple licenses at once""" -- try: -- data = request.get_json() -- license_ids = data.get('ids', []) -- -- if not license_ids: -- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Get license info for audit log (nur Live-Daten) -- cur.execute(""" -- SELECT license_key -- FROM licenses -- WHERE id = ANY(%s) AND is_test = FALSE -- """, (license_ids,)) -- license_keys = [row[0] for row in cur.fetchall()] -- -- # Delete all selected licenses (nur Live-Daten) -- cur.execute(""" -- DELETE FROM licenses -- WHERE id = ANY(%s) AND is_test = FALSE -- """, (license_ids,)) -- -- affected_rows = cur.rowcount -- conn.commit() -- -- # Log the bulk action -- log_audit('BULK_DELETE', 'licenses', None, -- old_values={'license_keys': license_keys, 'count': affected_rows}, -- additional_info=f"{affected_rows} Lizenzen gelöscht") -- -- cur.close() -- conn.close() -- -- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) -- except Exception as e: -- return jsonify({'success': False, 'message': str(e)}), 500 -- --# ===================== RESOURCE POOL MANAGEMENT ===================== -- --@app.route('/resources') --@login_required --def resources(): -- """Resource Pool Hauptübersicht""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) -- show_test = request.args.get('show_test', 'false').lower() == 'true' -- -- # Statistiken abrufen -- cur.execute(""" -- SELECT -- resource_type, -- COUNT(*) FILTER (WHERE status = 'available') as available, -- COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -- COUNT(*) as total -- FROM resource_pools -- WHERE is_test = %s -- GROUP BY resource_type -- """, (show_test,)) -- -- stats = {} -- for row in cur.fetchall(): -- stats[row[0]] = { -- 'available': row[1], -- 'allocated': row[2], -- 'quarantine': row[3], -- 'total': row[4], -- 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) -- } -- -- # Letzte Aktivitäten (gefiltert nach Test/Live) -- cur.execute(""" -- SELECT -- rh.action, -- rh.action_by, -- rh.action_at, -- rp.resource_type, -- rp.resource_value, -- rh.details -- FROM resource_history rh -- JOIN resource_pools rp ON rh.resource_id = rp.id -- WHERE rp.is_test = %s -- ORDER BY rh.action_at DESC -- LIMIT 10 -- """, (show_test,)) -- recent_activities = cur.fetchall() -- -- # Ressourcen-Liste mit Pagination -- page = request.args.get('page', 1, type=int) -- per_page = 50 -- offset = (page - 1) * per_page -- -- resource_type = request.args.get('type', '') -- status_filter = request.args.get('status', '') -- search = request.args.get('search', '') -- -- # Sortierung -- sort_by = request.args.get('sort', 'id') -- sort_order = request.args.get('order', 'desc') -- -- # Base Query -- query = """ -- SELECT -- rp.id, -- rp.resource_type, -- rp.resource_value, -- rp.status, -- rp.allocated_to_license, -- l.license_key, -- c.name as customer_name, -- rp.status_changed_at, -- rp.quarantine_reason, -- rp.quarantine_until, -- c.id as customer_id -- FROM resource_pools rp -- LEFT JOIN licenses l ON rp.allocated_to_license = l.id -- LEFT JOIN customers c ON l.customer_id = c.id -- WHERE rp.is_test = %s -- """ -- params = [show_test] -- -- if resource_type: -- query += " AND rp.resource_type = %s" -- params.append(resource_type) -- -- if status_filter: -- query += " AND rp.status = %s" -- params.append(status_filter) -- -- if search: -- query += " AND rp.resource_value ILIKE %s" -- params.append(f'%{search}%') -- -- # Count total -- count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" -- cur.execute(count_query, params) -- total = cur.fetchone()[0] -- total_pages = (total + per_page - 1) // per_page -- -- # Get paginated results with dynamic sorting -- sort_column_map = { -- 'id': 'rp.id', -- 'type': 'rp.resource_type', -- 'resource': 'rp.resource_value', -- 'status': 'rp.status', -- 'assigned': 'c.name', -- 'changed': 'rp.status_changed_at' -- } -- -- sort_column = sort_column_map.get(sort_by, 'rp.id') -- sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' -- -- query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" -- params.extend([per_page, offset]) -- -- cur.execute(query, params) -- resources = cur.fetchall() -- -- cur.close() -- conn.close() -- -- return render_template('resources.html', -- stats=stats, -- resources=resources, -- recent_activities=recent_activities, -- page=page, -- total_pages=total_pages, -- total=total, -- resource_type=resource_type, -- status_filter=status_filter, -- search=search, -- show_test=show_test, -- sort_by=sort_by, -- sort_order=sort_order, -- datetime=datetime, -- timedelta=timedelta) -- --@app.route('/resources/add', methods=['GET', 'POST']) --@login_required --def add_resources(): -- """Ressourcen zum Pool hinzufügen""" -- # Hole show_test Parameter für die Anzeige -- show_test = request.args.get('show_test', 'false').lower() == 'true' -- -- if request.method == 'POST': -- resource_type = request.form.get('resource_type') -- resources_text = request.form.get('resources_text', '') -- is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten -- -- # Parse resources (one per line) -- resources = [r.strip() for r in resources_text.split('\n') if r.strip()] -- -- if not resources: -- flash('Keine Ressourcen angegeben', 'error') -- return redirect(url_for('add_resources', show_test=show_test)) -- -- conn = get_connection() -- cur = conn.cursor() -- -- added = 0 -- duplicates = 0 -- -- for resource_value in resources: -- try: -- cur.execute(""" -- INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) -- VALUES (%s, %s, %s, %s) -- ON CONFLICT (resource_type, resource_value) DO NOTHING -- """, (resource_type, resource_value, session['username'], is_test)) -- -- if cur.rowcount > 0: -- added += 1 -- # Get the inserted ID -- cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", -- (resource_type, resource_value)) -- resource_id = cur.fetchone()[0] -- -- # Log in history -- cur.execute(""" -- INSERT INTO resource_history (resource_id, action, action_by, ip_address) -- VALUES (%s, 'created', %s, %s) -- """, (resource_id, session['username'], get_client_ip())) -- else: -- duplicates += 1 -- -- except Exception as e: -- app.logger.error(f"Error adding resource {resource_value}: {e}") -- -- conn.commit() -- cur.close() -- conn.close() -- -- log_audit('CREATE', 'resource_pool', None, -- new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, -- additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") -- -- flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') -- return redirect(url_for('resources', show_test=show_test)) -- -- return render_template('add_resources.html', show_test=show_test) -- --@app.route('/resources/quarantine/', methods=['POST']) --@login_required --def quarantine_resource(resource_id): -- """Ressource in Quarantäne setzen""" -- reason = request.form.get('reason', 'review') -- until_date = request.form.get('until_date') -- notes = request.form.get('notes', '') -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Get current resource info -- cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) -- resource = cur.fetchone() -- -- if not resource: -- flash('Ressource nicht gefunden', 'error') -- return redirect(url_for('resources')) -- -- old_status = resource[2] -- -- # Update resource -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'quarantine', -- quarantine_reason = %s, -- quarantine_until = %s, -- notes = %s, -- status_changed_at = CURRENT_TIMESTAMP, -- status_changed_by = %s -- WHERE id = %s -- """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) -- -- # Log in history -- cur.execute(""" -- INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) -- VALUES (%s, 'quarantined', %s, %s, %s) -- """, (resource_id, session['username'], get_client_ip(), -- Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) -- -- conn.commit() -- cur.close() -- conn.close() -- -- log_audit('UPDATE', 'resource', resource_id, -- old_values={'status': old_status}, -- new_values={'status': 'quarantine', 'reason': reason}, -- additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") -- -- flash('Ressource in Quarantäne gesetzt', 'success') -- -- # Redirect mit allen aktuellen Filtern -- return redirect(url_for('resources', -- show_test=request.args.get('show_test', request.form.get('show_test', 'false')), -- type=request.args.get('type', request.form.get('type', '')), -- status=request.args.get('status', request.form.get('status', '')), -- search=request.args.get('search', request.form.get('search', '')))) -- --@app.route('/resources/release', methods=['POST']) --@login_required --def release_resources(): -- """Ressourcen aus Quarantäne freigeben""" -- resource_ids = request.form.getlist('resource_ids') -- -- if not resource_ids: -- flash('Keine Ressourcen ausgewählt', 'error') -- return redirect(url_for('resources')) -- -- conn = get_connection() -- cur = conn.cursor() -- -- released = 0 -- for resource_id in resource_ids: -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'available', -- quarantine_reason = NULL, -- quarantine_until = NULL, -- allocated_to_license = NULL, -- status_changed_at = CURRENT_TIMESTAMP, -- status_changed_by = %s -- WHERE id = %s AND status = 'quarantine' -- """, (session['username'], resource_id)) -- -- if cur.rowcount > 0: -- released += 1 -- # Log in history -- cur.execute(""" -- INSERT INTO resource_history (resource_id, action, action_by, ip_address) -- VALUES (%s, 'released', %s, %s) -- """, (resource_id, session['username'], get_client_ip())) -- -- conn.commit() -- cur.close() -- conn.close() -- -- log_audit('UPDATE', 'resource_pool', None, -- new_values={'released': released}, -- additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") -- -- flash(f'{released} Ressourcen freigegeben', 'success') -- -- # Redirect mit allen aktuellen Filtern -- return redirect(url_for('resources', -- show_test=request.args.get('show_test', request.form.get('show_test', 'false')), -- type=request.args.get('type', request.form.get('type', '')), -- status=request.args.get('status', request.form.get('status', '')), -- search=request.args.get('search', request.form.get('search', '')))) -- --@app.route('/api/resources/allocate', methods=['POST']) --@login_required --def allocate_resources_api(): -- """API für Ressourcen-Zuweisung bei Lizenzerstellung""" -- data = request.json -- license_id = data.get('license_id') -- domain_count = data.get('domain_count', 1) -- ipv4_count = data.get('ipv4_count', 1) -- phone_count = data.get('phone_count', 1) -- -- conn = get_connection() -- cur = conn.cursor() -- -- try: -- allocated = {'domains': [], 'ipv4s': [], 'phones': []} -- -- # Allocate domains -- if domain_count > 0: -- cur.execute(""" -- SELECT id, resource_value FROM resource_pools -- WHERE resource_type = 'domain' AND status = 'available' -- LIMIT %s FOR UPDATE -- """, (domain_count,)) -- domains = cur.fetchall() -- -- if len(domains) < domain_count: -- raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") -- -- for domain_id, domain_value in domains: -- # Update resource status -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', -- allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, -- status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], domain_id)) -- -- # Create assignment -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, domain_id, session['username'])) -- -- # Log history -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (domain_id, license_id, session['username'], get_client_ip())) -- -- allocated['domains'].append(domain_value) -- -- # Allocate IPv4s (similar logic) -- if ipv4_count > 0: -- cur.execute(""" -- SELECT id, resource_value FROM resource_pools -- WHERE resource_type = 'ipv4' AND status = 'available' -- LIMIT %s FOR UPDATE -- """, (ipv4_count,)) -- ipv4s = cur.fetchall() -- -- if len(ipv4s) < ipv4_count: -- raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") -- -- for ipv4_id, ipv4_value in ipv4s: -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', -- allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, -- status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], ipv4_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, ipv4_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (ipv4_id, license_id, session['username'], get_client_ip())) -- -- allocated['ipv4s'].append(ipv4_value) -- -- # Allocate phones (similar logic) -- if phone_count > 0: -- cur.execute(""" -- SELECT id, resource_value FROM resource_pools -- WHERE resource_type = 'phone' AND status = 'available' -- LIMIT %s FOR UPDATE -- """, (phone_count,)) -- phones = cur.fetchall() -- -- if len(phones) < phone_count: -- raise ValueError(f"Nicht genügend Telefonnummern verfügbar") -- -- for phone_id, phone_value in phones: -- cur.execute(""" -- UPDATE resource_pools -- SET status = 'allocated', -- allocated_to_license = %s, -- status_changed_at = CURRENT_TIMESTAMP, -- status_changed_by = %s -- WHERE id = %s -- """, (license_id, session['username'], phone_id)) -- -- cur.execute(""" -- INSERT INTO license_resources (license_id, resource_id, assigned_by) -- VALUES (%s, %s, %s) -- """, (license_id, phone_id, session['username'])) -- -- cur.execute(""" -- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -- VALUES (%s, %s, 'allocated', %s, %s) -- """, (phone_id, license_id, session['username'], get_client_ip())) -- -- allocated['phones'].append(phone_value) -- -- # Update license resource counts -- cur.execute(""" -- UPDATE licenses -- SET domain_count = %s, -- ipv4_count = %s, -- phone_count = %s -- WHERE id = %s -- """, (domain_count, ipv4_count, phone_count, license_id)) -- -- conn.commit() -- cur.close() -- conn.close() -- -- return jsonify({ -- 'success': True, -- 'allocated': allocated -- }) -- -- except Exception as e: -- conn.rollback() -- cur.close() -- conn.close() -- return jsonify({ -- 'success': False, -- 'error': str(e) -- }), 400 -- --@app.route('/api/resources/check-availability', methods=['GET']) --@login_required --def check_resource_availability(): -- """Prüft verfügbare Ressourcen""" -- resource_type = request.args.get('type', '') -- count = request.args.get('count', 10, type=int) -- show_test = request.args.get('show_test', 'false').lower() == 'true' -- -- conn = get_connection() -- cur = conn.cursor() -- -- if resource_type: -- # Spezifische Ressourcen für einen Typ -- cur.execute(""" -- SELECT id, resource_value -- FROM resource_pools -- WHERE status = 'available' -- AND resource_type = %s -- AND is_test = %s -- ORDER BY resource_value -- LIMIT %s -- """, (resource_type, show_test, count)) -- -- resources = [] -- for row in cur.fetchall(): -- resources.append({ -- 'id': row[0], -- 'value': row[1] -- }) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'available': resources, -- 'type': resource_type, -- 'count': len(resources) -- }) -- else: -- # Zusammenfassung aller Typen -- cur.execute(""" -- SELECT -- resource_type, -- COUNT(*) as available -- FROM resource_pools -- WHERE status = 'available' -- AND is_test = %s -- GROUP BY resource_type -- """, (show_test,)) -- -- availability = {} -- for row in cur.fetchall(): -- availability[row[0]] = row[1] -- -- cur.close() -- conn.close() -- -- return jsonify(availability) -- --@app.route('/api/global-search', methods=['GET']) --@login_required --def global_search(): -- """Global search API endpoint for searching customers and licenses""" -- query = request.args.get('q', '').strip() -- -- if not query or len(query) < 2: -- return jsonify({'customers': [], 'licenses': []}) -- -- conn = get_connection() -- cur = conn.cursor() -- -- # Search pattern with wildcards -- search_pattern = f'%{query}%' -- -- # Search customers -- cur.execute(""" -- SELECT id, name, email, company_name -- FROM customers -- WHERE (LOWER(name) LIKE LOWER(%s) -- OR LOWER(email) LIKE LOWER(%s) -- OR LOWER(company_name) LIKE LOWER(%s)) -- AND is_test = FALSE -- ORDER BY name -- LIMIT 5 -- """, (search_pattern, search_pattern, search_pattern)) -- -- customers = [] -- for row in cur.fetchall(): -- customers.append({ -- 'id': row[0], -- 'name': row[1], -- 'email': row[2], -- 'company_name': row[3] -- }) -- -- # Search licenses -- cur.execute(""" -- SELECT l.id, l.license_key, c.name as customer_name -- FROM licenses l -- JOIN customers c ON l.customer_id = c.id -- WHERE LOWER(l.license_key) LIKE LOWER(%s) -- AND l.is_test = FALSE -- ORDER BY l.created_at DESC -- LIMIT 5 -- """, (search_pattern,)) -- -- licenses = [] -- for row in cur.fetchall(): -- licenses.append({ -- 'id': row[0], -- 'license_key': row[1], -- 'customer_name': row[2] -- }) -- -- cur.close() -- conn.close() -- -- return jsonify({ -- 'customers': customers, -- 'licenses': licenses -- }) -- --@app.route('/resources/history/') --@login_required --def resource_history(resource_id): -- """Zeigt die komplette Historie einer Ressource""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Get complete resource info using named columns -- cur.execute(""" -- SELECT id, resource_type, resource_value, status, allocated_to_license, -- status_changed_at, status_changed_by, quarantine_reason, -- quarantine_until, created_at, notes -- FROM resource_pools -- WHERE id = %s -- """, (resource_id,)) -- row = cur.fetchone() -- -- if not row: -- flash('Ressource nicht gefunden', 'error') -- return redirect(url_for('resources')) -- -- # Create resource object with named attributes -- resource = { -- 'id': row[0], -- 'resource_type': row[1], -- 'resource_value': row[2], -- 'status': row[3], -- 'allocated_to_license': row[4], -- 'status_changed_at': row[5], -- 'status_changed_by': row[6], -- 'quarantine_reason': row[7], -- 'quarantine_until': row[8], -- 'created_at': row[9], -- 'notes': row[10] -- } -- -- # Get license info if allocated -- license_info = None -- if resource['allocated_to_license']: -- cur.execute("SELECT license_key FROM licenses WHERE id = %s", -- (resource['allocated_to_license'],)) -- lic = cur.fetchone() -- if lic: -- license_info = {'license_key': lic[0]} -- -- # Get history with named columns -- cur.execute(""" -- SELECT -- rh.action, -- rh.action_by, -- rh.action_at, -- rh.details, -- rh.license_id, -- rh.ip_address -- FROM resource_history rh -- WHERE rh.resource_id = %s -- ORDER BY rh.action_at DESC -- """, (resource_id,)) -- -- history = [] -- for row in cur.fetchall(): -- history.append({ -- 'action': row[0], -- 'action_by': row[1], -- 'action_at': row[2], -- 'details': row[3], -- 'license_id': row[4], -- 'ip_address': row[5] -- }) -- -- cur.close() -- conn.close() -- -- # Convert to object-like for template -- class ResourceObj: -- def __init__(self, data): -- for key, value in data.items(): -- setattr(self, key, value) -- -- resource_obj = ResourceObj(resource) -- history_objs = [ResourceObj(h) for h in history] -- -- return render_template('resource_history.html', -- resource=resource_obj, -- license_info=license_info, -- history=history_objs) -- --@app.route('/resources/metrics') --@login_required --def resources_metrics(): -- """Dashboard für Resource Metrics und Reports""" -- conn = get_connection() -- cur = conn.cursor() -- -- # Overall stats with fallback values -- cur.execute(""" -- SELECT -- COUNT(DISTINCT resource_id) as total_resources, -- COALESCE(AVG(performance_score), 0) as avg_performance, -- COALESCE(SUM(cost), 0) as total_cost, -- COALESCE(SUM(revenue), 0) as total_revenue, -- COALESCE(SUM(issues_count), 0) as total_issues -- FROM resource_metrics -- WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' -- """) -- row = cur.fetchone() -- -- # Calculate ROI -- roi = 0 -- if row[2] > 0: # if total_cost > 0 -- roi = row[3] / row[2] # revenue / cost -- -- stats = { -- 'total_resources': row[0] or 0, -- 'avg_performance': row[1] or 0, -- 'total_cost': row[2] or 0, -- 'total_revenue': row[3] or 0, -- 'total_issues': row[4] or 0, -- 'roi': roi -- } -- -- # Performance by type -- cur.execute(""" -- SELECT -- rp.resource_type, -- COALESCE(AVG(rm.performance_score), 0) as avg_score, -- COUNT(DISTINCT rp.id) as resource_count -- FROM resource_pools rp -- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -- GROUP BY rp.resource_type -- ORDER BY rp.resource_type -- """) -- performance_by_type = cur.fetchall() -- -- # Utilization data -- cur.execute(""" -- SELECT -- resource_type, -- COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -- COUNT(*) as total, -- ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent -- FROM resource_pools -- GROUP BY resource_type -- """) -- utilization_rows = cur.fetchall() -- utilization_data = [ -- { -- 'type': row[0].upper(), -- 'allocated': row[1], -- 'total': row[2], -- 'allocated_percent': row[3] -- } -- for row in utilization_rows -- ] -- -- # Top performing resources -- cur.execute(""" -- SELECT -- rp.id, -- rp.resource_type, -- rp.resource_value, -- COALESCE(AVG(rm.performance_score), 0) as avg_score, -- COALESCE(SUM(rm.revenue), 0) as total_revenue, -- COALESCE(SUM(rm.cost), 1) as total_cost, -- CASE -- WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 -- ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) -- END as roi -- FROM resource_pools rp -- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -- WHERE rp.status != 'quarantine' -- GROUP BY rp.id, rp.resource_type, rp.resource_value -- HAVING AVG(rm.performance_score) IS NOT NULL -- ORDER BY avg_score DESC -- LIMIT 10 -- """) -- top_rows = cur.fetchall() -- top_performers = [ -- { -- 'id': row[0], -- 'resource_type': row[1], -- 'resource_value': row[2], -- 'avg_score': row[3], -- 'roi': row[6] -- } -- for row in top_rows -- ] -- -- # Resources with issues -- cur.execute(""" -- SELECT -- rp.id, -- rp.resource_type, -- rp.resource_value, -- rp.status, -- COALESCE(SUM(rm.issues_count), 0) as total_issues -- FROM resource_pools rp -- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -- WHERE rm.issues_count > 0 OR rp.status = 'quarantine' -- GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status -- HAVING SUM(rm.issues_count) > 0 -- ORDER BY total_issues DESC -- LIMIT 10 -- """) -- problem_rows = cur.fetchall() -- problem_resources = [ -- { -- 'id': row[0], -- 'resource_type': row[1], -- 'resource_value': row[2], -- 'status': row[3], -- 'total_issues': row[4] -- } -- for row in problem_rows -- ] -- -- # Daily metrics for trend chart (last 30 days) -- cur.execute(""" -- SELECT -- metric_date, -- COALESCE(AVG(performance_score), 0) as avg_performance, -- COALESCE(SUM(issues_count), 0) as total_issues -- FROM resource_metrics -- WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' -- GROUP BY metric_date -- ORDER BY metric_date -- """) -- daily_rows = cur.fetchall() -- daily_metrics = [ -- { -- 'date': row[0].strftime('%d.%m'), -- 'performance': float(row[1]), -- 'issues': int(row[2]) -- } -- for row in daily_rows -- ] -- -- cur.close() -- conn.close() -- -- return render_template('resource_metrics.html', -- stats=stats, -- performance_by_type=performance_by_type, -- utilization_data=utilization_data, -- top_performers=top_performers, -- problem_resources=problem_resources, -- daily_metrics=daily_metrics) -- --@app.route('/resources/report', methods=['GET']) --@login_required --def resources_report(): -- """Generiert Ressourcen-Reports oder zeigt Report-Formular""" -- # Prüfe ob Download angefordert wurde -- if request.args.get('download') == 'true': -- report_type = request.args.get('type', 'usage') -- format_type = request.args.get('format', 'excel') -- date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) -- date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) -- -- conn = get_connection() -- cur = conn.cursor() -- -- if report_type == 'usage': -- # Auslastungsreport -- query = """ -- SELECT -- rp.resource_type, -- rp.resource_value, -- rp.status, -- COUNT(DISTINCT rh.license_id) as unique_licenses, -- COUNT(rh.id) as total_allocations, -- MIN(rh.action_at) as first_used, -- MAX(rh.action_at) as last_used -- FROM resource_pools rp -- LEFT JOIN resource_history rh ON rp.id = rh.resource_id -- AND rh.action = 'allocated' -- AND rh.action_at BETWEEN %s AND %s -- GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status -- ORDER BY rp.resource_type, total_allocations DESC -- """ -- cur.execute(query, (date_from, date_to)) -- columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] -- -- elif report_type == 'performance': -- # Performance-Report -- query = """ -- SELECT -- rp.resource_type, -- rp.resource_value, -- AVG(rm.performance_score) as avg_performance, -- SUM(rm.usage_count) as total_usage, -- SUM(rm.revenue) as total_revenue, -- SUM(rm.cost) as total_cost, -- SUM(rm.revenue - rm.cost) as profit, -- SUM(rm.issues_count) as total_issues -- FROM resource_pools rp -- JOIN resource_metrics rm ON rp.id = rm.resource_id -- WHERE rm.metric_date BETWEEN %s AND %s -- GROUP BY rp.id, rp.resource_type, rp.resource_value -- ORDER BY profit DESC -- """ -- cur.execute(query, (date_from, date_to)) -- columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] -- -- elif report_type == 'compliance': -- # Compliance-Report -- query = """ -- SELECT -- rh.action_at, -- rh.action, -- rh.action_by, -- rp.resource_type, -- rp.resource_value, -- l.license_key, -- c.name as customer_name, -- rh.ip_address -- FROM resource_history rh -- JOIN resource_pools rp ON rh.resource_id = rp.id -- LEFT JOIN licenses l ON rh.license_id = l.id -- LEFT JOIN customers c ON l.customer_id = c.id -- WHERE rh.action_at BETWEEN %s AND %s -- ORDER BY rh.action_at DESC -- """ -- cur.execute(query, (date_from, date_to)) -- columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] -- -- else: # inventory report -- # Inventar-Report -- query = """ -- SELECT -- resource_type, -- COUNT(*) FILTER (WHERE status = 'available') as available, -- COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -- COUNT(*) as total -- FROM resource_pools -- GROUP BY resource_type -- ORDER BY resource_type -- """ -- cur.execute(query) -- columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] -- -- # Convert to DataFrame -- data = cur.fetchall() -- df = pd.DataFrame(data, columns=columns) -- -- cur.close() -- conn.close() -- -- # Generate file -- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -- filename = f"resource_report_{report_type}_{timestamp}" -- -- if format_type == 'excel': -- output = io.BytesIO() -- with pd.ExcelWriter(output, engine='openpyxl') as writer: -- df.to_excel(writer, sheet_name='Report', index=False) -- -- # Auto-adjust columns width -- worksheet = writer.sheets['Report'] -- for column in worksheet.columns: -- max_length = 0 -- column = [cell for cell in column] -- for cell in column: -- try: -- if len(str(cell.value)) > max_length: -- max_length = len(str(cell.value)) -- except: -- pass -- adjusted_width = (max_length + 2) -- worksheet.column_dimensions[column[0].column_letter].width = adjusted_width -- -- output.seek(0) -- -- log_audit('EXPORT', 'resource_report', None, -- new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, -- additional_info=f"Resource Report {report_type} exportiert") -- -- return send_file(output, -- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -- as_attachment=True, -- download_name=f'{filename}.xlsx') -- -- else: # CSV -- output = io.StringIO() -- df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') -- output.seek(0) -- -- log_audit('EXPORT', 'resource_report', None, -- new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, -- additional_info=f"Resource Report {report_type} exportiert") -- -- return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), -- mimetype='text/csv', -- as_attachment=True, -- download_name=f'{filename}.csv') -- -- # Wenn kein Download, zeige Report-Formular -- return render_template('resource_report.html', -- datetime=datetime, -- timedelta=timedelta, -- username=session.get('username')) -- --if __name__ == "__main__": -- app.run(host="0.0.0.0", port=5000) -+import os -+import time -+import json -+import logging -+import requests -+from io import BytesIO -+from datetime import datetime, timedelta -+from zoneinfo import ZoneInfo -+from pathlib import Path -+ -+from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -+from flask_session import Session -+from werkzeug.middleware.proxy_fix import ProxyFix -+from apscheduler.schedulers.background import BackgroundScheduler -+import pandas as pd -+from psycopg2.extras import Json -+ -+# Import our new modules -+import config -+from db import get_connection, get_db_connection, get_db_cursor, execute_query -+from auth.decorators import login_required -+from auth.password import hash_password, verify_password -+from auth.two_factor import ( -+ generate_totp_secret, generate_qr_code, verify_totp, -+ generate_backup_codes, hash_backup_code, verify_backup_code -+) -+from auth.rate_limiting import ( -+ get_client_ip, check_ip_blocked, record_failed_attempt, -+ reset_login_attempts, get_login_attempts -+) -+from utils.audit import log_audit -+from utils.license import generate_license_key, validate_license_key -+from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -+from utils.export import ( -+ create_excel_export, format_datetime_for_export, -+ prepare_license_export_data, prepare_customer_export_data, -+ prepare_session_export_data, prepare_audit_export_data -+) -+ -+app = Flask(__name__) -+# Load configuration from config module -+app.config['SECRET_KEY'] = config.SECRET_KEY -+app.config['SESSION_TYPE'] = config.SESSION_TYPE -+app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -+app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -+app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -+app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -+app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -+app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -+app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -+app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -+Session(app) -+ -+# ProxyFix für korrekte IP-Adressen hinter Nginx -+app.wsgi_app = ProxyFix( -+ app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -+) -+ -+# Configuration is now loaded from config module -+ -+# Scheduler für automatische Backups -+scheduler = BackgroundScheduler() -+scheduler.start() -+ -+# Logging konfigurieren -+logging.basicConfig(level=logging.INFO) -+ -+ -+# Login decorator -+def login_required(f): -+ @wraps(f) -+ def decorated_function(*args, **kwargs): -+ if 'logged_in' not in session: -+ return redirect(url_for('login')) -+ -+ # Prüfe ob Session abgelaufen ist -+ 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 -+ app.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 abgelaufen - Logout -+ username = session.get('username', 'unbekannt') -+ app.logger.info(f"Session timeout for user {username} - auto logout") -+ # Audit-Log für automatischen Logout (vor 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('login')) -+ -+ # Aktivität NICHT automatisch aktualisieren -+ # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) -+ return f(*args, **kwargs) -+ return decorated_function -+ -+# DB-Verbindung mit UTF-8 Encoding -+def get_connection(): -+ 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"), -+ options='-c client_encoding=UTF8' -+ ) -+ conn.set_client_encoding('UTF8') -+ return conn -+ -+# User Authentication Helper Functions -+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')) -+ -+def get_user_by_username(username): -+ """Get user from database by username""" -+ conn = get_connection() -+ cur = conn.cursor() -+ try: -+ cur.execute(""" -+ SELECT id, username, password_hash, email, totp_secret, totp_enabled, -+ backup_codes, last_password_change, failed_2fa_attempts -+ FROM users WHERE username = %s -+ """, (username,)) -+ user = cur.fetchone() -+ if user: -+ return { -+ 'id': user[0], -+ 'username': user[1], -+ 'password_hash': user[2], -+ 'email': user[3], -+ 'totp_secret': user[4], -+ 'totp_enabled': user[5], -+ 'backup_codes': user[6], -+ 'last_password_change': user[7], -+ 'failed_2fa_attempts': user[8] -+ } -+ return None -+ finally: -+ cur.close() -+ conn.close() -+ -+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 -+ -+# Audit-Log-Funktion -+def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): -+ """Protokolliert Änderungen im Audit-Log""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ username = session.get('username', 'system') -+ ip_address = get_client_ip() if request else None -+ user_agent = request.headers.get('User-Agent') if request else None -+ -+ # Debug logging -+ app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") -+ -+ # Konvertiere Dictionaries zu JSONB -+ old_json = Json(old_values) if old_values else None -+ new_json = Json(new_values) if new_values else None -+ -+ cur.execute(""" -+ INSERT INTO audit_log -+ (username, action, entity_type, entity_id, old_values, new_values, -+ ip_address, user_agent, additional_info) -+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) -+ """, (username, action, entity_type, entity_id, old_json, new_json, -+ ip_address, user_agent, additional_info)) -+ -+ conn.commit() -+ except Exception as e: -+ print(f"Audit log error: {e}") -+ conn.rollback() -+ finally: -+ cur.close() -+ conn.close() -+ -+# Verschlüsselungs-Funktionen -+def get_or_create_encryption_key(): -+ """Holt oder erstellt einen Verschlüsselungsschlüssel""" -+ key_file = BACKUP_DIR / ".backup_key" -+ -+ # Versuche Key aus Umgebungsvariable zu lesen -+ env_key = os.getenv("BACKUP_ENCRYPTION_KEY") -+ if env_key: -+ try: -+ # Validiere den Key -+ Fernet(env_key.encode()) -+ return env_key.encode() -+ except: -+ pass -+ -+ # Wenn kein gültiger Key in ENV, prüfe Datei -+ if key_file.exists(): -+ return key_file.read_bytes() -+ -+ # Erstelle neuen Key -+ key = Fernet.generate_key() -+ key_file.write_bytes(key) -+ logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt") -+ return key -+ -+# Backup-Funktionen -+def create_backup(backup_type="manual", created_by=None): -+ """Erstellt ein verschlüsseltes Backup der Datenbank""" -+ start_time = time.time() -+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") -+ filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" -+ filepath = BACKUP_DIR / filename -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Backup-Eintrag erstellen -+ cur.execute(""" -+ INSERT INTO backup_history -+ (filename, filepath, backup_type, status, created_by, is_encrypted) -+ VALUES (%s, %s, %s, %s, %s, %s) -+ RETURNING id -+ """, (filename, str(filepath), backup_type, 'in_progress', -+ created_by or 'system', True)) -+ backup_id = cur.fetchone()[0] -+ conn.commit() -+ -+ try: -+ # PostgreSQL Dump erstellen -+ dump_command = [ -+ 'pg_dump', -+ '-h', os.getenv("POSTGRES_HOST", "postgres"), -+ '-p', os.getenv("POSTGRES_PORT", "5432"), -+ '-U', os.getenv("POSTGRES_USER"), -+ '-d', os.getenv("POSTGRES_DB"), -+ '--no-password', -+ '--verbose' -+ ] -+ -+ # PGPASSWORD setzen -+ env = os.environ.copy() -+ env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") -+ -+ # Dump ausführen -+ result = subprocess.run(dump_command, capture_output=True, text=True, env=env) -+ -+ if result.returncode != 0: -+ raise Exception(f"pg_dump failed: {result.stderr}") -+ -+ dump_data = result.stdout.encode('utf-8') -+ -+ # Komprimieren -+ compressed_data = gzip.compress(dump_data) -+ -+ # Verschlüsseln -+ key = get_or_create_encryption_key() -+ f = Fernet(key) -+ encrypted_data = f.encrypt(compressed_data) -+ -+ # Speichern -+ filepath.write_bytes(encrypted_data) -+ -+ # Statistiken sammeln -+ cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") -+ tables_count = cur.fetchone()[0] -+ -+ cur.execute(""" -+ SELECT SUM(n_live_tup) -+ FROM pg_stat_user_tables -+ """) -+ records_count = cur.fetchone()[0] or 0 -+ -+ duration = time.time() - start_time -+ filesize = filepath.stat().st_size -+ -+ # Backup-Eintrag aktualisieren -+ cur.execute(""" -+ UPDATE backup_history -+ SET status = %s, filesize = %s, tables_count = %s, -+ records_count = %s, duration_seconds = %s -+ WHERE id = %s -+ """, ('success', filesize, tables_count, records_count, duration, backup_id)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('BACKUP', 'database', backup_id, -+ additional_info=f"Backup erstellt: {filename} ({filesize} bytes)") -+ -+ # E-Mail-Benachrichtigung (wenn konfiguriert) -+ send_backup_notification(True, filename, filesize, duration) -+ -+ logging.info(f"Backup erfolgreich erstellt: {filename}") -+ return True, filename -+ -+ except Exception as e: -+ # Fehler protokollieren -+ cur.execute(""" -+ UPDATE backup_history -+ SET status = %s, error_message = %s, duration_seconds = %s -+ WHERE id = %s -+ """, ('failed', str(e), time.time() - start_time, backup_id)) -+ conn.commit() -+ -+ logging.error(f"Backup fehlgeschlagen: {e}") -+ send_backup_notification(False, filename, error=str(e)) -+ -+ return False, str(e) -+ -+ finally: -+ cur.close() -+ conn.close() -+ -+def restore_backup(backup_id, encryption_key=None): -+ """Stellt ein Backup wieder her""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Backup-Info abrufen -+ cur.execute(""" -+ SELECT filename, filepath, is_encrypted -+ FROM backup_history -+ WHERE id = %s -+ """, (backup_id,)) -+ backup_info = cur.fetchone() -+ -+ if not backup_info: -+ raise Exception("Backup nicht gefunden") -+ -+ filename, filepath, is_encrypted = backup_info -+ filepath = Path(filepath) -+ -+ if not filepath.exists(): -+ raise Exception("Backup-Datei nicht gefunden") -+ -+ # Datei lesen -+ encrypted_data = filepath.read_bytes() -+ -+ # Entschlüsseln -+ if is_encrypted: -+ key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() -+ try: -+ f = Fernet(key) -+ compressed_data = f.decrypt(encrypted_data) -+ except: -+ raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?") -+ else: -+ compressed_data = encrypted_data -+ -+ # Dekomprimieren -+ dump_data = gzip.decompress(compressed_data) -+ sql_commands = dump_data.decode('utf-8') -+ -+ # Bestehende Verbindungen schließen -+ cur.close() -+ conn.close() -+ -+ # Datenbank wiederherstellen -+ restore_command = [ -+ 'psql', -+ '-h', os.getenv("POSTGRES_HOST", "postgres"), -+ '-p', os.getenv("POSTGRES_PORT", "5432"), -+ '-U', os.getenv("POSTGRES_USER"), -+ '-d', os.getenv("POSTGRES_DB"), -+ '--no-password' -+ ] -+ -+ env = os.environ.copy() -+ env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") -+ -+ result = subprocess.run(restore_command, input=sql_commands, -+ capture_output=True, text=True, env=env) -+ -+ if result.returncode != 0: -+ raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}") -+ -+ # Audit-Log (neue Verbindung) -+ log_audit('RESTORE', 'database', backup_id, -+ additional_info=f"Backup wiederhergestellt: {filename}") -+ -+ return True, "Backup erfolgreich wiederhergestellt" -+ -+ except Exception as e: -+ logging.error(f"Wiederherstellung fehlgeschlagen: {e}") -+ return False, str(e) -+ -+def send_backup_notification(success, filename, filesize=None, duration=None, error=None): -+ """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)""" -+ if not os.getenv("EMAIL_ENABLED", "false").lower() == "true": -+ return -+ -+ # E-Mail-Funktion vorbereitet aber deaktiviert -+ # TODO: Implementieren wenn E-Mail-Server konfiguriert ist -+ logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}") -+ -+# Scheduled Backup Job -+def scheduled_backup(): -+ """Führt ein geplantes Backup aus""" -+ logging.info("Starte geplantes Backup...") -+ create_backup(backup_type="scheduled", created_by="scheduler") -+ -+# Scheduler konfigurieren - täglich um 3:00 Uhr -+scheduler.add_job( -+ scheduled_backup, -+ 'cron', -+ hour=3, -+ minute=0, -+ id='daily_backup', -+ replace_existing=True -+) -+ -+# Rate-Limiting Funktionen -+def get_client_ip(): -+ """Ermittelt die echte IP-Adresse des Clients""" -+ # Debug logging -+ app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") -+ -+ # Try X-Real-IP first (set by nginx) -+ if request.headers.get('X-Real-IP'): -+ return request.headers.get('X-Real-IP') -+ # Then X-Forwarded-For -+ elif request.headers.get('X-Forwarded-For'): -+ # X-Forwarded-For can contain multiple IPs, take the first one -+ return request.headers.get('X-Forwarded-For').split(',')[0].strip() -+ # Fallback to remote_addr -+ else: -+ return request.remote_addr -+ -+def check_ip_blocked(ip_address): -+ """Prüft ob eine IP-Adresse gesperrt ist""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ cur.execute(""" -+ SELECT blocked_until FROM login_attempts -+ WHERE ip_address = %s AND blocked_until IS NOT NULL -+ """, (ip_address,)) -+ -+ result = cur.fetchone() -+ cur.close() -+ conn.close() -+ -+ 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): -+ """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Random Fehlermeldung -+ error_message = random.choice(FAIL_MESSAGES) -+ -+ try: -+ # Prüfen ob IP bereits existiert -+ cur.execute(""" -+ SELECT attempt_count FROM login_attempts -+ WHERE ip_address = %s -+ """, (ip_address,)) -+ -+ result = cur.fetchone() -+ -+ if result: -+ # Update bestehenden Eintrag -+ 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) -+ # E-Mail-Benachrichtigung (wenn aktiviert) -+ if os.getenv("EMAIL_ENABLED", "false").lower() == "true": -+ 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: -+ # Neuen Eintrag erstellen -+ 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: -+ print(f"Rate limiting error: {e}") -+ conn.rollback() -+ finally: -+ cur.close() -+ conn.close() -+ -+ return error_message -+ -+def reset_login_attempts(ip_address): -+ """Setzt die Login-Versuche für eine IP zurück""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ cur.execute(""" -+ DELETE FROM login_attempts -+ WHERE ip_address = %s -+ """, (ip_address,)) -+ conn.commit() -+ except Exception as e: -+ print(f"Reset attempts error: {e}") -+ conn.rollback() -+ finally: -+ cur.close() -+ conn.close() -+ -+def get_login_attempts(ip_address): -+ """Gibt die Anzahl der Login-Versuche für eine IP zurück""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ cur.execute(""" -+ SELECT attempt_count FROM login_attempts -+ WHERE ip_address = %s -+ """, (ip_address,)) -+ -+ result = cur.fetchone() -+ cur.close() -+ conn.close() -+ -+ return result[0] if result else 0 -+ -+def send_security_alert_email(ip_address, username, attempt_count): -+ """Sendet eine Sicherheitswarnung per E-Mail""" -+ 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: E-Mail-Versand implementieren wenn SMTP konfiguriert -+ logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") -+ print(f"E-Mail würde gesendet: {subject}") -+ -+def verify_recaptcha(response): -+ """Verifiziert die reCAPTCHA v2 Response mit Google""" -+ secret_key = os.getenv('RECAPTCHA_SECRET_KEY') -+ -+ # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) -+ if not secret_key: -+ logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") -+ return True -+ -+ # Verifizierung bei Google -+ try: -+ verify_url = 'https://www.google.com/recaptcha/api/siteverify' -+ data = { -+ 'secret': secret_key, -+ 'response': response -+ } -+ -+ # Timeout für Request setzen -+ r = requests.post(verify_url, data=data, timeout=5) -+ result = r.json() -+ -+ # Log für Debugging -+ if not result.get('success'): -+ logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") -+ -+ return result.get('success', False) -+ -+ except requests.exceptions.RequestException as e: -+ logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") -+ # Bei Netzwerkfehlern CAPTCHA als bestanden werten -+ return True -+ except Exception as e: -+ logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") -+ return False -+ -+def generate_license_key(license_type='full'): -+ """ -+ Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ -+ -+ AF = Account Factory (Produktkennung) -+ F/T = F für Fullversion, T für Testversion -+ YYYY = Jahr -+ MM = Monat -+ XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen -+ """ -+ # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) -+ chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' -+ -+ # Datum-Teil -+ now = datetime.now(ZoneInfo("Europe/Berlin")) -+ date_part = now.strftime('%Y%m') -+ type_char = 'F' if license_type == 'full' else 'T' -+ -+ # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) -+ parts = [] -+ for _ in range(3): -+ part = ''.join(secrets.choice(chars) for _ in range(4)) -+ parts.append(part) -+ -+ # Key zusammensetzen -+ key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" -+ -+ return key -+ -+def validate_license_key(key): -+ """ -+ Validiert das License Key Format -+ Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ -+ """ -+ if not key: -+ return False -+ -+ # Pattern für das neue Format -+ # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen -+ pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' -+ -+ # Großbuchstaben für Vergleich -+ return bool(re.match(pattern, key.upper())) -+ -+@app.route("/login", methods=["GET", "POST"]) -+def login(): -+ # Timing-Attack Schutz - Start Zeit merken -+ start_time = time.time() -+ -+ # IP-Adresse ermitteln -+ ip_address = get_client_ip() -+ -+ # Prüfen ob IP gesperrt ist -+ is_blocked, blocked_until = check_ip_blocked(ip_address) -+ if is_blocked: -+ time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 -+ error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." -+ return render_template("login.html", error=error_msg, error_type="blocked") -+ -+ # Anzahl bisheriger Versuche -+ attempt_count = get_login_attempts(ip_address) -+ -+ if request.method == "POST": -+ username = request.form.get("username") -+ password = request.form.get("password") -+ captcha_response = request.form.get("g-recaptcha-response") -+ -+ # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind -+ recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') -+ if attempt_count >= 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, 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, 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 -+ admin1_user = os.getenv("ADMIN1_USERNAME") -+ admin1_pass = os.getenv("ADMIN1_PASSWORD") -+ admin2_user = os.getenv("ADMIN2_USERNAME") -+ admin2_pass = os.getenv("ADMIN2_PASSWORD") -+ -+ if ((username == admin1_user and password == admin1_pass) or -+ (username == admin2_user and password == admin2_pass)): -+ login_success = True -+ -+ # Timing-Attack Schutz - Mindestens 1 Sekunde warten -+ elapsed = time.time() - start_time -+ if elapsed < 1.0: -+ time.sleep(1.0 - elapsed) -+ -+ if login_success: -+ # Erfolgreicher Login -+ if needs_2fa: -+ # Store temporary session for 2FA verification -+ session['temp_username'] = username -+ session['temp_user_id'] = user['id'] -+ session['awaiting_2fa'] = True -+ return redirect(url_for('verify_2fa')) -+ else: -+ # Complete login without 2FA -+ session.permanent = True # Aktiviert das Timeout -+ session['logged_in'] = True -+ session['username'] = username -+ session['user_id'] = user['id'] if user else None -+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ reset_login_attempts(ip_address) -+ log_audit('LOGIN_SUCCESS', 'user', -+ additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") -+ return redirect(url_for('dashboard')) -+ else: -+ # Fehlgeschlagener Login -+ error_message = record_failed_attempt(ip_address, username) -+ new_attempt_count = get_login_attempts(ip_address) -+ -+ # Prüfen ob jetzt gesperrt -+ is_now_blocked, _ = check_ip_blocked(ip_address) -+ if is_now_blocked: -+ log_audit('LOGIN_BLOCKED', 'security', -+ additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") -+ -+ return render_template("login.html", -+ error=error_message, -+ show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), -+ error_type="failed", -+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), -+ recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) -+ -+ # GET Request -+ return render_template("login.html", -+ show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), -+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), -+ recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) -+ -+@app.route("/logout") -+def logout(): -+ username = session.get('username', 'unknown') -+ log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") -+ session.pop('logged_in', None) -+ session.pop('username', None) -+ session.pop('user_id', None) -+ session.pop('temp_username', None) -+ session.pop('temp_user_id', None) -+ session.pop('awaiting_2fa', None) -+ return redirect(url_for('login')) -+ -+@app.route("/verify-2fa", methods=["GET", "POST"]) -+def verify_2fa(): -+ if not session.get('awaiting_2fa'): -+ return redirect(url_for('login')) -+ -+ if request.method == "POST": -+ token = request.form.get('token', '').replace(' ', '') -+ username = session.get('temp_username') -+ user_id = session.get('temp_user_id') -+ -+ if not username or not user_id: -+ flash('Session expired. Please login again.', 'error') -+ return redirect(url_for('login')) -+ -+ user = get_user_by_username(username) -+ if not user: -+ flash('User not found.', 'error') -+ return redirect(url_for('login')) -+ -+ # Check if it's a backup code -+ if len(token) == 8 and token.isupper(): -+ # Try backup code -+ backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] -+ if verify_backup_code(token, backup_codes): -+ # Remove used backup code -+ code_hash = hash_backup_code(token) -+ backup_codes.remove(code_hash) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", -+ (json.dumps(backup_codes), user_id)) -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ # Complete login -+ session.permanent = True -+ session['logged_in'] = True -+ session['username'] = username -+ session['user_id'] = user_id -+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ session.pop('temp_username', None) -+ session.pop('temp_user_id', None) -+ session.pop('awaiting_2fa', None) -+ -+ flash('Login successful using backup code. Please generate new backup codes.', 'warning') -+ log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") -+ return redirect(url_for('dashboard')) -+ else: -+ # Try TOTP token -+ if verify_totp(user['totp_secret'], token): -+ # Complete login -+ session.permanent = True -+ session['logged_in'] = True -+ session['username'] = username -+ session['user_id'] = user_id -+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ session.pop('temp_username', None) -+ session.pop('temp_user_id', None) -+ session.pop('awaiting_2fa', None) -+ -+ log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") -+ return redirect(url_for('dashboard')) -+ -+ # Failed verification -+ conn = get_connection() -+ cur = conn.cursor() -+ cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", -+ (datetime.now(), user_id)) -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ flash('Invalid authentication code. Please try again.', 'error') -+ log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") -+ -+ return render_template('verify_2fa.html') -+ -+@app.route("/profile") -+@login_required -+def profile(): -+ user = get_user_by_username(session['username']) -+ if not user: -+ # For environment-based users, redirect with message -+ flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') -+ return redirect(url_for('dashboard')) -+ return render_template('profile.html', user=user) -+ -+@app.route("/profile/change-password", methods=["POST"]) -+@login_required -+def change_password(): -+ current_password = request.form.get('current_password') -+ new_password = request.form.get('new_password') -+ confirm_password = request.form.get('confirm_password') -+ -+ user = get_user_by_username(session['username']) -+ -+ # Verify current password -+ if not verify_password(current_password, user['password_hash']): -+ flash('Current password is incorrect.', 'error') -+ return redirect(url_for('profile')) -+ -+ # Check new password -+ if new_password != confirm_password: -+ flash('New passwords do not match.', 'error') -+ return redirect(url_for('profile')) -+ -+ if len(new_password) < 8: -+ flash('Password must be at least 8 characters long.', 'error') -+ return redirect(url_for('profile')) -+ -+ # Update password -+ new_hash = hash_password(new_password) -+ conn = get_connection() -+ cur = conn.cursor() -+ cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", -+ (new_hash, datetime.now(), user['id'])) -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], -+ additional_info="Password changed successfully") -+ flash('Password changed successfully.', 'success') -+ return redirect(url_for('profile')) -+ -+@app.route("/profile/setup-2fa") -+@login_required -+def setup_2fa(): -+ user = get_user_by_username(session['username']) -+ -+ if user['totp_enabled']: -+ flash('2FA is already enabled for your account.', 'info') -+ return redirect(url_for('profile')) -+ -+ # Generate new TOTP secret -+ totp_secret = generate_totp_secret() -+ session['temp_totp_secret'] = totp_secret -+ -+ # Generate QR code -+ qr_code = generate_qr_code(user['username'], totp_secret) -+ -+ return render_template('setup_2fa.html', -+ totp_secret=totp_secret, -+ qr_code=qr_code) -+ -+@app.route("/profile/enable-2fa", methods=["POST"]) -+@login_required -+def enable_2fa(): -+ token = request.form.get('token', '').replace(' ', '') -+ totp_secret = session.get('temp_totp_secret') -+ -+ if not totp_secret: -+ flash('2FA setup session expired. Please try again.', 'error') -+ return redirect(url_for('setup_2fa')) -+ -+ # Verify the token -+ if not verify_totp(totp_secret, token): -+ flash('Invalid authentication code. Please try again.', 'error') -+ return redirect(url_for('setup_2fa')) -+ -+ # Generate backup codes -+ backup_codes = generate_backup_codes() -+ hashed_codes = [hash_backup_code(code) for code in backup_codes] -+ -+ # Enable 2FA -+ conn = get_connection() -+ cur = conn.cursor() -+ cur.execute(""" -+ UPDATE users -+ SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s -+ WHERE username = %s -+ """, (totp_secret, json.dumps(hashed_codes), session['username'])) -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ session.pop('temp_totp_secret', None) -+ -+ log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") -+ -+ # Show backup codes -+ return render_template('backup_codes.html', backup_codes=backup_codes) -+ -+@app.route("/profile/disable-2fa", methods=["POST"]) -+@login_required -+def disable_2fa(): -+ password = request.form.get('password') -+ user = get_user_by_username(session['username']) -+ -+ # Verify password -+ if not verify_password(password, user['password_hash']): -+ flash('Incorrect password.', 'error') -+ return redirect(url_for('profile')) -+ -+ # Disable 2FA -+ conn = get_connection() -+ cur = conn.cursor() -+ cur.execute(""" -+ UPDATE users -+ SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL -+ WHERE username = %s -+ """, (session['username'],)) -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") -+ flash('2FA has been disabled for your account.', 'success') -+ return redirect(url_for('profile')) -+ -+@app.route("/heartbeat", methods=['POST']) -+@login_required -+def heartbeat(): -+ """Endpoint für Session Keep-Alive - aktualisiert last_activity""" -+ # Aktualisiere last_activity nur wenn explizit angefordert -+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ # Force session save -+ session.modified = True -+ -+ return jsonify({ -+ 'status': 'ok', -+ 'last_activity': session['last_activity'], -+ 'username': session.get('username') -+ }) -+ -+@app.route("/api/generate-license-key", methods=['POST']) -+@login_required -+def api_generate_key(): -+ """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" -+ try: -+ # Lizenztyp aus Request holen (default: full) -+ data = request.get_json() or {} -+ license_type = data.get('type', 'full') -+ -+ # Key generieren -+ key = generate_license_key(license_type) -+ -+ # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Wiederhole bis eindeutiger Key gefunden -+ attempts = 0 -+ while attempts < 10: # Max 10 Versuche -+ cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) -+ if not cur.fetchone(): -+ break # Key ist eindeutig -+ key = generate_license_key(license_type) -+ attempts += 1 -+ -+ cur.close() -+ conn.close() -+ -+ # Log für Audit -+ log_audit('GENERATE_KEY', 'license', -+ additional_info={'type': license_type, 'key': key}) -+ -+ return jsonify({ -+ 'success': True, -+ 'key': key, -+ 'type': license_type -+ }) -+ -+ except Exception as e: -+ logging.error(f"Fehler bei Key-Generierung: {str(e)}") -+ return jsonify({ -+ 'success': False, -+ 'error': 'Fehler bei der Key-Generierung' -+ }), 500 -+ -+@app.route("/api/customers", methods=['GET']) -+@login_required -+def api_customers(): -+ """API Endpoint für die Kundensuche mit Select2""" -+ try: -+ # Suchparameter -+ search = request.args.get('q', '').strip() -+ page = request.args.get('page', 1, type=int) -+ per_page = 20 -+ customer_id = request.args.get('id', type=int) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Einzelnen Kunden per ID abrufen -+ if customer_id: -+ cur.execute(""" -+ SELECT c.id, c.name, c.email, -+ COUNT(l.id) as license_count -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ WHERE c.id = %s -+ GROUP BY c.id, c.name, c.email -+ """, (customer_id,)) -+ -+ customer = cur.fetchone() -+ results = [] -+ if customer: -+ results.append({ -+ 'id': customer[0], -+ 'text': f"{customer[1]} ({customer[2]})", -+ 'name': customer[1], -+ 'email': customer[2], -+ 'license_count': customer[3] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'results': results, -+ 'pagination': {'more': False} -+ }) -+ -+ # SQL Query mit optionaler Suche -+ elif search: -+ cur.execute(""" -+ SELECT c.id, c.name, c.email, -+ COUNT(l.id) as license_count -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ WHERE LOWER(c.name) LIKE LOWER(%s) -+ OR LOWER(c.email) LIKE LOWER(%s) -+ GROUP BY c.id, c.name, c.email -+ ORDER BY c.name -+ LIMIT %s OFFSET %s -+ """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) -+ else: -+ cur.execute(""" -+ SELECT c.id, c.name, c.email, -+ COUNT(l.id) as license_count -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ GROUP BY c.id, c.name, c.email -+ ORDER BY c.name -+ LIMIT %s OFFSET %s -+ """, (per_page, (page - 1) * per_page)) -+ -+ customers = cur.fetchall() -+ -+ # Format für Select2 -+ results = [] -+ for customer in customers: -+ results.append({ -+ 'id': customer[0], -+ 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", -+ 'name': customer[1], -+ 'email': customer[2], -+ 'license_count': customer[3] -+ }) -+ -+ # Gesamtanzahl für Pagination -+ if search: -+ cur.execute(""" -+ SELECT COUNT(*) FROM customers -+ WHERE LOWER(name) LIKE LOWER(%s) -+ OR LOWER(email) LIKE LOWER(%s) -+ """, (f'%{search}%', f'%{search}%')) -+ else: -+ cur.execute("SELECT COUNT(*) FROM customers") -+ -+ total_count = cur.fetchone()[0] -+ -+ cur.close() -+ conn.close() -+ -+ # Select2 Response Format -+ return jsonify({ -+ 'results': results, -+ 'pagination': { -+ 'more': (page * per_page) < total_count -+ } -+ }) -+ -+ except Exception as e: -+ logging.error(f"Fehler bei Kundensuche: {str(e)}") -+ return jsonify({ -+ 'results': [], -+ 'error': 'Fehler bei der Kundensuche' -+ }), 500 -+ -+@app.route("/") -+@login_required -+def dashboard(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Statistiken abrufen -+ # Gesamtanzahl Kunden (ohne Testdaten) -+ cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") -+ total_customers = cur.fetchone()[0] -+ -+ # Gesamtanzahl Lizenzen (ohne Testdaten) -+ cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") -+ total_licenses = cur.fetchone()[0] -+ -+ # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) -+ cur.execute(""" -+ SELECT COUNT(*) FROM licenses -+ WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE -+ """) -+ active_licenses = cur.fetchone()[0] -+ -+ # Aktive Sessions -+ cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") -+ active_sessions_count = cur.fetchone()[0] -+ -+ # Abgelaufene Lizenzen (ohne Testdaten) -+ cur.execute(""" -+ SELECT COUNT(*) FROM licenses -+ WHERE valid_until < CURRENT_DATE AND is_test = FALSE -+ """) -+ expired_licenses = cur.fetchone()[0] -+ -+ # Deaktivierte Lizenzen (ohne Testdaten) -+ cur.execute(""" -+ SELECT COUNT(*) FROM licenses -+ WHERE is_active = FALSE AND is_test = FALSE -+ """) -+ inactive_licenses = cur.fetchone()[0] -+ -+ # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) -+ cur.execute(""" -+ SELECT COUNT(*) FROM licenses -+ WHERE valid_until >= CURRENT_DATE -+ AND valid_until < CURRENT_DATE + INTERVAL '30 days' -+ AND is_active = TRUE -+ AND is_test = FALSE -+ """) -+ expiring_soon = cur.fetchone()[0] -+ -+ # Testlizenzen vs Vollversionen (ohne Testdaten) -+ cur.execute(""" -+ SELECT license_type, COUNT(*) -+ FROM licenses -+ WHERE is_test = FALSE -+ GROUP BY license_type -+ """) -+ license_types = dict(cur.fetchall()) -+ -+ # Anzahl Testdaten -+ cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") -+ test_data_count = cur.fetchone()[0] -+ -+ # Anzahl Test-Kunden -+ cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") -+ test_customers_count = cur.fetchone()[0] -+ -+ # Anzahl Test-Ressourcen -+ cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") -+ test_resources_count = cur.fetchone()[0] -+ -+ # Letzte 5 erstellten Lizenzen (ohne Testdaten) -+ cur.execute(""" -+ SELECT l.id, l.license_key, c.name, l.valid_until, -+ CASE -+ WHEN l.is_active = FALSE THEN 'deaktiviert' -+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -+ ELSE 'aktiv' -+ END as status -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ WHERE l.is_test = FALSE -+ ORDER BY l.id DESC -+ LIMIT 5 -+ """) -+ recent_licenses = cur.fetchall() -+ -+ # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) -+ cur.execute(""" -+ SELECT l.id, l.license_key, c.name, l.valid_until, -+ l.valid_until - CURRENT_DATE as days_left -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ WHERE l.valid_until >= CURRENT_DATE -+ AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' -+ AND l.is_active = TRUE -+ AND l.is_test = FALSE -+ ORDER BY l.valid_until -+ LIMIT 10 -+ """) -+ expiring_licenses = cur.fetchall() -+ -+ # Letztes Backup -+ cur.execute(""" -+ SELECT created_at, filesize, duration_seconds, backup_type, status -+ FROM backup_history -+ ORDER BY created_at DESC -+ LIMIT 1 -+ """) -+ last_backup_info = cur.fetchone() -+ -+ # Sicherheitsstatistiken -+ # Gesperrte IPs -+ cur.execute(""" -+ SELECT COUNT(*) FROM login_attempts -+ WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP -+ """) -+ blocked_ips_count = cur.fetchone()[0] -+ -+ # Fehlversuche heute -+ cur.execute(""" -+ SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts -+ WHERE last_attempt::date = CURRENT_DATE -+ """) -+ failed_attempts_today = cur.fetchone()[0] -+ -+ # Letzte 5 Sicherheitsereignisse -+ cur.execute(""" -+ SELECT -+ la.ip_address, -+ la.attempt_count, -+ la.last_attempt, -+ la.blocked_until, -+ la.last_username_tried, -+ la.last_error_message -+ FROM login_attempts la -+ ORDER BY la.last_attempt DESC -+ LIMIT 5 -+ """) -+ recent_security_events = [] -+ for event in cur.fetchall(): -+ recent_security_events.append({ -+ 'ip_address': event[0], -+ 'attempt_count': event[1], -+ 'last_attempt': event[2].strftime('%d.%m %H:%M'), -+ 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, -+ 'username_tried': event[4], -+ 'error_message': event[5] -+ }) -+ -+ # Sicherheitslevel berechnen -+ if blocked_ips_count > 5 or failed_attempts_today > 50: -+ security_level = 'danger' -+ security_level_text = 'KRITISCH' -+ elif blocked_ips_count > 2 or failed_attempts_today > 20: -+ security_level = 'warning' -+ security_level_text = 'ERHÖHT' -+ else: -+ security_level = 'success' -+ security_level_text = 'NORMAL' -+ -+ # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) -+ cur.execute(""" -+ SELECT -+ resource_type, -+ COUNT(*) FILTER (WHERE status = 'available') as available, -+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -+ COUNT(*) as total -+ FROM resource_pools -+ WHERE is_test = FALSE -+ GROUP BY resource_type -+ """) -+ -+ resource_stats = {} -+ resource_warning = None -+ -+ for row in cur.fetchall(): -+ available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) -+ resource_stats[row[0]] = { -+ 'available': row[1], -+ 'allocated': row[2], -+ 'quarantine': row[3], -+ 'total': row[4], -+ 'available_percent': available_percent, -+ 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' -+ } -+ -+ # Warnung bei niedrigem Bestand -+ if row[1] < 50: -+ if not resource_warning: -+ resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" -+ else: -+ resource_warning += f" | {row[0].upper()}: {row[1]}" -+ -+ cur.close() -+ conn.close() -+ -+ stats = { -+ 'total_customers': total_customers, -+ 'total_licenses': total_licenses, -+ 'active_licenses': active_licenses, -+ 'expired_licenses': expired_licenses, -+ 'inactive_licenses': inactive_licenses, -+ 'expiring_soon': expiring_soon, -+ 'full_licenses': license_types.get('full', 0), -+ 'test_licenses': license_types.get('test', 0), -+ 'test_data_count': test_data_count, -+ 'test_customers_count': test_customers_count, -+ 'test_resources_count': test_resources_count, -+ 'recent_licenses': recent_licenses, -+ 'expiring_licenses': expiring_licenses, -+ 'active_sessions': active_sessions_count, -+ 'last_backup': last_backup_info, -+ # Sicherheitsstatistiken -+ 'blocked_ips_count': blocked_ips_count, -+ 'failed_attempts_today': failed_attempts_today, -+ 'recent_security_events': recent_security_events, -+ 'security_level': security_level, -+ 'security_level_text': security_level_text, -+ 'resource_stats': resource_stats -+ } -+ -+ return render_template("dashboard.html", -+ stats=stats, -+ resource_stats=resource_stats, -+ resource_warning=resource_warning, -+ username=session.get('username')) -+ -+@app.route("/create", methods=["GET", "POST"]) -+@login_required -+def create_license(): -+ if request.method == "POST": -+ customer_id = request.form.get("customer_id") -+ license_key = request.form["license_key"].upper() # Immer Großbuchstaben -+ license_type = request.form["license_type"] -+ valid_from = request.form["valid_from"] -+ is_test = request.form.get("is_test") == "on" # Checkbox value -+ -+ # Berechne valid_until basierend auf Laufzeit -+ duration = int(request.form.get("duration", 1)) -+ duration_type = request.form.get("duration_type", "years") -+ -+ from datetime import datetime, timedelta -+ from dateutil.relativedelta import relativedelta -+ -+ start_date = datetime.strptime(valid_from, "%Y-%m-%d") -+ -+ if duration_type == "days": -+ end_date = start_date + timedelta(days=duration) -+ elif duration_type == "months": -+ end_date = start_date + relativedelta(months=duration) -+ else: # years -+ end_date = start_date + relativedelta(years=duration) -+ -+ # Ein Tag abziehen, da der Starttag mitgezählt wird -+ end_date = end_date - timedelta(days=1) -+ valid_until = end_date.strftime("%Y-%m-%d") -+ -+ # Validiere License Key Format -+ if not validate_license_key(license_key): -+ flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') -+ return redirect(url_for('create_license')) -+ -+ # Resource counts -+ domain_count = int(request.form.get("domain_count", 1)) -+ ipv4_count = int(request.form.get("ipv4_count", 1)) -+ phone_count = int(request.form.get("phone_count", 1)) -+ device_limit = int(request.form.get("device_limit", 3)) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Prüfe ob neuer Kunde oder bestehender -+ if customer_id == "new": -+ # Neuer Kunde -+ name = request.form.get("customer_name") -+ email = request.form.get("email") -+ -+ if not name: -+ flash('Kundenname ist erforderlich!', 'error') -+ return redirect(url_for('create_license')) -+ -+ # Prüfe ob E-Mail bereits existiert -+ if email: -+ cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) -+ existing = cur.fetchone() -+ if existing: -+ flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') -+ return redirect(url_for('create_license')) -+ -+ # Kunde einfügen (erbt Test-Status von Lizenz) -+ cur.execute(""" -+ INSERT INTO customers (name, email, is_test, created_at) -+ VALUES (%s, %s, %s, NOW()) -+ RETURNING id -+ """, (name, email, is_test)) -+ customer_id = cur.fetchone()[0] -+ customer_info = {'name': name, 'email': email, 'is_test': is_test} -+ -+ # Audit-Log für neuen Kunden -+ log_audit('CREATE', 'customer', customer_id, -+ new_values={'name': name, 'email': email, 'is_test': is_test}) -+ else: -+ # Bestehender Kunde - hole Infos für Audit-Log -+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -+ customer_data = cur.fetchone() -+ if not customer_data: -+ flash('Kunde nicht gefunden!', 'error') -+ return redirect(url_for('create_license')) -+ customer_info = {'name': customer_data[0], 'email': customer_data[1]} -+ -+ # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren -+ if customer_data[2]: # is_test des Kunden -+ is_test = True -+ -+ # Lizenz hinzufügen -+ cur.execute(""" -+ INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, -+ domain_count, ipv4_count, phone_count, device_limit, is_test) -+ VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) -+ RETURNING id -+ """, (license_key, customer_id, license_type, valid_from, valid_until, -+ domain_count, ipv4_count, phone_count, device_limit, is_test)) -+ license_id = cur.fetchone()[0] -+ -+ # Ressourcen zuweisen -+ try: -+ # Prüfe Verfügbarkeit -+ cur.execute(""" -+ SELECT -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones -+ """, (is_test, is_test, is_test)) -+ available = cur.fetchone() -+ -+ if available[0] < domain_count: -+ raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") -+ if available[1] < ipv4_count: -+ raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") -+ if available[2] < phone_count: -+ raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") -+ -+ # Domains zuweisen -+ if domain_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, domain_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ # IPv4s zuweisen -+ if ipv4_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, ipv4_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ # Telefonnummern zuweisen -+ if phone_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, phone_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ except ValueError as e: -+ conn.rollback() -+ flash(str(e), 'error') -+ return redirect(url_for('create_license')) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('CREATE', 'license', license_id, -+ new_values={ -+ 'license_key': license_key, -+ 'customer_name': customer_info['name'], -+ 'customer_email': customer_info['email'], -+ 'license_type': license_type, -+ 'valid_from': valid_from, -+ 'valid_until': valid_until, -+ 'device_limit': device_limit, -+ 'is_test': is_test -+ }) -+ -+ flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') -+ -+ except Exception as e: -+ conn.rollback() -+ logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") -+ flash('Fehler beim Erstellen der Lizenz!', 'error') -+ finally: -+ cur.close() -+ conn.close() -+ -+ # Preserve show_test parameter if present -+ redirect_url = "/create" -+ if request.args.get('show_test') == 'true': -+ redirect_url += "?show_test=true" -+ return redirect(redirect_url) -+ -+ # Unterstützung für vorausgewählten Kunden -+ preselected_customer_id = request.args.get('customer_id', type=int) -+ return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) -+ -+@app.route("/batch", methods=["GET", "POST"]) -+@login_required -+def batch_licenses(): -+ """Batch-Generierung mehrerer Lizenzen für einen Kunden""" -+ if request.method == "POST": -+ # Formulardaten -+ customer_id = request.form.get("customer_id") -+ license_type = request.form["license_type"] -+ quantity = int(request.form["quantity"]) -+ valid_from = request.form["valid_from"] -+ is_test = request.form.get("is_test") == "on" # Checkbox value -+ -+ # Berechne valid_until basierend auf Laufzeit -+ duration = int(request.form.get("duration", 1)) -+ duration_type = request.form.get("duration_type", "years") -+ -+ from datetime import datetime, timedelta -+ from dateutil.relativedelta import relativedelta -+ -+ start_date = datetime.strptime(valid_from, "%Y-%m-%d") -+ -+ if duration_type == "days": -+ end_date = start_date + timedelta(days=duration) -+ elif duration_type == "months": -+ end_date = start_date + relativedelta(months=duration) -+ else: # years -+ end_date = start_date + relativedelta(years=duration) -+ -+ # Ein Tag abziehen, da der Starttag mitgezählt wird -+ end_date = end_date - timedelta(days=1) -+ valid_until = end_date.strftime("%Y-%m-%d") -+ -+ # Resource counts -+ domain_count = int(request.form.get("domain_count", 1)) -+ ipv4_count = int(request.form.get("ipv4_count", 1)) -+ phone_count = int(request.form.get("phone_count", 1)) -+ device_limit = int(request.form.get("device_limit", 3)) -+ -+ # Sicherheitslimit -+ if quantity < 1 or quantity > 100: -+ flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') -+ return redirect(url_for('batch_licenses')) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Prüfe ob neuer Kunde oder bestehender -+ if customer_id == "new": -+ # Neuer Kunde -+ name = request.form.get("customer_name") -+ email = request.form.get("email") -+ -+ if not name: -+ flash('Kundenname ist erforderlich!', 'error') -+ return redirect(url_for('batch_licenses')) -+ -+ # Prüfe ob E-Mail bereits existiert -+ if email: -+ cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) -+ existing = cur.fetchone() -+ if existing: -+ flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') -+ return redirect(url_for('batch_licenses')) -+ -+ # Kunde einfügen (erbt Test-Status von Lizenz) -+ cur.execute(""" -+ INSERT INTO customers (name, email, is_test, created_at) -+ VALUES (%s, %s, %s, NOW()) -+ RETURNING id -+ """, (name, email, is_test)) -+ customer_id = cur.fetchone()[0] -+ -+ # Audit-Log für neuen Kunden -+ log_audit('CREATE', 'customer', customer_id, -+ new_values={'name': name, 'email': email, 'is_test': is_test}) -+ else: -+ # Bestehender Kunde - hole Infos -+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -+ customer_data = cur.fetchone() -+ if not customer_data: -+ flash('Kunde nicht gefunden!', 'error') -+ return redirect(url_for('batch_licenses')) -+ name = customer_data[0] -+ email = customer_data[1] -+ -+ # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren -+ if customer_data[2]: # is_test des Kunden -+ is_test = True -+ -+ # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch -+ total_domains_needed = domain_count * quantity -+ total_ipv4s_needed = ipv4_count * quantity -+ total_phones_needed = phone_count * quantity -+ -+ cur.execute(""" -+ SELECT -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, -+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones -+ """, (is_test, is_test, is_test)) -+ available = cur.fetchone() -+ -+ if available[0] < total_domains_needed: -+ flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') -+ return redirect(url_for('batch_licenses')) -+ if available[1] < total_ipv4s_needed: -+ flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') -+ return redirect(url_for('batch_licenses')) -+ if available[2] < total_phones_needed: -+ flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') -+ return redirect(url_for('batch_licenses')) -+ -+ # Lizenzen generieren und speichern -+ generated_licenses = [] -+ for i in range(quantity): -+ # Eindeutigen Key generieren -+ attempts = 0 -+ while attempts < 10: -+ license_key = generate_license_key(license_type) -+ cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) -+ if not cur.fetchone(): -+ break -+ attempts += 1 -+ -+ # Lizenz einfügen -+ cur.execute(""" -+ INSERT INTO licenses (license_key, customer_id, license_type, is_test, -+ valid_from, valid_until, is_active, -+ domain_count, ipv4_count, phone_count, device_limit) -+ VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) -+ RETURNING id -+ """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, -+ domain_count, ipv4_count, phone_count, device_limit)) -+ license_id = cur.fetchone()[0] -+ -+ # Ressourcen für diese Lizenz zuweisen -+ # Domains -+ if domain_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, domain_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ # IPv4s -+ if ipv4_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, ipv4_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ # Telefonnummern -+ if phone_count > 0: -+ cur.execute(""" -+ SELECT id FROM resource_pools -+ WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s -+ LIMIT %s FOR UPDATE -+ """, (is_test, phone_count)) -+ for (resource_id,) in cur.fetchall(): -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], resource_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, resource_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (resource_id, license_id, session['username'], get_client_ip())) -+ -+ generated_licenses.append({ -+ 'id': license_id, -+ 'key': license_key, -+ 'type': license_type -+ }) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('CREATE_BATCH', 'license', -+ new_values={'customer': name, 'quantity': quantity, 'type': license_type}, -+ additional_info=f"Batch-Generierung von {quantity} Lizenzen") -+ -+ # Session für Export speichern -+ session['batch_export'] = { -+ 'customer': name, -+ 'email': email, -+ 'licenses': generated_licenses, -+ 'valid_from': valid_from, -+ 'valid_until': valid_until, -+ 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() -+ } -+ -+ flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') -+ return render_template("batch_result.html", -+ customer=name, -+ email=email, -+ licenses=generated_licenses, -+ valid_from=valid_from, -+ valid_until=valid_until) -+ -+ except Exception as e: -+ conn.rollback() -+ logging.error(f"Fehler bei Batch-Generierung: {str(e)}") -+ flash('Fehler bei der Batch-Generierung!', 'error') -+ return redirect(url_for('batch_licenses')) -+ finally: -+ cur.close() -+ conn.close() -+ -+ # GET Request -+ return render_template("batch_form.html") -+ -+@app.route("/batch/export") -+@login_required -+def export_batch(): -+ """Exportiert die zuletzt generierten Batch-Lizenzen""" -+ batch_data = session.get('batch_export') -+ if not batch_data: -+ flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') -+ return redirect(url_for('batch_licenses')) -+ -+ # CSV generieren -+ output = io.StringIO() -+ output.write('\ufeff') # UTF-8 BOM für Excel -+ -+ # Header -+ output.write(f"Kunde: {batch_data['customer']}\n") -+ output.write(f"E-Mail: {batch_data['email']}\n") -+ output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") -+ output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") -+ output.write("\n") -+ output.write("Nr;Lizenzschlüssel;Typ\n") -+ -+ # Lizenzen -+ for i, license in enumerate(batch_data['licenses'], 1): -+ typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" -+ output.write(f"{i};{license['key']};{typ_text}\n") -+ -+ output.seek(0) -+ -+ # Audit-Log -+ log_audit('EXPORT', 'batch_licenses', -+ additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8-sig')), -+ mimetype='text/csv', -+ as_attachment=True, -+ download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" -+ ) -+ -+@app.route("/licenses") -+@login_required -+def licenses(): -+ # Redirect zur kombinierten Ansicht -+ return redirect("/customers-licenses") -+ -+@app.route("/license/edit/", methods=["GET", "POST"]) -+@login_required -+def edit_license(license_id): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ if request.method == "POST": -+ # Alte Werte für Audit-Log abrufen -+ cur.execute(""" -+ SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit -+ FROM licenses WHERE id = %s -+ """, (license_id,)) -+ old_license = cur.fetchone() -+ -+ # Update license -+ license_key = request.form["license_key"] -+ license_type = request.form["license_type"] -+ valid_from = request.form["valid_from"] -+ valid_until = request.form["valid_until"] -+ is_active = request.form.get("is_active") == "on" -+ is_test = request.form.get("is_test") == "on" -+ device_limit = int(request.form.get("device_limit", 3)) -+ -+ cur.execute(""" -+ UPDATE licenses -+ SET license_key = %s, license_type = %s, valid_from = %s, -+ valid_until = %s, is_active = %s, is_test = %s, device_limit = %s -+ WHERE id = %s -+ """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('UPDATE', 'license', license_id, -+ old_values={ -+ 'license_key': old_license[0], -+ 'license_type': old_license[1], -+ 'valid_from': str(old_license[2]), -+ 'valid_until': str(old_license[3]), -+ 'is_active': old_license[4], -+ 'is_test': old_license[5], -+ 'device_limit': old_license[6] -+ }, -+ new_values={ -+ 'license_key': license_key, -+ 'license_type': license_type, -+ 'valid_from': valid_from, -+ 'valid_until': valid_until, -+ 'is_active': is_active, -+ 'is_test': is_test, -+ 'device_limit': device_limit -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ # Redirect zurück zu customers-licenses mit beibehaltenen Parametern -+ redirect_url = "/customers-licenses" -+ -+ # Behalte show_test Parameter bei (aus Form oder GET-Parameter) -+ show_test = request.form.get('show_test') or request.args.get('show_test') -+ if show_test == 'true': -+ redirect_url += "?show_test=true" -+ -+ # Behalte customer_id bei wenn vorhanden -+ if request.referrer and 'customer_id=' in request.referrer: -+ import re -+ match = re.search(r'customer_id=(\d+)', request.referrer) -+ if match: -+ connector = "&" if "?" in redirect_url else "?" -+ redirect_url += f"{connector}customer_id={match.group(1)}" -+ -+ return redirect(redirect_url) -+ -+ # Get license data -+ cur.execute(""" -+ SELECT l.id, l.license_key, c.name, c.email, l.license_type, -+ l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ WHERE l.id = %s -+ """, (license_id,)) -+ -+ license = cur.fetchone() -+ cur.close() -+ conn.close() -+ -+ if not license: -+ return redirect("/licenses") -+ -+ return render_template("edit_license.html", license=license, username=session.get('username')) -+ -+@app.route("/license/delete/", methods=["POST"]) -+@login_required -+def delete_license(license_id): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Lizenzdetails für Audit-Log abrufen -+ cur.execute(""" -+ SELECT l.license_key, c.name, l.license_type -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ WHERE l.id = %s -+ """, (license_id,)) -+ license_info = cur.fetchone() -+ -+ cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ if license_info: -+ log_audit('DELETE', 'license', license_id, -+ old_values={ -+ 'license_key': license_info[0], -+ 'customer_name': license_info[1], -+ 'license_type': license_info[2] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return redirect("/licenses") -+ -+@app.route("/customers") -+@login_required -+def customers(): -+ # Redirect zur kombinierten Ansicht -+ return redirect("/customers-licenses") -+ -+@app.route("/customer/edit/", methods=["GET", "POST"]) -+@login_required -+def edit_customer(customer_id): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ if request.method == "POST": -+ # Alte Werte für Audit-Log abrufen -+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) -+ old_customer = cur.fetchone() -+ -+ # Update customer -+ name = request.form["name"] -+ email = request.form["email"] -+ is_test = request.form.get("is_test") == "on" -+ -+ cur.execute(""" -+ UPDATE customers -+ SET name = %s, email = %s, is_test = %s -+ WHERE id = %s -+ """, (name, email, is_test, customer_id)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('UPDATE', 'customer', customer_id, -+ old_values={ -+ 'name': old_customer[0], -+ 'email': old_customer[1], -+ 'is_test': old_customer[2] -+ }, -+ new_values={ -+ 'name': name, -+ 'email': email, -+ 'is_test': is_test -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ # Redirect zurück zu customers-licenses mit beibehaltenen Parametern -+ redirect_url = "/customers-licenses" -+ -+ # Behalte show_test Parameter bei (aus Form oder GET-Parameter) -+ show_test = request.form.get('show_test') or request.args.get('show_test') -+ if show_test == 'true': -+ redirect_url += "?show_test=true" -+ -+ # Behalte customer_id bei (immer der aktuelle Kunde) -+ connector = "&" if "?" in redirect_url else "?" -+ redirect_url += f"{connector}customer_id={customer_id}" -+ -+ return redirect(redirect_url) -+ -+ # Get customer data with licenses -+ cur.execute(""" -+ SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s -+ """, (customer_id,)) -+ -+ customer = cur.fetchone() -+ if not customer: -+ cur.close() -+ conn.close() -+ return "Kunde nicht gefunden", 404 -+ -+ -+ # Get customer's licenses -+ cur.execute(""" -+ SELECT id, license_key, license_type, valid_from, valid_until, is_active -+ FROM licenses -+ WHERE customer_id = %s -+ ORDER BY valid_until DESC -+ """, (customer_id,)) -+ -+ licenses = cur.fetchall() -+ -+ cur.close() -+ conn.close() -+ -+ if not customer: -+ return redirect("/customers-licenses") -+ -+ return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) -+ -+@app.route("/customer/create", methods=["GET", "POST"]) -+@login_required -+def create_customer(): -+ """Erstellt einen neuen Kunden ohne Lizenz""" -+ if request.method == "POST": -+ name = request.form.get('name') -+ email = request.form.get('email') -+ is_test = request.form.get('is_test') == 'on' -+ -+ if not name or not email: -+ flash("Name und E-Mail sind Pflichtfelder!", "error") -+ return render_template("create_customer.html", username=session.get('username')) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Prüfen ob E-Mail bereits existiert -+ cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) -+ existing = cur.fetchone() -+ if existing: -+ flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") -+ return render_template("create_customer.html", username=session.get('username')) -+ -+ # Kunde erstellen -+ cur.execute(""" -+ INSERT INTO customers (name, email, created_at, is_test) -+ VALUES (%s, %s, %s, %s) RETURNING id -+ """, (name, email, datetime.now(), is_test)) -+ -+ customer_id = cur.fetchone()[0] -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('CREATE', 'customer', customer_id, -+ new_values={ -+ 'name': name, -+ 'email': email, -+ 'is_test': is_test -+ }) -+ -+ flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") -+ return redirect(f"/customer/edit/{customer_id}") -+ -+ except Exception as e: -+ conn.rollback() -+ flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") -+ return render_template("create_customer.html", username=session.get('username')) -+ finally: -+ cur.close() -+ conn.close() -+ -+ # GET Request - Formular anzeigen -+ return render_template("create_customer.html", username=session.get('username')) -+ -+@app.route("/customer/delete/", methods=["POST"]) -+@login_required -+def delete_customer(customer_id): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Prüfen ob Kunde Lizenzen hat -+ cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) -+ license_count = cur.fetchone()[0] -+ -+ if license_count > 0: -+ # Kunde hat Lizenzen - nicht löschen -+ cur.close() -+ conn.close() -+ return redirect("/customers") -+ -+ # Kundendetails für Audit-Log abrufen -+ cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) -+ customer_info = cur.fetchone() -+ -+ # Kunde löschen wenn keine Lizenzen vorhanden -+ cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ if customer_info: -+ log_audit('DELETE', 'customer', customer_id, -+ old_values={ -+ 'name': customer_info[0], -+ 'email': customer_info[1] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return redirect("/customers") -+ -+@app.route("/customers-licenses") -+@login_required -+def customers_licenses(): -+ """Kombinierte Ansicht für Kunden und deren Lizenzen""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) -+ show_test = request.args.get('show_test', 'false').lower() == 'true' -+ -+ query = """ -+ SELECT -+ c.id, -+ c.name, -+ c.email, -+ c.created_at, -+ COUNT(l.id) as total_licenses, -+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ """ -+ -+ if not show_test: -+ query += " WHERE c.is_test = FALSE" -+ -+ query += """ -+ GROUP BY c.id, c.name, c.email, c.created_at -+ ORDER BY c.name -+ """ -+ -+ cur.execute(query) -+ customers = cur.fetchall() -+ -+ # Hole ausgewählten Kunden nur wenn explizit in URL angegeben -+ selected_customer_id = request.args.get('customer_id', type=int) -+ licenses = [] -+ selected_customer = None -+ -+ if customers and selected_customer_id: -+ # Hole Daten des ausgewählten Kunden -+ for customer in customers: -+ if customer[0] == selected_customer_id: -+ selected_customer = customer -+ break -+ -+ # Hole Lizenzen des ausgewählten Kunden -+ if selected_customer: -+ cur.execute(""" -+ SELECT -+ l.id, -+ l.license_key, -+ l.license_type, -+ l.valid_from, -+ l.valid_until, -+ l.is_active, -+ CASE -+ WHEN l.is_active = FALSE THEN 'deaktiviert' -+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -+ ELSE 'aktiv' -+ END as status, -+ l.domain_count, -+ l.ipv4_count, -+ l.phone_count, -+ l.device_limit, -+ (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, -+ -- Actual resource counts -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count -+ FROM licenses l -+ WHERE l.customer_id = %s -+ ORDER BY l.created_at DESC, l.id DESC -+ """, (selected_customer_id,)) -+ licenses = cur.fetchall() -+ -+ cur.close() -+ conn.close() -+ -+ return render_template("customers_licenses.html", -+ customers=customers, -+ selected_customer=selected_customer, -+ selected_customer_id=selected_customer_id, -+ licenses=licenses, -+ show_test=show_test) -+ -+@app.route("/api/customer//licenses") -+@login_required -+def api_customer_licenses(customer_id): -+ """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Hole Lizenzen des Kunden -+ cur.execute(""" -+ SELECT -+ l.id, -+ l.license_key, -+ l.license_type, -+ l.valid_from, -+ l.valid_until, -+ l.is_active, -+ CASE -+ WHEN l.is_active = FALSE THEN 'deaktiviert' -+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' -+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' -+ ELSE 'aktiv' -+ END as status, -+ l.domain_count, -+ l.ipv4_count, -+ l.phone_count, -+ l.device_limit, -+ (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, -+ -- Actual resource counts -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, -+ (SELECT COUNT(*) FROM license_resources lr -+ JOIN resource_pools rp ON lr.resource_id = rp.id -+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count -+ FROM licenses l -+ WHERE l.customer_id = %s -+ ORDER BY l.created_at DESC, l.id DESC -+ """, (customer_id,)) -+ -+ licenses = [] -+ for row in cur.fetchall(): -+ license_id = row[0] -+ -+ # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz -+ cur.execute(""" -+ SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at -+ FROM resource_pools rp -+ JOIN license_resources lr ON rp.id = lr.resource_id -+ WHERE lr.license_id = %s AND lr.is_active = true -+ ORDER BY rp.resource_type, rp.resource_value -+ """, (license_id,)) -+ -+ resources = { -+ 'domains': [], -+ 'ipv4s': [], -+ 'phones': [] -+ } -+ -+ for res_row in cur.fetchall(): -+ resource_info = { -+ 'id': res_row[0], -+ 'value': res_row[2], -+ 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' -+ } -+ -+ if res_row[1] == 'domain': -+ resources['domains'].append(resource_info) -+ elif res_row[1] == 'ipv4': -+ resources['ipv4s'].append(resource_info) -+ elif res_row[1] == 'phone': -+ resources['phones'].append(resource_info) -+ -+ licenses.append({ -+ 'id': row[0], -+ 'license_key': row[1], -+ 'license_type': row[2], -+ 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', -+ 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', -+ 'is_active': row[5], -+ 'status': row[6], -+ 'domain_count': row[7], # limit -+ 'ipv4_count': row[8], # limit -+ 'phone_count': row[9], # limit -+ 'device_limit': row[10], -+ 'active_devices': row[11], -+ 'actual_domain_count': row[12], # actual count -+ 'actual_ipv4_count': row[13], # actual count -+ 'actual_phone_count': row[14], # actual count -+ 'resources': resources -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'success': True, -+ 'licenses': licenses, -+ 'count': len(licenses) -+ }) -+ -+@app.route("/api/customer//quick-stats") -+@login_required -+def api_customer_quick_stats(customer_id): -+ """API-Endpoint für Schnellstatistiken eines Kunden""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Hole Kundenstatistiken -+ cur.execute(""" -+ SELECT -+ COUNT(l.id) as total_licenses, -+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, -+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon -+ FROM licenses l -+ WHERE l.customer_id = %s -+ """, (customer_id,)) -+ -+ stats = cur.fetchone() -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'success': True, -+ 'stats': { -+ 'total': stats[0], -+ 'active': stats[1], -+ 'expired': stats[2], -+ 'expiring_soon': stats[3] -+ } -+ }) -+ -+@app.route("/api/license//quick-edit", methods=['POST']) -+@login_required -+def api_license_quick_edit(license_id): -+ """API-Endpoint für schnelle Lizenz-Bearbeitung""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ data = request.get_json() -+ -+ # Hole alte Werte für Audit-Log -+ cur.execute(""" -+ SELECT is_active, valid_until, license_type -+ FROM licenses WHERE id = %s -+ """, (license_id,)) -+ old_values = cur.fetchone() -+ -+ if not old_values: -+ return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 -+ -+ # Update-Felder vorbereiten -+ updates = [] -+ params = [] -+ new_values = {} -+ -+ if 'is_active' in data: -+ updates.append("is_active = %s") -+ params.append(data['is_active']) -+ new_values['is_active'] = data['is_active'] -+ -+ if 'valid_until' in data: -+ updates.append("valid_until = %s") -+ params.append(data['valid_until']) -+ new_values['valid_until'] = data['valid_until'] -+ -+ if 'license_type' in data: -+ updates.append("license_type = %s") -+ params.append(data['license_type']) -+ new_values['license_type'] = data['license_type'] -+ -+ if updates: -+ params.append(license_id) -+ cur.execute(f""" -+ UPDATE licenses -+ SET {', '.join(updates)} -+ WHERE id = %s -+ """, params) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('UPDATE', 'license', license_id, -+ old_values={ -+ 'is_active': old_values[0], -+ 'valid_until': old_values[1].isoformat() if old_values[1] else None, -+ 'license_type': old_values[2] -+ }, -+ new_values=new_values) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True}) -+ -+ except Exception as e: -+ conn.rollback() -+ cur.close() -+ conn.close() -+ return jsonify({'success': False, 'error': str(e)}), 500 -+ -+@app.route("/api/license//resources") -+@login_required -+def api_license_resources(license_id): -+ """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz -+ cur.execute(""" -+ SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at -+ FROM resource_pools rp -+ JOIN license_resources lr ON rp.id = lr.resource_id -+ WHERE lr.license_id = %s AND lr.is_active = true -+ ORDER BY rp.resource_type, rp.resource_value -+ """, (license_id,)) -+ -+ resources = { -+ 'domains': [], -+ 'ipv4s': [], -+ 'phones': [] -+ } -+ -+ for row in cur.fetchall(): -+ resource_info = { -+ 'id': row[0], -+ 'value': row[2], -+ 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' -+ } -+ -+ if row[1] == 'domain': -+ resources['domains'].append(resource_info) -+ elif row[1] == 'ipv4': -+ resources['ipv4s'].append(resource_info) -+ elif row[1] == 'phone': -+ resources['phones'].append(resource_info) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'success': True, -+ 'resources': resources -+ }) -+ -+ except Exception as e: -+ cur.close() -+ conn.close() -+ return jsonify({'success': False, 'error': str(e)}), 500 -+ -+@app.route("/sessions") -+@login_required -+def sessions(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Sortierparameter -+ active_sort = request.args.get('active_sort', 'last_heartbeat') -+ active_order = request.args.get('active_order', 'desc') -+ ended_sort = request.args.get('ended_sort', 'ended_at') -+ ended_order = request.args.get('ended_order', 'desc') -+ -+ # Whitelist für erlaubte Sortierfelder - Aktive Sessions -+ active_sort_fields = { -+ 'customer': 'c.name', -+ 'license': 'l.license_key', -+ 'ip': 's.ip_address', -+ 'started': 's.started_at', -+ 'last_heartbeat': 's.last_heartbeat', -+ 'inactive': 'minutes_inactive' -+ } -+ -+ # Whitelist für erlaubte Sortierfelder - Beendete Sessions -+ ended_sort_fields = { -+ 'customer': 'c.name', -+ 'license': 'l.license_key', -+ 'ip': 's.ip_address', -+ 'started': 's.started_at', -+ 'ended_at': 's.ended_at', -+ 'duration': 'duration_minutes' -+ } -+ -+ # Validierung -+ if active_sort not in active_sort_fields: -+ active_sort = 'last_heartbeat' -+ if ended_sort not in ended_sort_fields: -+ ended_sort = 'ended_at' -+ if active_order not in ['asc', 'desc']: -+ active_order = 'desc' -+ if ended_order not in ['asc', 'desc']: -+ ended_order = 'desc' -+ -+ # Aktive Sessions abrufen -+ cur.execute(f""" -+ SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, -+ s.user_agent, s.started_at, s.last_heartbeat, -+ EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive -+ FROM sessions s -+ JOIN licenses l ON s.license_id = l.id -+ JOIN customers c ON l.customer_id = c.id -+ WHERE s.is_active = TRUE -+ ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} -+ """) -+ active_sessions = cur.fetchall() -+ -+ # Inaktive Sessions der letzten 24 Stunden -+ cur.execute(f""" -+ SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, -+ s.started_at, s.ended_at, -+ EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes -+ FROM sessions s -+ JOIN licenses l ON s.license_id = l.id -+ JOIN customers c ON l.customer_id = c.id -+ WHERE s.is_active = FALSE -+ AND s.ended_at > NOW() - INTERVAL '24 hours' -+ ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} -+ LIMIT 50 -+ """) -+ recent_sessions = cur.fetchall() -+ -+ cur.close() -+ conn.close() -+ -+ return render_template("sessions.html", -+ active_sessions=active_sessions, -+ recent_sessions=recent_sessions, -+ active_sort=active_sort, -+ active_order=active_order, -+ ended_sort=ended_sort, -+ ended_order=ended_order, -+ username=session.get('username')) -+ -+@app.route("/session/end/", methods=["POST"]) -+@login_required -+def end_session(session_id): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Session beenden -+ cur.execute(""" -+ UPDATE sessions -+ SET is_active = FALSE, ended_at = NOW() -+ WHERE id = %s AND is_active = TRUE -+ """, (session_id,)) -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ return redirect("/sessions") -+ -+@app.route("/export/licenses") -+@login_required -+def export_licenses(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) -+ include_test = request.args.get('include_test', 'false').lower() == 'true' -+ customer_id = request.args.get('customer_id', type=int) -+ -+ query = """ -+ SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, -+ l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, -+ CASE -+ WHEN l.is_active = FALSE THEN 'Deaktiviert' -+ WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' -+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' -+ ELSE 'Aktiv' -+ END as status -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ """ -+ -+ # Build WHERE clause -+ where_conditions = [] -+ params = [] -+ -+ if not include_test: -+ where_conditions.append("l.is_test = FALSE") -+ -+ if customer_id: -+ where_conditions.append("l.customer_id = %s") -+ params.append(customer_id) -+ -+ if where_conditions: -+ query += " WHERE " + " AND ".join(where_conditions) -+ -+ query += " ORDER BY l.id" -+ -+ cur.execute(query, params) -+ -+ # Spaltennamen -+ columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', -+ 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] -+ -+ # Daten in DataFrame -+ data = cur.fetchall() -+ df = pd.DataFrame(data, columns=columns) -+ -+ # Datumsformatierung -+ df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') -+ df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') -+ -+ # Typ und Aktiv Status anpassen -+ df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) -+ df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) -+ df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) -+ -+ cur.close() -+ conn.close() -+ -+ # Export Format -+ export_format = request.args.get('format', 'excel') -+ -+ # Audit-Log -+ log_audit('EXPORT', 'license', -+ additional_info=f"Export aller Lizenzen als {export_format.upper()}") -+ filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' -+ -+ if export_format == 'csv': -+ # CSV Export -+ output = io.StringIO() -+ df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') -+ output.seek(0) -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8-sig')), -+ mimetype='text/csv', -+ as_attachment=True, -+ download_name=f'{filename}.csv' -+ ) -+ else: -+ # Excel Export -+ output = io.BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, sheet_name='Lizenzen', index=False) -+ -+ # Formatierung -+ worksheet = writer.sheets['Lizenzen'] -+ for column in worksheet.columns: -+ max_length = 0 -+ column_letter = column[0].column_letter -+ for cell in column: -+ try: -+ if len(str(cell.value)) > max_length: -+ max_length = len(str(cell.value)) -+ except: -+ pass -+ adjusted_width = min(max_length + 2, 50) -+ worksheet.column_dimensions[column_letter].width = adjusted_width -+ -+ output.seek(0) -+ -+ return send_file( -+ output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx' -+ ) -+ -+@app.route("/export/audit") -+@login_required -+def export_audit(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Holen der Filter-Parameter -+ filter_user = request.args.get('user', '') -+ filter_action = request.args.get('action', '') -+ filter_entity = request.args.get('entity', '') -+ export_format = request.args.get('format', 'excel') -+ -+ # SQL Query mit Filtern -+ query = """ -+ SELECT id, timestamp, username, action, entity_type, entity_id, -+ old_values, new_values, ip_address, user_agent, additional_info -+ FROM audit_log -+ WHERE 1=1 -+ """ -+ params = [] -+ -+ if filter_user: -+ query += " AND username ILIKE %s" -+ params.append(f'%{filter_user}%') -+ -+ if filter_action: -+ query += " AND action = %s" -+ params.append(filter_action) -+ -+ if filter_entity: -+ query += " AND entity_type = %s" -+ params.append(filter_entity) -+ -+ query += " ORDER BY timestamp DESC" -+ -+ cur.execute(query, params) -+ audit_logs = cur.fetchall() -+ cur.close() -+ conn.close() -+ -+ # Daten für Export vorbereiten -+ data = [] -+ for log in audit_logs: -+ action_text = { -+ 'CREATE': 'Erstellt', -+ 'UPDATE': 'Bearbeitet', -+ 'DELETE': 'Gelöscht', -+ 'LOGIN': 'Anmeldung', -+ 'LOGOUT': 'Abmeldung', -+ 'AUTO_LOGOUT': 'Auto-Logout', -+ 'EXPORT': 'Export', -+ 'GENERATE_KEY': 'Key generiert', -+ 'CREATE_BATCH': 'Batch erstellt', -+ 'BACKUP': 'Backup erstellt', -+ 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', -+ 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', -+ 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', -+ 'LOGIN_BLOCKED': 'Login-Blockiert', -+ 'RESTORE': 'Wiederhergestellt', -+ 'PASSWORD_CHANGE': 'Passwort geändert', -+ '2FA_ENABLED': '2FA aktiviert', -+ '2FA_DISABLED': '2FA deaktiviert' -+ }.get(log[3], log[3]) -+ -+ data.append({ -+ 'ID': log[0], -+ 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), -+ 'Benutzer': log[2], -+ 'Aktion': action_text, -+ 'Entität': log[4], -+ 'Entität-ID': log[5] or '', -+ 'IP-Adresse': log[8] or '', -+ 'Zusatzinfo': log[10] or '' -+ }) -+ -+ # DataFrame erstellen -+ df = pd.DataFrame(data) -+ -+ # Timestamp für Dateiname -+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -+ filename = f'audit_log_export_{timestamp}' -+ -+ # Audit Log für Export -+ log_audit('EXPORT', 'audit_log', -+ additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") -+ -+ if export_format == 'csv': -+ # CSV Export -+ output = io.StringIO() -+ # UTF-8 BOM für Excel -+ output.write('\ufeff') -+ df.to_csv(output, index=False, sep=';', encoding='utf-8') -+ output.seek(0) -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8')), -+ mimetype='text/csv;charset=utf-8', -+ as_attachment=True, -+ download_name=f'{filename}.csv' -+ ) -+ else: -+ # Excel Export -+ output = BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, index=False, sheet_name='Audit Log') -+ -+ # Spaltenbreiten anpassen -+ worksheet = writer.sheets['Audit Log'] -+ for idx, col in enumerate(df.columns): -+ max_length = max( -+ df[col].astype(str).map(len).max(), -+ len(col) -+ ) + 2 -+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -+ -+ output.seek(0) -+ -+ return send_file( -+ output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx' -+ ) -+ -+@app.route("/export/customers") -+@login_required -+def export_customers(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Check if test data should be included -+ include_test = request.args.get('include_test', 'false').lower() == 'true' -+ -+ # Build query based on test data filter -+ if include_test: -+ # Include all customers -+ query = """ -+ SELECT c.id, c.name, c.email, c.created_at, c.is_test, -+ COUNT(l.id) as total_licenses, -+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, -+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ GROUP BY c.id, c.name, c.email, c.created_at, c.is_test -+ ORDER BY c.id -+ """ -+ else: -+ # Exclude test customers and test licenses -+ query = """ -+ SELECT c.id, c.name, c.email, c.created_at, c.is_test, -+ COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, -+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, -+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses -+ FROM customers c -+ LEFT JOIN licenses l ON c.id = l.customer_id -+ WHERE c.is_test = FALSE -+ GROUP BY c.id, c.name, c.email, c.created_at, c.is_test -+ ORDER BY c.id -+ """ -+ -+ cur.execute(query) -+ -+ # Spaltennamen -+ columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', -+ 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] -+ -+ # Daten in DataFrame -+ data = cur.fetchall() -+ df = pd.DataFrame(data, columns=columns) -+ -+ # Datumsformatierung -+ df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') -+ -+ # Testdaten formatting -+ df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) -+ -+ cur.close() -+ conn.close() -+ -+ # Export Format -+ export_format = request.args.get('format', 'excel') -+ -+ # Audit-Log -+ log_audit('EXPORT', 'customer', -+ additional_info=f"Export aller Kunden als {export_format.upper()}") -+ filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' -+ -+ if export_format == 'csv': -+ # CSV Export -+ output = io.StringIO() -+ df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') -+ output.seek(0) -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8-sig')), -+ mimetype='text/csv', -+ as_attachment=True, -+ download_name=f'{filename}.csv' -+ ) -+ else: -+ # Excel Export -+ output = io.BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, sheet_name='Kunden', index=False) -+ -+ # Formatierung -+ worksheet = writer.sheets['Kunden'] -+ for column in worksheet.columns: -+ max_length = 0 -+ column_letter = column[0].column_letter -+ for cell in column: -+ try: -+ if len(str(cell.value)) > max_length: -+ max_length = len(str(cell.value)) -+ except: -+ pass -+ adjusted_width = min(max_length + 2, 50) -+ worksheet.column_dimensions[column_letter].width = adjusted_width -+ -+ output.seek(0) -+ -+ return send_file( -+ output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx' -+ ) -+ -+@app.route("/export/sessions") -+@login_required -+def export_sessions(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Holen des Session-Typs (active oder ended) -+ session_type = request.args.get('type', 'active') -+ export_format = request.args.get('format', 'excel') -+ -+ # Daten je nach Typ abrufen -+ if session_type == 'active': -+ # Aktive Lizenz-Sessions -+ cur.execute(""" -+ SELECT s.id, l.license_key, c.name as customer_name, s.session_id, -+ s.started_at, s.last_heartbeat, -+ EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, -+ s.ip_address, s.user_agent -+ FROM sessions s -+ JOIN licenses l ON s.license_id = l.id -+ JOIN customers c ON l.customer_id = c.id -+ WHERE s.is_active = true -+ ORDER BY s.last_heartbeat DESC -+ """) -+ sessions = cur.fetchall() -+ -+ # Daten für Export vorbereiten -+ data = [] -+ for sess in sessions: -+ duration = sess[6] -+ hours = duration // 3600 -+ minutes = (duration % 3600) // 60 -+ seconds = duration % 60 -+ -+ data.append({ -+ 'Session-ID': sess[0], -+ 'Lizenzschlüssel': sess[1], -+ 'Kunde': sess[2], -+ 'Session-ID (Tech)': sess[3], -+ 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), -+ 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), -+ 'Dauer': f"{hours}h {minutes}m {seconds}s", -+ 'IP-Adresse': sess[7], -+ 'Browser': sess[8] -+ }) -+ -+ sheet_name = 'Aktive Sessions' -+ filename_prefix = 'aktive_sessions' -+ else: -+ # Beendete Lizenz-Sessions -+ cur.execute(""" -+ SELECT s.id, l.license_key, c.name as customer_name, s.session_id, -+ s.started_at, s.ended_at, -+ EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, -+ s.ip_address, s.user_agent -+ FROM sessions s -+ JOIN licenses l ON s.license_id = l.id -+ JOIN customers c ON l.customer_id = c.id -+ WHERE s.is_active = false AND s.ended_at IS NOT NULL -+ ORDER BY s.ended_at DESC -+ LIMIT 1000 -+ """) -+ sessions = cur.fetchall() -+ -+ # Daten für Export vorbereiten -+ data = [] -+ for sess in sessions: -+ duration = sess[6] if sess[6] else 0 -+ hours = duration // 3600 -+ minutes = (duration % 3600) // 60 -+ seconds = duration % 60 -+ -+ data.append({ -+ 'Session-ID': sess[0], -+ 'Lizenzschlüssel': sess[1], -+ 'Kunde': sess[2], -+ 'Session-ID (Tech)': sess[3], -+ 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), -+ 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', -+ 'Dauer': f"{hours}h {minutes}m {seconds}s", -+ 'IP-Adresse': sess[7], -+ 'Browser': sess[8] -+ }) -+ -+ sheet_name = 'Beendete Sessions' -+ filename_prefix = 'beendete_sessions' -+ -+ cur.close() -+ conn.close() -+ -+ # DataFrame erstellen -+ df = pd.DataFrame(data) -+ -+ # Timestamp für Dateiname -+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -+ filename = f'{filename_prefix}_export_{timestamp}' -+ -+ # Audit Log für Export -+ log_audit('EXPORT', 'sessions', -+ additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") -+ -+ if export_format == 'csv': -+ # CSV Export -+ output = io.StringIO() -+ # UTF-8 BOM für Excel -+ output.write('\ufeff') -+ df.to_csv(output, index=False, sep=';', encoding='utf-8') -+ output.seek(0) -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8')), -+ mimetype='text/csv;charset=utf-8', -+ as_attachment=True, -+ download_name=f'{filename}.csv' -+ ) -+ else: -+ # Excel Export -+ output = BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, index=False, sheet_name=sheet_name) -+ -+ # Spaltenbreiten anpassen -+ worksheet = writer.sheets[sheet_name] -+ for idx, col in enumerate(df.columns): -+ max_length = max( -+ df[col].astype(str).map(len).max(), -+ len(col) -+ ) + 2 -+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -+ -+ output.seek(0) -+ -+ return send_file( -+ output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx' -+ ) -+ -+@app.route("/export/resources") -+@login_required -+def export_resources(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Holen der Filter-Parameter -+ filter_type = request.args.get('type', '') -+ filter_status = request.args.get('status', '') -+ search_query = request.args.get('search', '') -+ show_test = request.args.get('show_test', 'false').lower() == 'true' -+ export_format = request.args.get('format', 'excel') -+ -+ # SQL Query mit Filtern -+ query = """ -+ SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, -+ r.created_at, r.status_changed_at, -+ l.license_key, c.name as customer_name, c.email as customer_email, -+ l.license_type -+ FROM resource_pools r -+ LEFT JOIN licenses l ON r.allocated_to_license = l.id -+ LEFT JOIN customers c ON l.customer_id = c.id -+ WHERE 1=1 -+ """ -+ params = [] -+ -+ # Filter für Testdaten -+ if not show_test: -+ query += " AND (r.is_test = false OR r.is_test IS NULL)" -+ -+ # Filter für Ressourcentyp -+ if filter_type: -+ query += " AND r.resource_type = %s" -+ params.append(filter_type) -+ -+ # Filter für Status -+ if filter_status: -+ query += " AND r.status = %s" -+ params.append(filter_status) -+ -+ # Suchfilter -+ if search_query: -+ query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" -+ params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) -+ -+ query += " ORDER BY r.id DESC" -+ -+ cur.execute(query, params) -+ resources = cur.fetchall() -+ cur.close() -+ conn.close() -+ -+ # Daten für Export vorbereiten -+ data = [] -+ for res in resources: -+ status_text = { -+ 'available': 'Verfügbar', -+ 'allocated': 'Zugewiesen', -+ 'quarantine': 'Quarantäne' -+ }.get(res[3], res[3]) -+ -+ type_text = { -+ 'domain': 'Domain', -+ 'ipv4': 'IPv4', -+ 'phone': 'Telefon' -+ }.get(res[1], res[1]) -+ -+ data.append({ -+ 'ID': res[0], -+ 'Typ': type_text, -+ 'Ressource': res[2], -+ 'Status': status_text, -+ 'Lizenzschlüssel': res[7] or '', -+ 'Kunde': res[8] or '', -+ 'Kunden-Email': res[9] or '', -+ 'Lizenztyp': res[10] or '', -+ 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', -+ 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' -+ }) -+ -+ # DataFrame erstellen -+ df = pd.DataFrame(data) -+ -+ # Timestamp für Dateiname -+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -+ filename = f'resources_export_{timestamp}' -+ -+ # Audit Log für Export -+ log_audit('EXPORT', 'resources', -+ additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") -+ -+ if export_format == 'csv': -+ # CSV Export -+ output = io.StringIO() -+ # UTF-8 BOM für Excel -+ output.write('\ufeff') -+ df.to_csv(output, index=False, sep=';', encoding='utf-8') -+ output.seek(0) -+ -+ return send_file( -+ io.BytesIO(output.getvalue().encode('utf-8')), -+ mimetype='text/csv;charset=utf-8', -+ as_attachment=True, -+ download_name=f'{filename}.csv' -+ ) -+ else: -+ # Excel Export -+ output = BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, index=False, sheet_name='Resources') -+ -+ # Spaltenbreiten anpassen -+ worksheet = writer.sheets['Resources'] -+ for idx, col in enumerate(df.columns): -+ max_length = max( -+ df[col].astype(str).map(len).max(), -+ len(col) -+ ) + 2 -+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) -+ -+ output.seek(0) -+ -+ return send_file( -+ output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx' -+ ) -+ -+@app.route("/audit") -+@login_required -+def audit_log(): -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Parameter -+ filter_user = request.args.get('user', '').strip() -+ filter_action = request.args.get('action', '').strip() -+ filter_entity = request.args.get('entity', '').strip() -+ page = request.args.get('page', 1, type=int) -+ sort = request.args.get('sort', 'timestamp') -+ order = request.args.get('order', 'desc') -+ per_page = 50 -+ -+ # Whitelist für erlaubte Sortierfelder -+ allowed_sort_fields = { -+ 'timestamp': 'timestamp', -+ 'username': 'username', -+ 'action': 'action', -+ 'entity': 'entity_type', -+ 'ip': 'ip_address' -+ } -+ -+ # Validierung -+ if sort not in allowed_sort_fields: -+ sort = 'timestamp' -+ if order not in ['asc', 'desc']: -+ order = 'desc' -+ -+ sort_field = allowed_sort_fields[sort] -+ -+ # SQL Query mit optionalen Filtern -+ query = """ -+ SELECT id, timestamp, username, action, entity_type, entity_id, -+ old_values, new_values, ip_address, user_agent, additional_info -+ FROM audit_log -+ WHERE 1=1 -+ """ -+ -+ params = [] -+ -+ # Filter -+ if filter_user: -+ query += " AND LOWER(username) LIKE LOWER(%s)" -+ params.append(f'%{filter_user}%') -+ -+ if filter_action: -+ query += " AND action = %s" -+ params.append(filter_action) -+ -+ if filter_entity: -+ query += " AND entity_type = %s" -+ params.append(filter_entity) -+ -+ # Gesamtanzahl für Pagination -+ count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" -+ cur.execute(count_query, params) -+ total = cur.fetchone()[0] -+ -+ # Pagination -+ offset = (page - 1) * per_page -+ query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" -+ params.extend([per_page, offset]) -+ -+ cur.execute(query, params) -+ logs = cur.fetchall() -+ -+ # JSON-Werte parsen -+ parsed_logs = [] -+ for log in logs: -+ parsed_log = list(log) -+ # old_values und new_values sind bereits Dictionaries (JSONB) -+ # Keine Konvertierung nötig -+ parsed_logs.append(parsed_log) -+ -+ # Pagination Info -+ total_pages = (total + per_page - 1) // per_page -+ -+ cur.close() -+ conn.close() -+ -+ return render_template("audit_log.html", -+ logs=parsed_logs, -+ filter_user=filter_user, -+ filter_action=filter_action, -+ filter_entity=filter_entity, -+ page=page, -+ total_pages=total_pages, -+ total=total, -+ sort=sort, -+ order=order, -+ username=session.get('username')) -+ -+@app.route("/backups") -+@login_required -+def backups(): -+ """Zeigt die Backup-Historie an""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Letztes erfolgreiches Backup für Dashboard -+ cur.execute(""" -+ SELECT created_at, filesize, duration_seconds -+ FROM backup_history -+ WHERE status = 'success' -+ ORDER BY created_at DESC -+ LIMIT 1 -+ """) -+ last_backup = cur.fetchone() -+ -+ # Alle Backups abrufen -+ cur.execute(""" -+ SELECT id, filename, filesize, backup_type, status, error_message, -+ created_at, created_by, tables_count, records_count, -+ duration_seconds, is_encrypted -+ FROM backup_history -+ ORDER BY created_at DESC -+ """) -+ backups = cur.fetchall() -+ -+ cur.close() -+ conn.close() -+ -+ return render_template("backups.html", -+ backups=backups, -+ last_backup=last_backup, -+ username=session.get('username')) -+ -+@app.route("/backup/create", methods=["POST"]) -+@login_required -+def create_backup_route(): -+ """Erstellt ein manuelles Backup""" -+ username = session.get('username') -+ success, result = create_backup(backup_type="manual", created_by=username) -+ -+ if success: -+ return jsonify({ -+ 'success': True, -+ 'message': f'Backup erfolgreich erstellt: {result}' -+ }) -+ else: -+ return jsonify({ -+ 'success': False, -+ 'message': f'Backup fehlgeschlagen: {result}' -+ }), 500 -+ -+@app.route("/backup/restore/", methods=["POST"]) -+@login_required -+def restore_backup_route(backup_id): -+ """Stellt ein Backup wieder her""" -+ encryption_key = request.form.get('encryption_key') -+ -+ success, message = restore_backup(backup_id, encryption_key) -+ -+ if success: -+ return jsonify({ -+ 'success': True, -+ 'message': message -+ }) -+ else: -+ return jsonify({ -+ 'success': False, -+ 'message': message -+ }), 500 -+ -+@app.route("/backup/download/") -+@login_required -+def download_backup(backup_id): -+ """Lädt eine Backup-Datei herunter""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ cur.execute(""" -+ SELECT filename, filepath -+ FROM backup_history -+ WHERE id = %s -+ """, (backup_id,)) -+ backup_info = cur.fetchone() -+ -+ cur.close() -+ conn.close() -+ -+ if not backup_info: -+ return "Backup nicht gefunden", 404 -+ -+ filename, filepath = backup_info -+ filepath = Path(filepath) -+ -+ if not filepath.exists(): -+ return "Backup-Datei nicht gefunden", 404 -+ -+ # Audit-Log -+ log_audit('DOWNLOAD', 'backup', backup_id, -+ additional_info=f"Backup heruntergeladen: {filename}") -+ -+ return send_file(filepath, as_attachment=True, download_name=filename) -+ -+@app.route("/backup/delete/", methods=["DELETE"]) -+@login_required -+def delete_backup(backup_id): -+ """Löscht ein Backup""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ # Backup-Informationen abrufen -+ cur.execute(""" -+ SELECT filename, filepath -+ FROM backup_history -+ WHERE id = %s -+ """, (backup_id,)) -+ backup_info = cur.fetchone() -+ -+ if not backup_info: -+ return jsonify({ -+ 'success': False, -+ 'message': 'Backup nicht gefunden' -+ }), 404 -+ -+ filename, filepath = backup_info -+ filepath = Path(filepath) -+ -+ # Datei löschen, wenn sie existiert -+ if filepath.exists(): -+ filepath.unlink() -+ -+ # Aus Datenbank löschen -+ cur.execute(""" -+ DELETE FROM backup_history -+ WHERE id = %s -+ """, (backup_id,)) -+ -+ conn.commit() -+ -+ # Audit-Log -+ log_audit('DELETE', 'backup', backup_id, -+ additional_info=f"Backup gelöscht: {filename}") -+ -+ return jsonify({ -+ 'success': True, -+ 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' -+ }) -+ -+ except Exception as e: -+ conn.rollback() -+ return jsonify({ -+ 'success': False, -+ 'message': f'Fehler beim Löschen des Backups: {str(e)}' -+ }), 500 -+ finally: -+ cur.close() -+ conn.close() -+ -+@app.route("/security/blocked-ips") -+@login_required -+def blocked_ips(): -+ """Zeigt alle gesperrten IPs an""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ cur.execute(""" -+ SELECT -+ ip_address, -+ attempt_count, -+ first_attempt, -+ last_attempt, -+ blocked_until, -+ last_username_tried, -+ last_error_message -+ FROM login_attempts -+ WHERE blocked_until IS NOT NULL -+ ORDER BY blocked_until DESC -+ """) -+ -+ blocked_ips_list = [] -+ for ip in cur.fetchall(): -+ blocked_ips_list.append({ -+ 'ip_address': ip[0], -+ 'attempt_count': ip[1], -+ 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), -+ 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), -+ 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), -+ 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), -+ 'last_username': ip[5], -+ 'last_error': ip[6] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return render_template("blocked_ips.html", -+ blocked_ips=blocked_ips_list, -+ username=session.get('username')) -+ -+@app.route("/security/unblock-ip", methods=["POST"]) -+@login_required -+def unblock_ip(): -+ """Entsperrt eine IP-Adresse""" -+ ip_address = request.form.get('ip_address') -+ -+ if ip_address: -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ cur.execute(""" -+ UPDATE login_attempts -+ SET blocked_until = NULL -+ WHERE ip_address = %s -+ """, (ip_address,)) -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ # Audit-Log -+ log_audit('UNBLOCK_IP', 'security', -+ additional_info=f"IP {ip_address} manuell entsperrt") -+ -+ return redirect(url_for('blocked_ips')) -+ -+@app.route("/security/clear-attempts", methods=["POST"]) -+@login_required -+def clear_attempts(): -+ """Löscht alle Login-Versuche für eine IP""" -+ ip_address = request.form.get('ip_address') -+ -+ if ip_address: -+ reset_login_attempts(ip_address) -+ -+ # Audit-Log -+ log_audit('CLEAR_ATTEMPTS', 'security', -+ additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") -+ -+ return redirect(url_for('blocked_ips')) -+ -+# API Endpoints for License Management -+@app.route("/api/license//toggle", methods=["POST"]) -+@login_required -+def toggle_license_api(license_id): -+ """Toggle license active status via API""" -+ try: -+ data = request.get_json() -+ is_active = data.get('is_active', False) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Update license status -+ cur.execute(""" -+ UPDATE licenses -+ SET is_active = %s -+ WHERE id = %s -+ """, (is_active, license_id)) -+ -+ conn.commit() -+ -+ # Log the action -+ log_audit('UPDATE', 'license', license_id, -+ new_values={'is_active': is_active}, -+ additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) -+ except Exception as e: -+ return jsonify({'success': False, 'message': str(e)}), 500 -+ -+@app.route("/api/licenses/bulk-activate", methods=["POST"]) -+@login_required -+def bulk_activate_licenses(): -+ """Activate multiple licenses at once""" -+ try: -+ data = request.get_json() -+ license_ids = data.get('ids', []) -+ -+ if not license_ids: -+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Update all selected licenses (nur Live-Daten) -+ cur.execute(""" -+ UPDATE licenses -+ SET is_active = TRUE -+ WHERE id = ANY(%s) AND is_test = FALSE -+ """, (license_ids,)) -+ -+ affected_rows = cur.rowcount -+ conn.commit() -+ -+ # Log the bulk action -+ log_audit('BULK_UPDATE', 'licenses', None, -+ new_values={'is_active': True, 'count': affected_rows}, -+ additional_info=f"{affected_rows} Lizenzen aktiviert") -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) -+ except Exception as e: -+ return jsonify({'success': False, 'message': str(e)}), 500 -+ -+@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -+@login_required -+def bulk_deactivate_licenses(): -+ """Deactivate multiple licenses at once""" -+ try: -+ data = request.get_json() -+ license_ids = data.get('ids', []) -+ -+ if not license_ids: -+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Update all selected licenses (nur Live-Daten) -+ cur.execute(""" -+ UPDATE licenses -+ SET is_active = FALSE -+ WHERE id = ANY(%s) AND is_test = FALSE -+ """, (license_ids,)) -+ -+ affected_rows = cur.rowcount -+ conn.commit() -+ -+ # Log the bulk action -+ log_audit('BULK_UPDATE', 'licenses', None, -+ new_values={'is_active': False, 'count': affected_rows}, -+ additional_info=f"{affected_rows} Lizenzen deaktiviert") -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) -+ except Exception as e: -+ return jsonify({'success': False, 'message': str(e)}), 500 -+ -+@app.route("/api/license//devices") -+@login_required -+def get_license_devices(license_id): -+ """Hole alle registrierten Geräte einer Lizenz""" -+ try: -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Prüfe ob Lizenz existiert und hole device_limit -+ cur.execute(""" -+ SELECT device_limit FROM licenses WHERE id = %s -+ """, (license_id,)) -+ license_data = cur.fetchone() -+ -+ if not license_data: -+ return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 -+ -+ device_limit = license_data[0] -+ -+ # Hole alle Geräte für diese Lizenz -+ cur.execute(""" -+ SELECT id, hardware_id, device_name, operating_system, -+ first_seen, last_seen, is_active, ip_address -+ FROM device_registrations -+ WHERE license_id = %s -+ ORDER BY is_active DESC, last_seen DESC -+ """, (license_id,)) -+ -+ devices = [] -+ for row in cur.fetchall(): -+ devices.append({ -+ 'id': row[0], -+ 'hardware_id': row[1], -+ 'device_name': row[2] or 'Unbekanntes Gerät', -+ 'operating_system': row[3] or 'Unbekannt', -+ 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', -+ 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', -+ 'is_active': row[6], -+ 'ip_address': row[7] or '-' -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'success': True, -+ 'devices': devices, -+ 'device_limit': device_limit, -+ 'active_count': sum(1 for d in devices if d['is_active']) -+ }) -+ -+ except Exception as e: -+ logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") -+ return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 -+ -+@app.route("/api/license//register-device", methods=["POST"]) -+def register_device(license_id): -+ """Registriere ein neues Gerät für eine Lizenz""" -+ try: -+ data = request.get_json() -+ hardware_id = data.get('hardware_id') -+ device_name = data.get('device_name', '') -+ operating_system = data.get('operating_system', '') -+ -+ if not hardware_id: -+ return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Prüfe ob Lizenz existiert und aktiv ist -+ cur.execute(""" -+ SELECT device_limit, is_active, valid_until -+ FROM licenses -+ WHERE id = %s -+ """, (license_id,)) -+ license_data = cur.fetchone() -+ -+ if not license_data: -+ return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 -+ -+ device_limit, is_active, valid_until = license_data -+ -+ # Prüfe ob Lizenz aktiv und gültig ist -+ if not is_active: -+ return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 -+ -+ if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): -+ return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 -+ -+ # Prüfe ob Gerät bereits registriert ist -+ cur.execute(""" -+ SELECT id, is_active FROM device_registrations -+ WHERE license_id = %s AND hardware_id = %s -+ """, (license_id, hardware_id)) -+ existing_device = cur.fetchone() -+ -+ if existing_device: -+ device_id, is_device_active = existing_device -+ if is_device_active: -+ # Gerät ist bereits aktiv, update last_seen -+ cur.execute(""" -+ UPDATE device_registrations -+ SET last_seen = CURRENT_TIMESTAMP, -+ ip_address = %s, -+ user_agent = %s -+ WHERE id = %s -+ """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -+ conn.commit() -+ return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) -+ else: -+ # Gerät war deaktiviert, prüfe ob wir es reaktivieren können -+ cur.execute(""" -+ SELECT COUNT(*) FROM device_registrations -+ WHERE license_id = %s AND is_active = TRUE -+ """, (license_id,)) -+ active_count = cur.fetchone()[0] -+ -+ if active_count >= device_limit: -+ return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 -+ -+ # Reaktiviere das Gerät -+ cur.execute(""" -+ UPDATE device_registrations -+ SET is_active = TRUE, -+ last_seen = CURRENT_TIMESTAMP, -+ deactivated_at = NULL, -+ deactivated_by = NULL, -+ ip_address = %s, -+ user_agent = %s -+ WHERE id = %s -+ """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) -+ conn.commit() -+ return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) -+ -+ # Neues Gerät - prüfe Gerätelimit -+ cur.execute(""" -+ SELECT COUNT(*) FROM device_registrations -+ WHERE license_id = %s AND is_active = TRUE -+ """, (license_id,)) -+ active_count = cur.fetchone()[0] -+ -+ if active_count >= device_limit: -+ return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 -+ -+ # Registriere neues Gerät -+ cur.execute(""" -+ INSERT INTO device_registrations -+ (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) -+ VALUES (%s, %s, %s, %s, %s, %s) -+ RETURNING id -+ """, (license_id, hardware_id, device_name, operating_system, -+ get_client_ip(), request.headers.get('User-Agent', ''))) -+ device_id = cur.fetchone()[0] -+ -+ conn.commit() -+ -+ # Audit Log -+ log_audit('DEVICE_REGISTER', 'device', device_id, -+ new_values={'license_id': license_id, 'hardware_id': hardware_id}) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) -+ -+ except Exception as e: -+ logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") -+ return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 -+ -+@app.route("/api/license//deactivate-device/", methods=["POST"]) -+@login_required -+def deactivate_device(license_id, device_id): -+ """Deaktiviere ein registriertes Gerät""" -+ try: -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Prüfe ob das Gerät zu dieser Lizenz gehört -+ cur.execute(""" -+ SELECT id FROM device_registrations -+ WHERE id = %s AND license_id = %s AND is_active = TRUE -+ """, (device_id, license_id)) -+ -+ if not cur.fetchone(): -+ return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 -+ -+ # Deaktiviere das Gerät -+ cur.execute(""" -+ UPDATE device_registrations -+ SET is_active = FALSE, -+ deactivated_at = CURRENT_TIMESTAMP, -+ deactivated_by = %s -+ WHERE id = %s -+ """, (session['username'], device_id)) -+ -+ conn.commit() -+ -+ # Audit Log -+ log_audit('DEVICE_DEACTIVATE', 'device', device_id, -+ old_values={'is_active': True}, -+ new_values={'is_active': False}) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) -+ -+ except Exception as e: -+ logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") -+ return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 -+ -+@app.route("/api/licenses/bulk-delete", methods=["POST"]) -+@login_required -+def bulk_delete_licenses(): -+ """Delete multiple licenses at once""" -+ try: -+ data = request.get_json() -+ license_ids = data.get('ids', []) -+ -+ if not license_ids: -+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Get license info for audit log (nur Live-Daten) -+ cur.execute(""" -+ SELECT license_key -+ FROM licenses -+ WHERE id = ANY(%s) AND is_test = FALSE -+ """, (license_ids,)) -+ license_keys = [row[0] for row in cur.fetchall()] -+ -+ # Delete all selected licenses (nur Live-Daten) -+ cur.execute(""" -+ DELETE FROM licenses -+ WHERE id = ANY(%s) AND is_test = FALSE -+ """, (license_ids,)) -+ -+ affected_rows = cur.rowcount -+ conn.commit() -+ -+ # Log the bulk action -+ log_audit('BULK_DELETE', 'licenses', None, -+ old_values={'license_keys': license_keys, 'count': affected_rows}, -+ additional_info=f"{affected_rows} Lizenzen gelöscht") -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) -+ except Exception as e: -+ return jsonify({'success': False, 'message': str(e)}), 500 -+ -+# ===================== RESOURCE POOL MANAGEMENT ===================== -+ -+@app.route('/resources') -+@login_required -+def resources(): -+ """Resource Pool Hauptübersicht""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) -+ show_test = request.args.get('show_test', 'false').lower() == 'true' -+ -+ # Statistiken abrufen -+ cur.execute(""" -+ SELECT -+ resource_type, -+ COUNT(*) FILTER (WHERE status = 'available') as available, -+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -+ COUNT(*) as total -+ FROM resource_pools -+ WHERE is_test = %s -+ GROUP BY resource_type -+ """, (show_test,)) -+ -+ stats = {} -+ for row in cur.fetchall(): -+ stats[row[0]] = { -+ 'available': row[1], -+ 'allocated': row[2], -+ 'quarantine': row[3], -+ 'total': row[4], -+ 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) -+ } -+ -+ # Letzte Aktivitäten (gefiltert nach Test/Live) -+ cur.execute(""" -+ SELECT -+ rh.action, -+ rh.action_by, -+ rh.action_at, -+ rp.resource_type, -+ rp.resource_value, -+ rh.details -+ FROM resource_history rh -+ JOIN resource_pools rp ON rh.resource_id = rp.id -+ WHERE rp.is_test = %s -+ ORDER BY rh.action_at DESC -+ LIMIT 10 -+ """, (show_test,)) -+ recent_activities = cur.fetchall() -+ -+ # Ressourcen-Liste mit Pagination -+ page = request.args.get('page', 1, type=int) -+ per_page = 50 -+ offset = (page - 1) * per_page -+ -+ resource_type = request.args.get('type', '') -+ status_filter = request.args.get('status', '') -+ search = request.args.get('search', '') -+ -+ # Sortierung -+ sort_by = request.args.get('sort', 'id') -+ sort_order = request.args.get('order', 'desc') -+ -+ # Base Query -+ query = """ -+ SELECT -+ rp.id, -+ rp.resource_type, -+ rp.resource_value, -+ rp.status, -+ rp.allocated_to_license, -+ l.license_key, -+ c.name as customer_name, -+ rp.status_changed_at, -+ rp.quarantine_reason, -+ rp.quarantine_until, -+ c.id as customer_id -+ FROM resource_pools rp -+ LEFT JOIN licenses l ON rp.allocated_to_license = l.id -+ LEFT JOIN customers c ON l.customer_id = c.id -+ WHERE rp.is_test = %s -+ """ -+ params = [show_test] -+ -+ if resource_type: -+ query += " AND rp.resource_type = %s" -+ params.append(resource_type) -+ -+ if status_filter: -+ query += " AND rp.status = %s" -+ params.append(status_filter) -+ -+ if search: -+ query += " AND rp.resource_value ILIKE %s" -+ params.append(f'%{search}%') -+ -+ # Count total -+ count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" -+ cur.execute(count_query, params) -+ total = cur.fetchone()[0] -+ total_pages = (total + per_page - 1) // per_page -+ -+ # Get paginated results with dynamic sorting -+ sort_column_map = { -+ 'id': 'rp.id', -+ 'type': 'rp.resource_type', -+ 'resource': 'rp.resource_value', -+ 'status': 'rp.status', -+ 'assigned': 'c.name', -+ 'changed': 'rp.status_changed_at' -+ } -+ -+ sort_column = sort_column_map.get(sort_by, 'rp.id') -+ sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' -+ -+ query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" -+ params.extend([per_page, offset]) -+ -+ cur.execute(query, params) -+ resources = cur.fetchall() -+ -+ cur.close() -+ conn.close() -+ -+ return render_template('resources.html', -+ stats=stats, -+ resources=resources, -+ recent_activities=recent_activities, -+ page=page, -+ total_pages=total_pages, -+ total=total, -+ resource_type=resource_type, -+ status_filter=status_filter, -+ search=search, -+ show_test=show_test, -+ sort_by=sort_by, -+ sort_order=sort_order, -+ datetime=datetime, -+ timedelta=timedelta) -+ -+@app.route('/resources/add', methods=['GET', 'POST']) -+@login_required -+def add_resources(): -+ """Ressourcen zum Pool hinzufügen""" -+ # Hole show_test Parameter für die Anzeige -+ show_test = request.args.get('show_test', 'false').lower() == 'true' -+ -+ if request.method == 'POST': -+ resource_type = request.form.get('resource_type') -+ resources_text = request.form.get('resources_text', '') -+ is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten -+ -+ # Parse resources (one per line) -+ resources = [r.strip() for r in resources_text.split('\n') if r.strip()] -+ -+ if not resources: -+ flash('Keine Ressourcen angegeben', 'error') -+ return redirect(url_for('add_resources', show_test=show_test)) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ added = 0 -+ duplicates = 0 -+ -+ for resource_value in resources: -+ try: -+ cur.execute(""" -+ INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) -+ VALUES (%s, %s, %s, %s) -+ ON CONFLICT (resource_type, resource_value) DO NOTHING -+ """, (resource_type, resource_value, session['username'], is_test)) -+ -+ if cur.rowcount > 0: -+ added += 1 -+ # Get the inserted ID -+ cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", -+ (resource_type, resource_value)) -+ resource_id = cur.fetchone()[0] -+ -+ # Log in history -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, action, action_by, ip_address) -+ VALUES (%s, 'created', %s, %s) -+ """, (resource_id, session['username'], get_client_ip())) -+ else: -+ duplicates += 1 -+ -+ except Exception as e: -+ app.logger.error(f"Error adding resource {resource_value}: {e}") -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ log_audit('CREATE', 'resource_pool', None, -+ new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, -+ additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") -+ -+ flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') -+ return redirect(url_for('resources', show_test=show_test)) -+ -+ return render_template('add_resources.html', show_test=show_test) -+ -+@app.route('/resources/quarantine/', methods=['POST']) -+@login_required -+def quarantine_resource(resource_id): -+ """Ressource in Quarantäne setzen""" -+ reason = request.form.get('reason', 'review') -+ until_date = request.form.get('until_date') -+ notes = request.form.get('notes', '') -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Get current resource info -+ cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) -+ resource = cur.fetchone() -+ -+ if not resource: -+ flash('Ressource nicht gefunden', 'error') -+ return redirect(url_for('resources')) -+ -+ old_status = resource[2] -+ -+ # Update resource -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'quarantine', -+ quarantine_reason = %s, -+ quarantine_until = %s, -+ notes = %s, -+ status_changed_at = CURRENT_TIMESTAMP, -+ status_changed_by = %s -+ WHERE id = %s -+ """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) -+ -+ # Log in history -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) -+ VALUES (%s, 'quarantined', %s, %s, %s) -+ """, (resource_id, session['username'], get_client_ip(), -+ Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ log_audit('UPDATE', 'resource', resource_id, -+ old_values={'status': old_status}, -+ new_values={'status': 'quarantine', 'reason': reason}, -+ additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") -+ -+ flash('Ressource in Quarantäne gesetzt', 'success') -+ -+ # Redirect mit allen aktuellen Filtern -+ return redirect(url_for('resources', -+ show_test=request.args.get('show_test', request.form.get('show_test', 'false')), -+ type=request.args.get('type', request.form.get('type', '')), -+ status=request.args.get('status', request.form.get('status', '')), -+ search=request.args.get('search', request.form.get('search', '')))) -+ -+@app.route('/resources/release', methods=['POST']) -+@login_required -+def release_resources(): -+ """Ressourcen aus Quarantäne freigeben""" -+ resource_ids = request.form.getlist('resource_ids') -+ -+ if not resource_ids: -+ flash('Keine Ressourcen ausgewählt', 'error') -+ return redirect(url_for('resources')) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ released = 0 -+ for resource_id in resource_ids: -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'available', -+ quarantine_reason = NULL, -+ quarantine_until = NULL, -+ allocated_to_license = NULL, -+ status_changed_at = CURRENT_TIMESTAMP, -+ status_changed_by = %s -+ WHERE id = %s AND status = 'quarantine' -+ """, (session['username'], resource_id)) -+ -+ if cur.rowcount > 0: -+ released += 1 -+ # Log in history -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, action, action_by, ip_address) -+ VALUES (%s, 'released', %s, %s) -+ """, (resource_id, session['username'], get_client_ip())) -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ log_audit('UPDATE', 'resource_pool', None, -+ new_values={'released': released}, -+ additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") -+ -+ flash(f'{released} Ressourcen freigegeben', 'success') -+ -+ # Redirect mit allen aktuellen Filtern -+ return redirect(url_for('resources', -+ show_test=request.args.get('show_test', request.form.get('show_test', 'false')), -+ type=request.args.get('type', request.form.get('type', '')), -+ status=request.args.get('status', request.form.get('status', '')), -+ search=request.args.get('search', request.form.get('search', '')))) -+ -+@app.route('/api/resources/allocate', methods=['POST']) -+@login_required -+def allocate_resources_api(): -+ """API für Ressourcen-Zuweisung bei Lizenzerstellung""" -+ data = request.json -+ license_id = data.get('license_id') -+ domain_count = data.get('domain_count', 1) -+ ipv4_count = data.get('ipv4_count', 1) -+ phone_count = data.get('phone_count', 1) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ try: -+ allocated = {'domains': [], 'ipv4s': [], 'phones': []} -+ -+ # Allocate domains -+ if domain_count > 0: -+ cur.execute(""" -+ SELECT id, resource_value FROM resource_pools -+ WHERE resource_type = 'domain' AND status = 'available' -+ LIMIT %s FOR UPDATE -+ """, (domain_count,)) -+ domains = cur.fetchall() -+ -+ if len(domains) < domain_count: -+ raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") -+ -+ for domain_id, domain_value in domains: -+ # Update resource status -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', -+ allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, -+ status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], domain_id)) -+ -+ # Create assignment -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, domain_id, session['username'])) -+ -+ # Log history -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (domain_id, license_id, session['username'], get_client_ip())) -+ -+ allocated['domains'].append(domain_value) -+ -+ # Allocate IPv4s (similar logic) -+ if ipv4_count > 0: -+ cur.execute(""" -+ SELECT id, resource_value FROM resource_pools -+ WHERE resource_type = 'ipv4' AND status = 'available' -+ LIMIT %s FOR UPDATE -+ """, (ipv4_count,)) -+ ipv4s = cur.fetchall() -+ -+ if len(ipv4s) < ipv4_count: -+ raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") -+ -+ for ipv4_id, ipv4_value in ipv4s: -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', -+ allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, -+ status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], ipv4_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, ipv4_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (ipv4_id, license_id, session['username'], get_client_ip())) -+ -+ allocated['ipv4s'].append(ipv4_value) -+ -+ # Allocate phones (similar logic) -+ if phone_count > 0: -+ cur.execute(""" -+ SELECT id, resource_value FROM resource_pools -+ WHERE resource_type = 'phone' AND status = 'available' -+ LIMIT %s FOR UPDATE -+ """, (phone_count,)) -+ phones = cur.fetchall() -+ -+ if len(phones) < phone_count: -+ raise ValueError(f"Nicht genügend Telefonnummern verfügbar") -+ -+ for phone_id, phone_value in phones: -+ cur.execute(""" -+ UPDATE resource_pools -+ SET status = 'allocated', -+ allocated_to_license = %s, -+ status_changed_at = CURRENT_TIMESTAMP, -+ status_changed_by = %s -+ WHERE id = %s -+ """, (license_id, session['username'], phone_id)) -+ -+ cur.execute(""" -+ INSERT INTO license_resources (license_id, resource_id, assigned_by) -+ VALUES (%s, %s, %s) -+ """, (license_id, phone_id, session['username'])) -+ -+ cur.execute(""" -+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) -+ VALUES (%s, %s, 'allocated', %s, %s) -+ """, (phone_id, license_id, session['username'], get_client_ip())) -+ -+ allocated['phones'].append(phone_value) -+ -+ # Update license resource counts -+ cur.execute(""" -+ UPDATE licenses -+ SET domain_count = %s, -+ ipv4_count = %s, -+ phone_count = %s -+ WHERE id = %s -+ """, (domain_count, ipv4_count, phone_count, license_id)) -+ -+ conn.commit() -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'success': True, -+ 'allocated': allocated -+ }) -+ -+ except Exception as e: -+ conn.rollback() -+ cur.close() -+ conn.close() -+ return jsonify({ -+ 'success': False, -+ 'error': str(e) -+ }), 400 -+ -+@app.route('/api/resources/check-availability', methods=['GET']) -+@login_required -+def check_resource_availability(): -+ """Prüft verfügbare Ressourcen""" -+ resource_type = request.args.get('type', '') -+ count = request.args.get('count', 10, type=int) -+ show_test = request.args.get('show_test', 'false').lower() == 'true' -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ if resource_type: -+ # Spezifische Ressourcen für einen Typ -+ cur.execute(""" -+ SELECT id, resource_value -+ FROM resource_pools -+ WHERE status = 'available' -+ AND resource_type = %s -+ AND is_test = %s -+ ORDER BY resource_value -+ LIMIT %s -+ """, (resource_type, show_test, count)) -+ -+ resources = [] -+ for row in cur.fetchall(): -+ resources.append({ -+ 'id': row[0], -+ 'value': row[1] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'available': resources, -+ 'type': resource_type, -+ 'count': len(resources) -+ }) -+ else: -+ # Zusammenfassung aller Typen -+ cur.execute(""" -+ SELECT -+ resource_type, -+ COUNT(*) as available -+ FROM resource_pools -+ WHERE status = 'available' -+ AND is_test = %s -+ GROUP BY resource_type -+ """, (show_test,)) -+ -+ availability = {} -+ for row in cur.fetchall(): -+ availability[row[0]] = row[1] -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify(availability) -+ -+@app.route('/api/global-search', methods=['GET']) -+@login_required -+def global_search(): -+ """Global search API endpoint for searching customers and licenses""" -+ query = request.args.get('q', '').strip() -+ -+ if not query or len(query) < 2: -+ return jsonify({'customers': [], 'licenses': []}) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Search pattern with wildcards -+ search_pattern = f'%{query}%' -+ -+ # Search customers -+ cur.execute(""" -+ SELECT id, name, email, company_name -+ FROM customers -+ WHERE (LOWER(name) LIKE LOWER(%s) -+ OR LOWER(email) LIKE LOWER(%s) -+ OR LOWER(company_name) LIKE LOWER(%s)) -+ AND is_test = FALSE -+ ORDER BY name -+ LIMIT 5 -+ """, (search_pattern, search_pattern, search_pattern)) -+ -+ customers = [] -+ for row in cur.fetchall(): -+ customers.append({ -+ 'id': row[0], -+ 'name': row[1], -+ 'email': row[2], -+ 'company_name': row[3] -+ }) -+ -+ # Search licenses -+ cur.execute(""" -+ SELECT l.id, l.license_key, c.name as customer_name -+ FROM licenses l -+ JOIN customers c ON l.customer_id = c.id -+ WHERE LOWER(l.license_key) LIKE LOWER(%s) -+ AND l.is_test = FALSE -+ ORDER BY l.created_at DESC -+ LIMIT 5 -+ """, (search_pattern,)) -+ -+ licenses = [] -+ for row in cur.fetchall(): -+ licenses.append({ -+ 'id': row[0], -+ 'license_key': row[1], -+ 'customer_name': row[2] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ return jsonify({ -+ 'customers': customers, -+ 'licenses': licenses -+ }) -+ -+@app.route('/resources/history/') -+@login_required -+def resource_history(resource_id): -+ """Zeigt die komplette Historie einer Ressource""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Get complete resource info using named columns -+ cur.execute(""" -+ SELECT id, resource_type, resource_value, status, allocated_to_license, -+ status_changed_at, status_changed_by, quarantine_reason, -+ quarantine_until, created_at, notes -+ FROM resource_pools -+ WHERE id = %s -+ """, (resource_id,)) -+ row = cur.fetchone() -+ -+ if not row: -+ flash('Ressource nicht gefunden', 'error') -+ return redirect(url_for('resources')) -+ -+ # Create resource object with named attributes -+ resource = { -+ 'id': row[0], -+ 'resource_type': row[1], -+ 'resource_value': row[2], -+ 'status': row[3], -+ 'allocated_to_license': row[4], -+ 'status_changed_at': row[5], -+ 'status_changed_by': row[6], -+ 'quarantine_reason': row[7], -+ 'quarantine_until': row[8], -+ 'created_at': row[9], -+ 'notes': row[10] -+ } -+ -+ # Get license info if allocated -+ license_info = None -+ if resource['allocated_to_license']: -+ cur.execute("SELECT license_key FROM licenses WHERE id = %s", -+ (resource['allocated_to_license'],)) -+ lic = cur.fetchone() -+ if lic: -+ license_info = {'license_key': lic[0]} -+ -+ # Get history with named columns -+ cur.execute(""" -+ SELECT -+ rh.action, -+ rh.action_by, -+ rh.action_at, -+ rh.details, -+ rh.license_id, -+ rh.ip_address -+ FROM resource_history rh -+ WHERE rh.resource_id = %s -+ ORDER BY rh.action_at DESC -+ """, (resource_id,)) -+ -+ history = [] -+ for row in cur.fetchall(): -+ history.append({ -+ 'action': row[0], -+ 'action_by': row[1], -+ 'action_at': row[2], -+ 'details': row[3], -+ 'license_id': row[4], -+ 'ip_address': row[5] -+ }) -+ -+ cur.close() -+ conn.close() -+ -+ # Convert to object-like for template -+ class ResourceObj: -+ def __init__(self, data): -+ for key, value in data.items(): -+ setattr(self, key, value) -+ -+ resource_obj = ResourceObj(resource) -+ history_objs = [ResourceObj(h) for h in history] -+ -+ return render_template('resource_history.html', -+ resource=resource_obj, -+ license_info=license_info, -+ history=history_objs) -+ -+@app.route('/resources/metrics') -+@login_required -+def resources_metrics(): -+ """Dashboard für Resource Metrics und Reports""" -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ # Overall stats with fallback values -+ cur.execute(""" -+ SELECT -+ COUNT(DISTINCT resource_id) as total_resources, -+ COALESCE(AVG(performance_score), 0) as avg_performance, -+ COALESCE(SUM(cost), 0) as total_cost, -+ COALESCE(SUM(revenue), 0) as total_revenue, -+ COALESCE(SUM(issues_count), 0) as total_issues -+ FROM resource_metrics -+ WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' -+ """) -+ row = cur.fetchone() -+ -+ # Calculate ROI -+ roi = 0 -+ if row[2] > 0: # if total_cost > 0 -+ roi = row[3] / row[2] # revenue / cost -+ -+ stats = { -+ 'total_resources': row[0] or 0, -+ 'avg_performance': row[1] or 0, -+ 'total_cost': row[2] or 0, -+ 'total_revenue': row[3] or 0, -+ 'total_issues': row[4] or 0, -+ 'roi': roi -+ } -+ -+ # Performance by type -+ cur.execute(""" -+ SELECT -+ rp.resource_type, -+ COALESCE(AVG(rm.performance_score), 0) as avg_score, -+ COUNT(DISTINCT rp.id) as resource_count -+ FROM resource_pools rp -+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -+ GROUP BY rp.resource_type -+ ORDER BY rp.resource_type -+ """) -+ performance_by_type = cur.fetchall() -+ -+ # Utilization data -+ cur.execute(""" -+ SELECT -+ resource_type, -+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -+ COUNT(*) as total, -+ ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent -+ FROM resource_pools -+ GROUP BY resource_type -+ """) -+ utilization_rows = cur.fetchall() -+ utilization_data = [ -+ { -+ 'type': row[0].upper(), -+ 'allocated': row[1], -+ 'total': row[2], -+ 'allocated_percent': row[3] -+ } -+ for row in utilization_rows -+ ] -+ -+ # Top performing resources -+ cur.execute(""" -+ SELECT -+ rp.id, -+ rp.resource_type, -+ rp.resource_value, -+ COALESCE(AVG(rm.performance_score), 0) as avg_score, -+ COALESCE(SUM(rm.revenue), 0) as total_revenue, -+ COALESCE(SUM(rm.cost), 1) as total_cost, -+ CASE -+ WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 -+ ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) -+ END as roi -+ FROM resource_pools rp -+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -+ WHERE rp.status != 'quarantine' -+ GROUP BY rp.id, rp.resource_type, rp.resource_value -+ HAVING AVG(rm.performance_score) IS NOT NULL -+ ORDER BY avg_score DESC -+ LIMIT 10 -+ """) -+ top_rows = cur.fetchall() -+ top_performers = [ -+ { -+ 'id': row[0], -+ 'resource_type': row[1], -+ 'resource_value': row[2], -+ 'avg_score': row[3], -+ 'roi': row[6] -+ } -+ for row in top_rows -+ ] -+ -+ # Resources with issues -+ cur.execute(""" -+ SELECT -+ rp.id, -+ rp.resource_type, -+ rp.resource_value, -+ rp.status, -+ COALESCE(SUM(rm.issues_count), 0) as total_issues -+ FROM resource_pools rp -+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id -+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' -+ WHERE rm.issues_count > 0 OR rp.status = 'quarantine' -+ GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status -+ HAVING SUM(rm.issues_count) > 0 -+ ORDER BY total_issues DESC -+ LIMIT 10 -+ """) -+ problem_rows = cur.fetchall() -+ problem_resources = [ -+ { -+ 'id': row[0], -+ 'resource_type': row[1], -+ 'resource_value': row[2], -+ 'status': row[3], -+ 'total_issues': row[4] -+ } -+ for row in problem_rows -+ ] -+ -+ # Daily metrics for trend chart (last 30 days) -+ cur.execute(""" -+ SELECT -+ metric_date, -+ COALESCE(AVG(performance_score), 0) as avg_performance, -+ COALESCE(SUM(issues_count), 0) as total_issues -+ FROM resource_metrics -+ WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' -+ GROUP BY metric_date -+ ORDER BY metric_date -+ """) -+ daily_rows = cur.fetchall() -+ daily_metrics = [ -+ { -+ 'date': row[0].strftime('%d.%m'), -+ 'performance': float(row[1]), -+ 'issues': int(row[2]) -+ } -+ for row in daily_rows -+ ] -+ -+ cur.close() -+ conn.close() -+ -+ return render_template('resource_metrics.html', -+ stats=stats, -+ performance_by_type=performance_by_type, -+ utilization_data=utilization_data, -+ top_performers=top_performers, -+ problem_resources=problem_resources, -+ daily_metrics=daily_metrics) -+ -+@app.route('/resources/report', methods=['GET']) -+@login_required -+def resources_report(): -+ """Generiert Ressourcen-Reports oder zeigt Report-Formular""" -+ # Prüfe ob Download angefordert wurde -+ if request.args.get('download') == 'true': -+ report_type = request.args.get('type', 'usage') -+ format_type = request.args.get('format', 'excel') -+ date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) -+ date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) -+ -+ conn = get_connection() -+ cur = conn.cursor() -+ -+ if report_type == 'usage': -+ # Auslastungsreport -+ query = """ -+ SELECT -+ rp.resource_type, -+ rp.resource_value, -+ rp.status, -+ COUNT(DISTINCT rh.license_id) as unique_licenses, -+ COUNT(rh.id) as total_allocations, -+ MIN(rh.action_at) as first_used, -+ MAX(rh.action_at) as last_used -+ FROM resource_pools rp -+ LEFT JOIN resource_history rh ON rp.id = rh.resource_id -+ AND rh.action = 'allocated' -+ AND rh.action_at BETWEEN %s AND %s -+ GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status -+ ORDER BY rp.resource_type, total_allocations DESC -+ """ -+ cur.execute(query, (date_from, date_to)) -+ columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] -+ -+ elif report_type == 'performance': -+ # Performance-Report -+ query = """ -+ SELECT -+ rp.resource_type, -+ rp.resource_value, -+ AVG(rm.performance_score) as avg_performance, -+ SUM(rm.usage_count) as total_usage, -+ SUM(rm.revenue) as total_revenue, -+ SUM(rm.cost) as total_cost, -+ SUM(rm.revenue - rm.cost) as profit, -+ SUM(rm.issues_count) as total_issues -+ FROM resource_pools rp -+ JOIN resource_metrics rm ON rp.id = rm.resource_id -+ WHERE rm.metric_date BETWEEN %s AND %s -+ GROUP BY rp.id, rp.resource_type, rp.resource_value -+ ORDER BY profit DESC -+ """ -+ cur.execute(query, (date_from, date_to)) -+ columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] -+ -+ elif report_type == 'compliance': -+ # Compliance-Report -+ query = """ -+ SELECT -+ rh.action_at, -+ rh.action, -+ rh.action_by, -+ rp.resource_type, -+ rp.resource_value, -+ l.license_key, -+ c.name as customer_name, -+ rh.ip_address -+ FROM resource_history rh -+ JOIN resource_pools rp ON rh.resource_id = rp.id -+ LEFT JOIN licenses l ON rh.license_id = l.id -+ LEFT JOIN customers c ON l.customer_id = c.id -+ WHERE rh.action_at BETWEEN %s AND %s -+ ORDER BY rh.action_at DESC -+ """ -+ cur.execute(query, (date_from, date_to)) -+ columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] -+ -+ else: # inventory report -+ # Inventar-Report -+ query = """ -+ SELECT -+ resource_type, -+ COUNT(*) FILTER (WHERE status = 'available') as available, -+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated, -+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, -+ COUNT(*) as total -+ FROM resource_pools -+ GROUP BY resource_type -+ ORDER BY resource_type -+ """ -+ cur.execute(query) -+ columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] -+ -+ # Convert to DataFrame -+ data = cur.fetchall() -+ df = pd.DataFrame(data, columns=columns) -+ -+ cur.close() -+ conn.close() -+ -+ # Generate file -+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') -+ filename = f"resource_report_{report_type}_{timestamp}" -+ -+ if format_type == 'excel': -+ output = io.BytesIO() -+ with pd.ExcelWriter(output, engine='openpyxl') as writer: -+ df.to_excel(writer, sheet_name='Report', index=False) -+ -+ # Auto-adjust columns width -+ worksheet = writer.sheets['Report'] -+ for column in worksheet.columns: -+ max_length = 0 -+ column = [cell for cell in column] -+ for cell in column: -+ try: -+ if len(str(cell.value)) > max_length: -+ max_length = len(str(cell.value)) -+ except: -+ pass -+ adjusted_width = (max_length + 2) -+ worksheet.column_dimensions[column[0].column_letter].width = adjusted_width -+ -+ output.seek(0) -+ -+ log_audit('EXPORT', 'resource_report', None, -+ new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, -+ additional_info=f"Resource Report {report_type} exportiert") -+ -+ return send_file(output, -+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -+ as_attachment=True, -+ download_name=f'{filename}.xlsx') -+ -+ else: # CSV -+ output = io.StringIO() -+ df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') -+ output.seek(0) -+ -+ log_audit('EXPORT', 'resource_report', None, -+ new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, -+ additional_info=f"Resource Report {report_type} exportiert") -+ -+ return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), -+ mimetype='text/csv', -+ as_attachment=True, -+ download_name=f'{filename}.csv') -+ -+ # Wenn kein Download, zeige Report-Formular -+ return render_template('resource_report.html', -+ datetime=datetime, -+ timedelta=timedelta, -+ username=session.get('username')) -+ -+if __name__ == "__main__": -+ app.run(host="0.0.0.0", port=5000) -diff --git a/v2_adminpanel/comment_routes.py b/v2_adminpanel/comment_routes.py -deleted file mode 100644 -index 1c5c44d..0000000 ---- a/v2_adminpanel/comment_routes.py -+++ /dev/null -@@ -1,42 +0,0 @@ --#!/usr/bin/env python3 --""" --Script to comment out routes that have been moved to blueprints --""" -- --# Routes that have been moved to auth_routes.py --auth_routes = [ -- ("@app.route(\"/login\"", "def login():", 138, 251), # login route -- ("@app.route(\"/logout\")", "def logout():", 252, 263), # logout route -- ("@app.route(\"/verify-2fa\"", "def verify_2fa():", 264, 342), # verify-2fa route -- ("@app.route(\"/profile\")", "def profile():", 343, 352), # profile route -- ("@app.route(\"/profile/change-password\"", "def change_password():", 353, 390), # change-password route -- ("@app.route(\"/profile/setup-2fa\")", "def setup_2fa():", 391, 410), # setup-2fa route -- ("@app.route(\"/profile/enable-2fa\"", "def enable_2fa():", 411, 448), # enable-2fa route -- ("@app.route(\"/profile/disable-2fa\"", "def disable_2fa():", 449, 475), # disable-2fa route -- ("@app.route(\"/heartbeat\"", "def heartbeat():", 476, 489), # heartbeat route --] -- --# Routes that have been moved to admin_routes.py --admin_routes = [ -- ("@app.route(\"/\")", "def dashboard():", 647, 870), # dashboard route -- ("@app.route(\"/audit\")", "def audit_log():", 2772, 2866), # audit route -- ("@app.route(\"/backups\")", "def backups():", 2866, 2901), # backups route -- ("@app.route(\"/backup/create\"", "def create_backup_route():", 2901, 2919), # backup/create route -- ("@app.route(\"/backup/restore/\"", "def restore_backup_route(backup_id):", 2919, 2938), # backup/restore route -- ("@app.route(\"/backup/download/\")", "def download_backup(backup_id):", 2938, 2970), # backup/download route -- ("@app.route(\"/backup/delete/\"", "def delete_backup(backup_id):", 2970, 3026), # backup/delete route -- ("@app.route(\"/security/blocked-ips\")", "def blocked_ips():", 3026, 3067), # security/blocked-ips route -- ("@app.route(\"/security/unblock-ip\"", "def unblock_ip():", 3067, 3093), # security/unblock-ip route -- ("@app.route(\"/security/clear-attempts\"", "def clear_attempts():", 3093, 3119), # security/clear-attempts route --] -- --print("This script would comment out the following routes:") --print("\nAuth routes:") --for route in auth_routes: -- print(f" - {route[0]} (lines {route[2]}-{route[3]})") -- --print("\nAdmin routes:") --for route in admin_routes: -- print(f" - {route[0]} (lines {route[2]}-{route[3]})") -- --print("\nNote: Manual verification and adjustment of line numbers is recommended before running the actual commenting.") -\ No newline at end of file -diff --git a/v2_adminpanel/init.sql b/v2_adminpanel/init.sql -index fc91a66..5754e24 100644 ---- a/v2_adminpanel/init.sql -+++ b/v2_adminpanel/init.sql -@@ -1,282 +1,282 @@ ---- 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_test 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_test 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), -- session_id TEXT UNIQUE NOT NULL, -- ip_address TEXT, -- user_agent TEXT, -- 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 --); -- ---- 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_test 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_test 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_test') THEN -- ALTER TABLE licenses ADD COLUMN is_test BOOLEAN DEFAULT FALSE; -- -- -- Mark all existing licenses as test data -- UPDATE licenses SET is_test = TRUE; -- -- -- Add index for better performance when filtering test data -- CREATE INDEX idx_licenses_is_test ON licenses(is_test); -- END IF; --END $$; -- ---- Migration: Add is_test 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_test') THEN -- ALTER TABLE customers ADD COLUMN is_test BOOLEAN DEFAULT FALSE; -- -- -- Mark all existing customers as test data -- UPDATE customers SET is_test = TRUE; -- -- -- Add index for better performance -- CREATE INDEX idx_customers_is_test ON customers(is_test); -- END IF; --END $$; -- ---- Migration: Add is_test 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_test') THEN -- ALTER TABLE resource_pools ADD COLUMN is_test BOOLEAN DEFAULT FALSE; -- -- -- Mark all existing resources as test data -- UPDATE resource_pools SET is_test = TRUE; -- -- -- Add index for better performance -- CREATE INDEX idx_resource_pools_is_test ON resource_pools(is_test); -- END IF; --END $$; -+-- 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_test 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_test 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), -+ session_id TEXT UNIQUE NOT NULL, -+ ip_address TEXT, -+ user_agent TEXT, -+ 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 -+); -+ -+-- 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_test 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_test 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_test') THEN -+ ALTER TABLE licenses ADD COLUMN is_test BOOLEAN DEFAULT FALSE; -+ -+ -- Mark all existing licenses as test data -+ UPDATE licenses SET is_test = TRUE; -+ -+ -- Add index for better performance when filtering test data -+ CREATE INDEX idx_licenses_is_test ON licenses(is_test); -+ END IF; -+END $$; -+ -+-- Migration: Add is_test 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_test') THEN -+ ALTER TABLE customers ADD COLUMN is_test BOOLEAN DEFAULT FALSE; -+ -+ -- Mark all existing customers as test data -+ UPDATE customers SET is_test = TRUE; -+ -+ -- Add index for better performance -+ CREATE INDEX idx_customers_is_test ON customers(is_test); -+ END IF; -+END $$; -+ -+-- Migration: Add is_test 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_test') THEN -+ ALTER TABLE resource_pools ADD COLUMN is_test BOOLEAN DEFAULT FALSE; -+ -+ -- Mark all existing resources as test data -+ UPDATE resource_pools SET is_test = TRUE; -+ -+ -- Add index for better performance -+ CREATE INDEX idx_resource_pools_is_test ON resource_pools(is_test); -+ END IF; -+END $$; -diff --git a/v2_adminpanel/requirements.txt b/v2_adminpanel/requirements.txt -index f1fcc95..8588045 100644 ---- a/v2_adminpanel/requirements.txt -+++ b/v2_adminpanel/requirements.txt -@@ -1,14 +1,14 @@ --flask --flask-session --psycopg2-binary --python-dotenv --pyopenssl --pandas --openpyxl --cryptography --apscheduler --requests --python-dateutil --bcrypt --pyotp --qrcode[pil] -+flask -+flask-session -+psycopg2-binary -+python-dotenv -+pyopenssl -+pandas -+openpyxl -+cryptography -+apscheduler -+requests -+python-dateutil -+bcrypt -+pyotp -+qrcode[pil] -diff --git a/v2_adminpanel/templates/create_customer.html b/v2_adminpanel/templates/create_customer.html -index 55518fc..689ad85 100644 ---- a/v2_adminpanel/templates/create_customer.html -+++ b/v2_adminpanel/templates/create_customer.html -@@ -1,71 +1,71 @@ --{% extends "base.html" %} -- --{% block title %}Neuer Kunde{% endblock %} -- --{% block content %} --
--
--

👤 Neuer Kunde anlegen

-- ← Zurück zur Übersicht --
-- --
--
--
--
--
-- -- --
Der Name des Kunden oder der Firma
--
--
-- -- --
Kontakt-E-Mail-Adresse des Kunden
--
--
-- --
-- -- --
-- -- -- --
-- -- Abbrechen --
--
--
--
--
-- -- --{% with messages = get_flashed_messages(with_categories=true) %} -- {% if messages %} --
-- {% for category, message in messages %} -- -- {% endfor %} --
-- {% endif %} --{% endwith %} -+{% extends "base.html" %} -+ -+{% block title %}Neuer Kunde{% endblock %} -+ -+{% block content %} -+
-+
-+

👤 Neuer Kunde anlegen

-+ ← Zurück zur Übersicht -+
-+ -+
-+
-+
-+
-+
-+ -+ -+
Der Name des Kunden oder der Firma
-+
-+
-+ -+ -+
Kontakt-E-Mail-Adresse des Kunden
-+
-+
-+ -+
-+ -+ -+
-+ -+ -+ -+
-+ -+ Abbrechen -+
-+
-+
-+
-+
-+ -+ -+{% with messages = get_flashed_messages(with_categories=true) %} -+ {% if messages %} -+
-+ {% for category, message in messages %} -+ -+ {% endfor %} -+
-+ {% endif %} -+{% endwith %} - {% endblock %} -\ No newline at end of file -diff --git a/v2_adminpanel/templates/index.html b/v2_adminpanel/templates/index.html -index a14329a..2e0810f 100644 ---- a/v2_adminpanel/templates/index.html -+++ b/v2_adminpanel/templates/index.html -@@ -1,533 +1,533 @@ --{% extends "base.html" %} -- --{% block title %}Admin Panel{% endblock %} -- --{% block content %} --
--
--

Neue Lizenz erstellen

-- ← Zurück zur Übersicht --
-- --
--
--
-- -- --
-- -- --
-- --
-- -- --
--
Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)
--
--
-- -- --
--
-- -- --
--
-- -- --
--
-- -- --
--
-- -- --
--
-- -- --
--
--
-- Ressourcen-Zuweisung -- --
--
--
--
--
-- -- -- -- Verfügbar: - -- --
--
-- -- -- -- Verfügbar: - -- --
--
-- -- -- -- Verfügbar: - -- --
--
-- --
--
-- -- --
--
--
-- Gerätelimit --
--
--
--
--
-- -- -- -- Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann. -- --
--
--
--
-- -- --
-- -- --
-- --
-- --
--
--
-- -- --{% with messages = get_flashed_messages(with_categories=true) %} -- {% if messages %} --
-- {% for category, message in messages %} -- -- {% endfor %} --
-- {% endif %} --{% endwith %} -- -- --{% endblock %} -+{% extends "base.html" %} -+ -+{% block title %}Admin Panel{% endblock %} -+ -+{% block content %} -+
-+
-+

Neue Lizenz erstellen

-+ ← Zurück zur Übersicht -+
-+ -+
-+
-+
-+ -+ -+
-+ -+ -+
-+ -+
-+ -+ -+
-+
Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)
-+
-+
-+ -+ -+
-+
-+ -+ -+
-+
-+ -+ -+
-+
-+ -+ -+
-+
-+ -+ -+
-+
-+ -+ -+
-+
-+
-+ Ressourcen-Zuweisung -+ -+
-+
-+
-+
-+
-+ -+ -+ -+ Verfügbar: - -+ -+
-+
-+ -+ -+ -+ Verfügbar: - -+ -+
-+
-+ -+ -+ -+ Verfügbar: - -+ -+
-+
-+ -+
-+
-+ -+ -+
-+
-+
-+ Gerätelimit -+
-+
-+
-+
-+
-+ -+ -+ -+ Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann. -+ -+
-+
-+
-+
-+ -+ -+
-+ -+ -+
-+ -+
-+ -+
-+
-+
-+ -+ -+{% with messages = get_flashed_messages(with_categories=true) %} -+ {% if messages %} -+
-+ {% for category, message in messages %} -+ -+ {% endfor %} -+
-+ {% endif %} -+{% endwith %} -+ -+ -+{% endblock %} -diff --git a/v2_nginx/nginx.conf b/v2_nginx/nginx.conf -index 5830a2f..c3bbf80 100644 ---- a/v2_nginx/nginx.conf -+++ b/v2_nginx/nginx.conf -@@ -1,99 +1,99 @@ --events { -- worker_connections 1024; --} -- --http { -- # Moderne SSL-Einstellungen für maximale Sicherheit -- ssl_protocols TLSv1.2 TLSv1.3; -- ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; -- ssl_prefer_server_ciphers off; -- -- # SSL Session Einstellungen -- ssl_session_timeout 1d; -- ssl_session_cache shared:SSL:10m; -- ssl_session_tickets off; -- -- # OCSP Stapling -- ssl_stapling on; -- ssl_stapling_verify on; -- resolver 8.8.8.8 8.8.4.4 valid=300s; -- resolver_timeout 5s; -- -- # DH parameters für Perfect Forward Secrecy -- ssl_dhparam /etc/nginx/ssl/dhparam.pem; -- -- # Admin Panel -- server { -- listen 80; -- server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com; -- -- # Redirect HTTP to HTTPS -- return 301 https://$server_name$request_uri; -- } -- -- server { -- listen 443 ssl; -- server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com; -- -- # SSL-Zertifikate (echte Zertifikate) -- ssl_certificate /etc/nginx/ssl/fullchain.pem; -- ssl_certificate_key /etc/nginx/ssl/privkey.pem; -- -- # Security Headers -- add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; -- add_header X-Content-Type-Options "nosniff" always; -- add_header X-Frame-Options "SAMEORIGIN" always; -- add_header X-XSS-Protection "1; mode=block" always; -- add_header Referrer-Policy "strict-origin-when-cross-origin" always; -- -- # Proxy-Einstellungen -- location / { -- proxy_pass http://admin-panel:5000; -- proxy_set_header Host $host; -- proxy_set_header X-Real-IP $remote_addr; -- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -- proxy_set_header X-Forwarded-Proto $scheme; -- -- # WebSocket support (falls benötigt) -- proxy_http_version 1.1; -- proxy_set_header Upgrade $http_upgrade; -- proxy_set_header Connection "upgrade"; -- } -- } -- -- # API Server (für später) -- server { -- listen 80; -- server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com; -- -- return 301 https://$server_name$request_uri; -- } -- -- server { -- listen 443 ssl; -- server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com; -- -- ssl_certificate /etc/nginx/ssl/fullchain.pem; -- ssl_certificate_key /etc/nginx/ssl/privkey.pem; -- -- # Security Headers -- add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; -- add_header X-Content-Type-Options "nosniff" always; -- add_header X-Frame-Options "SAMEORIGIN" always; -- add_header X-XSS-Protection "1; mode=block" always; -- add_header Referrer-Policy "strict-origin-when-cross-origin" always; -- -- location / { -- proxy_pass http://license-server:8443; -- proxy_set_header Host $host; -- proxy_set_header X-Real-IP $remote_addr; -- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -- proxy_set_header X-Forwarded-Proto $scheme; -- -- # WebSocket support (falls benötigt) -- proxy_http_version 1.1; -- proxy_set_header Upgrade $http_upgrade; -- proxy_set_header Connection "upgrade"; -- } -- } -+events { -+ worker_connections 1024; -+} -+ -+http { -+ # Moderne SSL-Einstellungen für maximale Sicherheit -+ ssl_protocols TLSv1.2 TLSv1.3; -+ ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; -+ ssl_prefer_server_ciphers off; -+ -+ # SSL Session Einstellungen -+ ssl_session_timeout 1d; -+ ssl_session_cache shared:SSL:10m; -+ ssl_session_tickets off; -+ -+ # OCSP Stapling -+ ssl_stapling on; -+ ssl_stapling_verify on; -+ resolver 8.8.8.8 8.8.4.4 valid=300s; -+ resolver_timeout 5s; -+ -+ # DH parameters für Perfect Forward Secrecy -+ ssl_dhparam /etc/nginx/ssl/dhparam.pem; -+ -+ # Admin Panel -+ server { -+ listen 80; -+ server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com; -+ -+ # Redirect HTTP to HTTPS -+ return 301 https://$server_name$request_uri; -+ } -+ -+ server { -+ listen 443 ssl; -+ server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com; -+ -+ # SSL-Zertifikate (echte Zertifikate) -+ ssl_certificate /etc/nginx/ssl/fullchain.pem; -+ ssl_certificate_key /etc/nginx/ssl/privkey.pem; -+ -+ # Security Headers -+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; -+ add_header X-Content-Type-Options "nosniff" always; -+ add_header X-Frame-Options "SAMEORIGIN" always; -+ add_header X-XSS-Protection "1; mode=block" always; -+ add_header Referrer-Policy "strict-origin-when-cross-origin" always; -+ -+ # Proxy-Einstellungen -+ location / { -+ proxy_pass http://admin-panel:5000; -+ proxy_set_header Host $host; -+ proxy_set_header X-Real-IP $remote_addr; -+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -+ proxy_set_header X-Forwarded-Proto $scheme; -+ -+ # WebSocket support (falls benötigt) -+ proxy_http_version 1.1; -+ proxy_set_header Upgrade $http_upgrade; -+ proxy_set_header Connection "upgrade"; -+ } -+ } -+ -+ # API Server (für später) -+ server { -+ listen 80; -+ server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com; -+ -+ return 301 https://$server_name$request_uri; -+ } -+ -+ server { -+ listen 443 ssl; -+ server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com; -+ -+ ssl_certificate /etc/nginx/ssl/fullchain.pem; -+ ssl_certificate_key /etc/nginx/ssl/privkey.pem; -+ -+ # Security Headers -+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; -+ add_header X-Content-Type-Options "nosniff" always; -+ add_header X-Frame-Options "SAMEORIGIN" always; -+ add_header X-XSS-Protection "1; mode=block" always; -+ add_header Referrer-Policy "strict-origin-when-cross-origin" always; -+ -+ location / { -+ proxy_pass http://license-server:8443; -+ proxy_set_header Host $host; -+ proxy_set_header X-Real-IP $remote_addr; -+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -+ proxy_set_header X-Forwarded-Proto $scheme; -+ -+ # WebSocket support (falls benötigt) -+ proxy_http_version 1.1; -+ proxy_set_header Upgrade $http_upgrade; -+ proxy_set_header Connection "upgrade"; -+ } -+ } - } -\ No newline at end of file diff --git a/backups/refactoring_20250616_223724/git_log.txt b/backups/refactoring_20250616_223724/git_log.txt deleted file mode 100644 index 527415a..0000000 --- a/backups/refactoring_20250616_223724/git_log.txt +++ /dev/null @@ -1,10 +0,0 @@ -4915513 Refactoring - Part 1 -29b302a Refactoring - Part1 -262de28 lizenzserver -ff93520 Zuweisung über Kunden & Lizenzen geht -13e1386 Ressource Sort gefixt -b18fb49 Testressource Checkbox Fix -d65e5d3 Export und Aktion gefixt -df60ce6 Ressourcen bei Kunden&Lizenzen ist richtig -a878d9b Gerätelimit drin -4b66d8b Zurück zur Übersicht Button diff --git a/backups/refactoring_20250616_223724/git_status.txt b/backups/refactoring_20250616_223724/git_status.txt deleted file mode 100644 index 55dd675..0000000 --- a/backups/refactoring_20250616_223724/git_status.txt +++ /dev/null @@ -1,38 +0,0 @@ -On branch main -Your branch is up to date with 'origin/main'. - -Changes not staged for commit: - (use "git add/rm ..." to update what will be committed) - (use "git restore ..." to discard changes in working directory) - modified: .claude/settings.local.json - modified: JOURNAL.md - modified: v2/.env - modified: v2/docker-compose.yaml - modified: v2_adminpanel/Dockerfile - modified: v2_adminpanel/__pycache__/app.cpython-312.pyc - modified: v2_adminpanel/app.py - modified: v2_adminpanel/app.py.backup - modified: v2_adminpanel/app.py.old - deleted: v2_adminpanel/comment_routes.py - modified: v2_adminpanel/init.sql - modified: v2_adminpanel/requirements.txt - modified: v2_adminpanel/templates/create_customer.html - modified: v2_adminpanel/templates/index.html - modified: v2_nginx/nginx.conf - -Untracked files: - (use "git add ..." to include in what will be committed) - backup_before_cleanup.sh - backups/refactoring_20250616_223724/ - refactoring.md - v2_adminpanel/app.py.backup_before_blueprint_migration - v2_adminpanel/routes/api_routes.py - v2_adminpanel/routes/batch_routes.py - v2_adminpanel/routes/customer_routes.py - v2_adminpanel/routes/export_routes.py - v2_adminpanel/routes/license_routes.py - v2_adminpanel/routes/resource_routes.py - v2_adminpanel/routes/session_routes.py - v2_adminpanel/test_blueprint_routes.py - -no changes added to commit (use "git add" and/or "git commit -a") diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/Dockerfile b/backups/refactoring_20250616_223724/v2_adminpanel_backup/Dockerfile deleted file mode 100644 index cee53bf..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -FROM python:3.11-slim - -# Locale für deutsche Sprache und UTF-8 setzen -ENV LANG=de_DE.UTF-8 -ENV LC_ALL=de_DE.UTF-8 -ENV PYTHONIOENCODING=utf-8 - -# Zeitzone auf Europe/Berlin setzen -ENV TZ=Europe/Berlin - -WORKDIR /app - -# System-Dependencies inkl. PostgreSQL-Tools installieren -RUN apt-get update && apt-get install -y \ - locales \ - postgresql-client \ - tzdata \ - && sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \ - && locale-gen \ - && update-locale LANG=de_DE.UTF-8 \ - && ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \ - && echo "Europe/Berlin" > /etc/timezone \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 5000 - -CMD ["python", "app.py"] diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app.cpython-312.pyc b/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 99427c4..0000000 Binary files a/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app_no_duplicates.cpython-312.pyc b/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app_no_duplicates.cpython-312.pyc deleted file mode 100644 index d47d7f3..0000000 Binary files a/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app_no_duplicates.cpython-312.pyc and /dev/null differ diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app_refactored.cpython-312.pyc b/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app_refactored.cpython-312.pyc deleted file mode 100644 index 9c9153c..0000000 Binary files a/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app_refactored.cpython-312.pyc and /dev/null differ diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/config.cpython-312.pyc b/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 09aafeb..0000000 Binary files a/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py deleted file mode 100644 index 4e6204a..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py +++ /dev/null @@ -1,4475 +0,0 @@ -import os -import sys -import time -import json -import logging -import requests -import re -import random -import base64 -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Import and register blueprints -from routes.auth_routes import auth_bp -from routes.admin_routes import admin_bp -from routes.license_routes import license_bp -from routes.customer_routes import customer_bp -from routes.resource_routes import resource_bp -from routes.session_routes import session_bp -from routes.batch_routes import batch_bp -from routes.api_routes import api_bp -from routes.export_routes import export_bp - -# Register blueprints -app.register_blueprint(auth_bp) -app.register_blueprint(admin_bp) -app.register_blueprint(license_bp) -app.register_blueprint(customer_bp) -app.register_blueprint(resource_bp) -app.register_blueprint(session_bp) -app.register_blueprint(batch_bp) -app.register_blueprint(api_bp) -app.register_blueprint(export_bp) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -# @app.route("/login", methods=["GET", "POST"]) -# def login(): - # Timing-Attack Schutz - Start Zeit merken - # start_time = time.time() - - # IP-Adresse ermitteln - # ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - # is_blocked, blocked_until = check_ip_blocked(ip_address) - # if is_blocked: - # time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - # error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - # return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - # attempt_count = get_login_attempts(ip_address) - - # if request.method == "POST": - # username = request.form.get("username") - # password = request.form.get("password") - # captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - # recaptcha_site_key = config.RECAPTCHA_SITE_KEY - # if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - # if not captcha_response: - # Timing-Attack Schutz - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - # return render_template("login.html", - # error="CAPTCHA ERFORDERLICH!", - # show_captcha=True, - # error_type="captcha", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - # if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - # return render_template("login.html", - # error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - # show_captcha=True, - # error_type="captcha", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - # user = get_user_by_username(username) - # login_success = False - # needs_2fa = False - - # if user: - # Database user authentication - # if verify_password(password, user['password_hash']): - # login_success = True - # needs_2fa = user['totp_enabled'] - # else: - # Fallback to environment variables for backward compatibility - # if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - # login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - - # if login_success: - # Erfolgreicher Login - # if needs_2fa: - # Store temporary session for 2FA verification - # session['temp_username'] = username - # session['temp_user_id'] = user['id'] - # session['awaiting_2fa'] = True - # return redirect(url_for('verify_2fa')) - # else: - # Complete login without 2FA - # session.permanent = True # Aktiviert das Timeout - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user['id'] if user else None - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # reset_login_attempts(ip_address) - # log_audit('LOGIN_SUCCESS', 'user', - # additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - # return redirect(url_for('dashboard')) - # else: - # Fehlgeschlagener Login - # error_message = record_failed_attempt(ip_address, username) - # new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - # is_now_blocked, _ = check_ip_blocked(ip_address) - # if is_now_blocked: - # log_audit('LOGIN_BLOCKED', 'security', - # additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - # return render_template("login.html", - # error=error_message, - # show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - # error_type="failed", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - # recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - # return render_template("login.html", - # show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - -# @app.route("/logout") -# def logout(): - # username = session.get('username', 'unknown') - # log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - # session.pop('logged_in', None) - # session.pop('username', None) - # session.pop('user_id', None) - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - # return redirect(url_for('login')) - -# @app.route("/verify-2fa", methods=["GET", "POST"]) -# def verify_2fa(): - # if not session.get('awaiting_2fa'): - # return redirect(url_for('login')) - - # if request.method == "POST": - # token = request.form.get('token', '').replace(' ', '') - # username = session.get('temp_username') - # user_id = session.get('temp_user_id') - - # if not username or not user_id: - # flash('Session expired. Please login again.', 'error') - # return redirect(url_for('login')) - - # user = get_user_by_username(username) - # if not user: - # flash('User not found.', 'error') - # return redirect(url_for('login')) - - # Check if it's a backup code - # if len(token) == 8 and token.isupper(): - # Try backup code - # backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - # if verify_backup_code(token, backup_codes): - # Remove used backup code - # code_hash = hash_backup_code(token) - # backup_codes.remove(code_hash) - - # conn = get_connection() - # cur = conn.cursor() - # cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - # (json.dumps(backup_codes), user_id)) - # conn.commit() - # cur.close() - # conn.close() - - # Complete login - # session.permanent = True - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user_id - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - - # flash('Login successful using backup code. Please generate new backup codes.', 'warning') - # log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - # return redirect(url_for('dashboard')) - # else: - # Try TOTP token - # if verify_totp(user['totp_secret'], token): - # Complete login - # session.permanent = True - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user_id - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - - # log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - # return redirect(url_for('dashboard')) - - # Failed verification - # conn = get_connection() - # cur = conn.cursor() - # cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - # (datetime.now(), user_id)) - # conn.commit() - # cur.close() - # conn.close() - - # flash('Invalid authentication code. Please try again.', 'error') - # log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - # return render_template('verify_2fa.html') - -# @app.route("/profile") -# @login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -# @app.route("/profile/change-password", methods=["POST"]) -# @login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -# @app.route("/profile/setup-2fa") -# @login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -# @app.route("/profile/enable-2fa", methods=["POST"]) -# @login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -# @app.route("/profile/disable-2fa", methods=["POST"]) -# @login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -# @app.route("/heartbeat", methods=['POST']) -# @login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -# @app.route("/api/generate-license-key", methods=['POST']) -# @login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -# @app.route("/api/customers", methods=['GET']) -# @login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -# @app.route("/") -# @login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -# @app.route("/create", methods=["GET", "POST"]) -# @login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -# @app.route("/batch", methods=["GET", "POST"]) -# @login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -# @app.route("/batch/export") -# @login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -# @app.route("/licenses") -# @login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -# @app.route("/license/edit/", methods=["GET", "POST"]) -# @login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -# @app.route("/license/delete/", methods=["POST"]) -# @login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -# @app.route("/customers") -# @login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -# @app.route("/customer/edit/", methods=["GET", "POST"]) -# @login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -# @app.route("/customer/create", methods=["GET", "POST"]) -# @login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -# @app.route("/customer/delete/", methods=["POST"]) -# @login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -# @app.route("/customers-licenses") -# @login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -# @app.route("/api/customer//licenses") -# @login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -# @app.route("/api/customer//quick-stats") -# @login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -# @app.route("/api/license//quick-edit", methods=['POST']) -# @login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -# @app.route("/api/license//resources") -# @login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -# @app.route("/sessions") -# @login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -# @app.route("/session/end/", methods=["POST"]) -# @login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -# @app.route("/export/licenses") -# @login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/audit") -# @login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/customers") -# @login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/sessions") -# @login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/resources") -# @login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/audit") -# @login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -# @app.route("/backups") -# @login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -# @app.route("/backup/create", methods=["POST"]) -# @login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -# @app.route("/backup/restore/", methods=["POST"]) -# @login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -# @app.route("/backup/download/") -# @login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -# @app.route("/backup/delete/", methods=["DELETE"]) -# @login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -# @app.route("/security/blocked-ips") -# @login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -# @app.route("/security/unblock-ip", methods=["POST"]) -# @login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -# @app.route("/security/clear-attempts", methods=["POST"]) -# @login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -# @app.route("/api/license//toggle", methods=["POST"]) -# @login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/licenses/bulk-activate", methods=["POST"]) -# @login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -# @login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/license//devices") -# @login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -# @app.route("/api/license//register-device", methods=["POST"]) -# def register_device(license_id): - # """Registriere ein neues Gerät für eine Lizenz""" - # try: - # data = request.get_json() - # hardware_id = data.get('hardware_id') - # device_name = data.get('device_name', '') - # operating_system = data.get('operating_system', '') - - # if not hardware_id: - # return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - # conn = get_connection() - # cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - # cur.execute(""" - # SELECT device_limit, is_active, valid_until - # FROM licenses - # WHERE id = %s - # """, (license_id,)) - # license_data = cur.fetchone() - - # if not license_data: - # return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - # device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - # if not is_active: - # return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - # if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - # return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - # cur.execute(""" - # SELECT id, is_active FROM device_registrations - # WHERE license_id = %s AND hardware_id = %s - # """, (license_id, hardware_id)) - # existing_device = cur.fetchone() - - # if existing_device: - # device_id, is_device_active = existing_device - # if is_device_active: - # Gerät ist bereits aktiv, update last_seen - # cur.execute(""" - # UPDATE device_registrations - # SET last_seen = CURRENT_TIMESTAMP, - # ip_address = %s, - # user_agent = %s - # WHERE id = %s - # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - # conn.commit() - # return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - # else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - # cur.execute(""" - # SELECT COUNT(*) FROM device_registrations - # WHERE license_id = %s AND is_active = TRUE - # """, (license_id,)) - # active_count = cur.fetchone()[0] - - # if active_count >= device_limit: - # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - # cur.execute(""" - # UPDATE device_registrations - # SET is_active = TRUE, - # last_seen = CURRENT_TIMESTAMP, - # deactivated_at = NULL, - # deactivated_by = NULL, - # ip_address = %s, - # user_agent = %s - # WHERE id = %s - # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - # conn.commit() - # return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - # cur.execute(""" - # SELECT COUNT(*) FROM device_registrations - # WHERE license_id = %s AND is_active = TRUE - # """, (license_id,)) - # active_count = cur.fetchone()[0] - - # if active_count >= device_limit: - # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - # cur.execute(""" - # INSERT INTO device_registrations - # (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - # VALUES (%s, %s, %s, %s, %s, %s) - # RETURNING id - # """, (license_id, hardware_id, device_name, operating_system, - # get_client_ip(), request.headers.get('User-Agent', ''))) - # device_id = cur.fetchone()[0] - - # conn.commit() - - # Audit Log - # log_audit('DEVICE_REGISTER', 'device', device_id, - # new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - # cur.close() - # conn.close() - - # return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - # except Exception as e: - # logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - # return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -# @app.route("/api/license//deactivate-device/", methods=["POST"]) -# @login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -# @app.route("/api/licenses/bulk-delete", methods=["POST"]) -# @login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -# @app.route('/resources') -# @login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -# @app.route('/resources/add', methods=['GET', 'POST']) -# @login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -# @app.route('/resources/quarantine/', methods=['POST']) -# @login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -# @app.route('/resources/release', methods=['POST']) -# @login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -# @app.route('/api/resources/allocate', methods=['POST']) -# @login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -# @app.route('/api/resources/check-availability', methods=['GET']) -# @login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -# @app.route('/api/global-search', methods=['GET']) -# @login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -# @app.route('/resources/history/') -# @login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -# @app.route('/resources/metrics') -# @login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -# @app.route('/resources/report', methods=['GET']) -# @login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup deleted file mode 100644 index 96c85f1..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup +++ /dev/null @@ -1,5032 +0,0 @@ -import os -import psycopg2 -from psycopg2.extras import Json -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from functools import wraps -from dotenv import load_dotenv -import pandas as pd -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -import io -import subprocess -import gzip -from cryptography.fernet import Fernet -from pathlib import Path -import time -from apscheduler.schedulers.background import BackgroundScheduler -import logging -import random -import hashlib -import requests -import secrets -import string -import re -import bcrypt -import pyotp -import qrcode -from io import BytesIO -import base64 -import json -from werkzeug.middleware.proxy_fix import ProxyFix -from openpyxl.utils import get_column_letter - -load_dotenv() - -app = Flask(__name__) -app.config['SECRET_KEY'] = os.urandom(24) -app.config['SESSION_TYPE'] = 'filesystem' -app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8 -app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8' -app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=5) # 5 Minuten Session-Timeout -app.config['SESSION_COOKIE_HTTPONLY'] = True -app.config['SESSION_COOKIE_SECURE'] = False # Wird auf True gesetzt wenn HTTPS (intern läuft HTTP) -app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' -app.config['SESSION_COOKIE_NAME'] = 'admin_session' -# WICHTIG: Session-Cookie soll auch nach 5 Minuten ablaufen -app.config['SESSION_REFRESH_EACH_REQUEST'] = False -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 -) - -# Backup-Konfiguration -BACKUP_DIR = Path("/app/backups") -BACKUP_DIR.mkdir(exist_ok=True) - -# Rate-Limiting Konfiguration -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 - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - - -# Login decorator -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'logged_in' not in session: - return redirect(url_for('login')) - - # Prüfe ob Session abgelaufen ist - 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 - app.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 abgelaufen - Logout - username = session.get('username', 'unbekannt') - app.logger.info(f"Session timeout for user {username} - auto logout") - # Audit-Log für automatischen Logout (vor 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('login')) - - # Aktivität NICHT automatisch aktualisieren - # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) - return f(*args, **kwargs) - return decorated_function - -# DB-Verbindung mit UTF-8 Encoding -def get_connection(): - 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"), - options='-c client_encoding=UTF8' - ) - conn.set_client_encoding('UTF8') - return conn - -# User Authentication Helper Functions -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')) - -def get_user_by_username(username): - """Get user from database by username""" - conn = get_connection() - cur = conn.cursor() - try: - cur.execute(""" - SELECT id, username, password_hash, email, totp_secret, totp_enabled, - backup_codes, last_password_change, failed_2fa_attempts - FROM users WHERE username = %s - """, (username,)) - user = cur.fetchone() - if user: - return { - 'id': user[0], - 'username': user[1], - 'password_hash': user[2], - 'email': user[3], - 'totp_secret': user[4], - 'totp_enabled': user[5], - 'backup_codes': user[6], - 'last_password_change': user[7], - 'failed_2fa_attempts': user[8] - } - return None - finally: - cur.close() - conn.close() - -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 - -# Audit-Log-Funktion -def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): - """Protokolliert Änderungen im Audit-Log""" - conn = get_connection() - cur = conn.cursor() - - try: - username = session.get('username', 'system') - ip_address = get_client_ip() if request else None - user_agent = request.headers.get('User-Agent') if request else None - - # Debug logging - app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") - - # Konvertiere Dictionaries zu JSONB - old_json = Json(old_values) if old_values else None - new_json = Json(new_values) if new_values else None - - cur.execute(""" - INSERT INTO audit_log - (username, action, entity_type, entity_id, old_values, new_values, - ip_address, user_agent, additional_info) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, (username, action, entity_type, entity_id, old_json, new_json, - ip_address, user_agent, additional_info)) - - conn.commit() - except Exception as e: - print(f"Audit log error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -# Verschlüsselungs-Funktionen -def get_or_create_encryption_key(): - """Holt oder erstellt einen Verschlüsselungsschlüssel""" - key_file = BACKUP_DIR / ".backup_key" - - # Versuche Key aus Umgebungsvariable zu lesen - env_key = os.getenv("BACKUP_ENCRYPTION_KEY") - if env_key: - try: - # Validiere den Key - Fernet(env_key.encode()) - return env_key.encode() - except: - pass - - # Wenn kein gültiger Key in ENV, prüfe Datei - if key_file.exists(): - return key_file.read_bytes() - - # Erstelle neuen Key - key = Fernet.generate_key() - key_file.write_bytes(key) - logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt") - return key - -# Backup-Funktionen -def create_backup(backup_type="manual", created_by=None): - """Erstellt ein verschlüsseltes Backup der Datenbank""" - start_time = time.time() - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") - filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" - filepath = BACKUP_DIR / filename - - conn = get_connection() - cur = conn.cursor() - - # Backup-Eintrag erstellen - cur.execute(""" - INSERT INTO backup_history - (filename, filepath, backup_type, status, created_by, is_encrypted) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (filename, str(filepath), backup_type, 'in_progress', - created_by or 'system', True)) - backup_id = cur.fetchone()[0] - conn.commit() - - try: - # PostgreSQL Dump erstellen - dump_command = [ - 'pg_dump', - '-h', os.getenv("POSTGRES_HOST", "postgres"), - '-p', os.getenv("POSTGRES_PORT", "5432"), - '-U', os.getenv("POSTGRES_USER"), - '-d', os.getenv("POSTGRES_DB"), - '--no-password', - '--verbose' - ] - - # PGPASSWORD setzen - env = os.environ.copy() - env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") - - # Dump ausführen - result = subprocess.run(dump_command, capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"pg_dump failed: {result.stderr}") - - dump_data = result.stdout.encode('utf-8') - - # Komprimieren - compressed_data = gzip.compress(dump_data) - - # Verschlüsseln - key = get_or_create_encryption_key() - f = Fernet(key) - encrypted_data = f.encrypt(compressed_data) - - # Speichern - filepath.write_bytes(encrypted_data) - - # Statistiken sammeln - cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") - tables_count = cur.fetchone()[0] - - cur.execute(""" - SELECT SUM(n_live_tup) - FROM pg_stat_user_tables - """) - records_count = cur.fetchone()[0] or 0 - - duration = time.time() - start_time - filesize = filepath.stat().st_size - - # Backup-Eintrag aktualisieren - cur.execute(""" - UPDATE backup_history - SET status = %s, filesize = %s, tables_count = %s, - records_count = %s, duration_seconds = %s - WHERE id = %s - """, ('success', filesize, tables_count, records_count, duration, backup_id)) - - conn.commit() - - # Audit-Log - log_audit('BACKUP', 'database', backup_id, - additional_info=f"Backup erstellt: {filename} ({filesize} bytes)") - - # E-Mail-Benachrichtigung (wenn konfiguriert) - send_backup_notification(True, filename, filesize, duration) - - logging.info(f"Backup erfolgreich erstellt: {filename}") - return True, filename - - except Exception as e: - # Fehler protokollieren - cur.execute(""" - UPDATE backup_history - SET status = %s, error_message = %s, duration_seconds = %s - WHERE id = %s - """, ('failed', str(e), time.time() - start_time, backup_id)) - conn.commit() - - logging.error(f"Backup fehlgeschlagen: {e}") - send_backup_notification(False, filename, error=str(e)) - - return False, str(e) - - finally: - cur.close() - conn.close() - -def restore_backup(backup_id, encryption_key=None): - """Stellt ein Backup wieder her""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Info abrufen - cur.execute(""" - SELECT filename, filepath, is_encrypted - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - raise Exception("Backup nicht gefunden") - - filename, filepath, is_encrypted = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - raise Exception("Backup-Datei nicht gefunden") - - # Datei lesen - encrypted_data = filepath.read_bytes() - - # Entschlüsseln - if is_encrypted: - key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() - try: - f = Fernet(key) - compressed_data = f.decrypt(encrypted_data) - except: - raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?") - else: - compressed_data = encrypted_data - - # Dekomprimieren - dump_data = gzip.decompress(compressed_data) - sql_commands = dump_data.decode('utf-8') - - # Bestehende Verbindungen schließen - cur.close() - conn.close() - - # Datenbank wiederherstellen - restore_command = [ - 'psql', - '-h', os.getenv("POSTGRES_HOST", "postgres"), - '-p', os.getenv("POSTGRES_PORT", "5432"), - '-U', os.getenv("POSTGRES_USER"), - '-d', os.getenv("POSTGRES_DB"), - '--no-password' - ] - - env = os.environ.copy() - env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") - - result = subprocess.run(restore_command, input=sql_commands, - capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}") - - # Audit-Log (neue Verbindung) - log_audit('RESTORE', 'database', backup_id, - additional_info=f"Backup wiederhergestellt: {filename}") - - return True, "Backup erfolgreich wiederhergestellt" - - except Exception as e: - logging.error(f"Wiederherstellung fehlgeschlagen: {e}") - return False, str(e) - -def send_backup_notification(success, filename, filesize=None, duration=None, error=None): - """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)""" - if not os.getenv("EMAIL_ENABLED", "false").lower() == "true": - return - - # E-Mail-Funktion vorbereitet aber deaktiviert - # TODO: Implementieren wenn E-Mail-Server konfiguriert ist - logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}") - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=3, - minute=0, - id='daily_backup', - replace_existing=True -) - -# Rate-Limiting Funktionen -def get_client_ip(): - """Ermittelt die echte IP-Adresse des Clients""" - # Debug logging - app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") - - # Try X-Real-IP first (set by nginx) - if request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - # Then X-Forwarded-For - elif request.headers.get('X-Forwarded-For'): - # X-Forwarded-For can contain multiple IPs, take the first one - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - # Fallback to remote_addr - else: - return request.remote_addr - -def check_ip_blocked(ip_address): - """Prüft ob eine IP-Adresse gesperrt ist""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT blocked_until FROM login_attempts - WHERE ip_address = %s AND blocked_until IS NOT NULL - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - 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): - """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" - conn = get_connection() - cur = conn.cursor() - - # Random Fehlermeldung - error_message = random.choice(FAIL_MESSAGES) - - try: - # Prüfen ob IP bereits existiert - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - - if result: - # Update bestehenden Eintrag - 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) - # E-Mail-Benachrichtigung (wenn aktiviert) - if os.getenv("EMAIL_ENABLED", "false").lower() == "true": - 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: - # Neuen Eintrag erstellen - 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: - print(f"Rate limiting error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - - return error_message - -def reset_login_attempts(ip_address): - """Setzt die Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute(""" - DELETE FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - conn.commit() - except Exception as e: - print(f"Reset attempts error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -def get_login_attempts(ip_address): - """Gibt die Anzahl der Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - return result[0] if result else 0 - -def send_security_alert_email(ip_address, username, attempt_count): - """Sendet eine Sicherheitswarnung per E-Mail""" - 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: E-Mail-Versand implementieren wenn SMTP konfiguriert - logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") - print(f"E-Mail würde gesendet: {subject}") - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = os.getenv('RECAPTCHA_SECRET_KEY') - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - -def generate_license_key(license_type='full'): - """ - Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ - - AF = Account Factory (Produktkennung) - F/T = F für Fullversion, T für Testversion - YYYY = Jahr - MM = Monat - XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen - """ - # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) - chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - - # Datum-Teil - now = datetime.now(ZoneInfo("Europe/Berlin")) - date_part = now.strftime('%Y%m') - type_char = 'F' if license_type == 'full' else 'T' - - # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) - parts = [] - for _ in range(3): - part = ''.join(secrets.choice(chars) for _ in range(4)) - parts.append(part) - - # Key zusammensetzen - key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" - - return key - -def validate_license_key(key): - """ - Validiert das License Key Format - Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ - """ - if not key: - return False - - # Pattern für das neue Format - # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen - pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' - - # Großbuchstaben für Vergleich - return bool(re.match(pattern, key.upper())) - -@app.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') - if attempt_count >= 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, 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, 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 - admin1_user = os.getenv("ADMIN1_USERNAME") - admin1_pass = os.getenv("ADMIN1_PASSWORD") - admin2_user = os.getenv("ADMIN2_USERNAME") - admin2_pass = os.getenv("ADMIN2_PASSWORD") - - if ((username == admin1_user and password == admin1_pass) or - (username == admin2_user and password == admin2_pass)): - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - error_type="failed", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -@app.route("/backups") -@login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -@app.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -@app.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -@app.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup_before_blueprint_migration b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup_before_blueprint_migration deleted file mode 100644 index 0622714..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup_before_blueprint_migration +++ /dev/null @@ -1,4461 +0,0 @@ -import os -import sys -import time -import json -import logging -import requests -import re -import random -import base64 -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Import and register blueprints -from routes.auth_routes import auth_bp -from routes.admin_routes import admin_bp - -# Temporarily comment out blueprints to avoid conflicts -# app.register_blueprint(auth_bp) -# app.register_blueprint(admin_bp) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = config.RECAPTCHA_SITE_KEY - if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - error_type="failed", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -@app.route("/backups") -@login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -@app.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -@app.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -@app.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.old b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.old deleted file mode 100644 index 3849500..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.old +++ /dev/null @@ -1,5021 +0,0 @@ -import os -import time -import json -import logging -import requests -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - get_client_ip, check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - - -# Login decorator -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'logged_in' not in session: - return redirect(url_for('login')) - - # Prüfe ob Session abgelaufen ist - 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 - app.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 abgelaufen - Logout - username = session.get('username', 'unbekannt') - app.logger.info(f"Session timeout for user {username} - auto logout") - # Audit-Log für automatischen Logout (vor 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('login')) - - # Aktivität NICHT automatisch aktualisieren - # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) - return f(*args, **kwargs) - return decorated_function - -# DB-Verbindung mit UTF-8 Encoding -def get_connection(): - 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"), - options='-c client_encoding=UTF8' - ) - conn.set_client_encoding('UTF8') - return conn - -# User Authentication Helper Functions -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')) - -def get_user_by_username(username): - """Get user from database by username""" - conn = get_connection() - cur = conn.cursor() - try: - cur.execute(""" - SELECT id, username, password_hash, email, totp_secret, totp_enabled, - backup_codes, last_password_change, failed_2fa_attempts - FROM users WHERE username = %s - """, (username,)) - user = cur.fetchone() - if user: - return { - 'id': user[0], - 'username': user[1], - 'password_hash': user[2], - 'email': user[3], - 'totp_secret': user[4], - 'totp_enabled': user[5], - 'backup_codes': user[6], - 'last_password_change': user[7], - 'failed_2fa_attempts': user[8] - } - return None - finally: - cur.close() - conn.close() - -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 - -# Audit-Log-Funktion -def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): - """Protokolliert Änderungen im Audit-Log""" - conn = get_connection() - cur = conn.cursor() - - try: - username = session.get('username', 'system') - ip_address = get_client_ip() if request else None - user_agent = request.headers.get('User-Agent') if request else None - - # Debug logging - app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") - - # Konvertiere Dictionaries zu JSONB - old_json = Json(old_values) if old_values else None - new_json = Json(new_values) if new_values else None - - cur.execute(""" - INSERT INTO audit_log - (username, action, entity_type, entity_id, old_values, new_values, - ip_address, user_agent, additional_info) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, (username, action, entity_type, entity_id, old_json, new_json, - ip_address, user_agent, additional_info)) - - conn.commit() - except Exception as e: - print(f"Audit log error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -# Verschlüsselungs-Funktionen -def get_or_create_encryption_key(): - """Holt oder erstellt einen Verschlüsselungsschlüssel""" - key_file = BACKUP_DIR / ".backup_key" - - # Versuche Key aus Umgebungsvariable zu lesen - env_key = os.getenv("BACKUP_ENCRYPTION_KEY") - if env_key: - try: - # Validiere den Key - Fernet(env_key.encode()) - return env_key.encode() - except: - pass - - # Wenn kein gültiger Key in ENV, prüfe Datei - if key_file.exists(): - return key_file.read_bytes() - - # Erstelle neuen Key - key = Fernet.generate_key() - key_file.write_bytes(key) - logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt") - return key - -# Backup-Funktionen -def create_backup(backup_type="manual", created_by=None): - """Erstellt ein verschlüsseltes Backup der Datenbank""" - start_time = time.time() - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") - filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" - filepath = BACKUP_DIR / filename - - conn = get_connection() - cur = conn.cursor() - - # Backup-Eintrag erstellen - cur.execute(""" - INSERT INTO backup_history - (filename, filepath, backup_type, status, created_by, is_encrypted) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (filename, str(filepath), backup_type, 'in_progress', - created_by or 'system', True)) - backup_id = cur.fetchone()[0] - conn.commit() - - try: - # PostgreSQL Dump erstellen - dump_command = [ - 'pg_dump', - '-h', os.getenv("POSTGRES_HOST", "postgres"), - '-p', os.getenv("POSTGRES_PORT", "5432"), - '-U', os.getenv("POSTGRES_USER"), - '-d', os.getenv("POSTGRES_DB"), - '--no-password', - '--verbose' - ] - - # PGPASSWORD setzen - env = os.environ.copy() - env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") - - # Dump ausführen - result = subprocess.run(dump_command, capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"pg_dump failed: {result.stderr}") - - dump_data = result.stdout.encode('utf-8') - - # Komprimieren - compressed_data = gzip.compress(dump_data) - - # Verschlüsseln - key = get_or_create_encryption_key() - f = Fernet(key) - encrypted_data = f.encrypt(compressed_data) - - # Speichern - filepath.write_bytes(encrypted_data) - - # Statistiken sammeln - cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") - tables_count = cur.fetchone()[0] - - cur.execute(""" - SELECT SUM(n_live_tup) - FROM pg_stat_user_tables - """) - records_count = cur.fetchone()[0] or 0 - - duration = time.time() - start_time - filesize = filepath.stat().st_size - - # Backup-Eintrag aktualisieren - cur.execute(""" - UPDATE backup_history - SET status = %s, filesize = %s, tables_count = %s, - records_count = %s, duration_seconds = %s - WHERE id = %s - """, ('success', filesize, tables_count, records_count, duration, backup_id)) - - conn.commit() - - # Audit-Log - log_audit('BACKUP', 'database', backup_id, - additional_info=f"Backup erstellt: {filename} ({filesize} bytes)") - - # E-Mail-Benachrichtigung (wenn konfiguriert) - send_backup_notification(True, filename, filesize, duration) - - logging.info(f"Backup erfolgreich erstellt: {filename}") - return True, filename - - except Exception as e: - # Fehler protokollieren - cur.execute(""" - UPDATE backup_history - SET status = %s, error_message = %s, duration_seconds = %s - WHERE id = %s - """, ('failed', str(e), time.time() - start_time, backup_id)) - conn.commit() - - logging.error(f"Backup fehlgeschlagen: {e}") - send_backup_notification(False, filename, error=str(e)) - - return False, str(e) - - finally: - cur.close() - conn.close() - -def restore_backup(backup_id, encryption_key=None): - """Stellt ein Backup wieder her""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Info abrufen - cur.execute(""" - SELECT filename, filepath, is_encrypted - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - raise Exception("Backup nicht gefunden") - - filename, filepath, is_encrypted = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - raise Exception("Backup-Datei nicht gefunden") - - # Datei lesen - encrypted_data = filepath.read_bytes() - - # Entschlüsseln - if is_encrypted: - key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() - try: - f = Fernet(key) - compressed_data = f.decrypt(encrypted_data) - except: - raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?") - else: - compressed_data = encrypted_data - - # Dekomprimieren - dump_data = gzip.decompress(compressed_data) - sql_commands = dump_data.decode('utf-8') - - # Bestehende Verbindungen schließen - cur.close() - conn.close() - - # Datenbank wiederherstellen - restore_command = [ - 'psql', - '-h', os.getenv("POSTGRES_HOST", "postgres"), - '-p', os.getenv("POSTGRES_PORT", "5432"), - '-U', os.getenv("POSTGRES_USER"), - '-d', os.getenv("POSTGRES_DB"), - '--no-password' - ] - - env = os.environ.copy() - env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") - - result = subprocess.run(restore_command, input=sql_commands, - capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}") - - # Audit-Log (neue Verbindung) - log_audit('RESTORE', 'database', backup_id, - additional_info=f"Backup wiederhergestellt: {filename}") - - return True, "Backup erfolgreich wiederhergestellt" - - except Exception as e: - logging.error(f"Wiederherstellung fehlgeschlagen: {e}") - return False, str(e) - -def send_backup_notification(success, filename, filesize=None, duration=None, error=None): - """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)""" - if not os.getenv("EMAIL_ENABLED", "false").lower() == "true": - return - - # E-Mail-Funktion vorbereitet aber deaktiviert - # TODO: Implementieren wenn E-Mail-Server konfiguriert ist - logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}") - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=3, - minute=0, - id='daily_backup', - replace_existing=True -) - -# Rate-Limiting Funktionen -def get_client_ip(): - """Ermittelt die echte IP-Adresse des Clients""" - # Debug logging - app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") - - # Try X-Real-IP first (set by nginx) - if request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - # Then X-Forwarded-For - elif request.headers.get('X-Forwarded-For'): - # X-Forwarded-For can contain multiple IPs, take the first one - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - # Fallback to remote_addr - else: - return request.remote_addr - -def check_ip_blocked(ip_address): - """Prüft ob eine IP-Adresse gesperrt ist""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT blocked_until FROM login_attempts - WHERE ip_address = %s AND blocked_until IS NOT NULL - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - 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): - """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" - conn = get_connection() - cur = conn.cursor() - - # Random Fehlermeldung - error_message = random.choice(FAIL_MESSAGES) - - try: - # Prüfen ob IP bereits existiert - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - - if result: - # Update bestehenden Eintrag - 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) - # E-Mail-Benachrichtigung (wenn aktiviert) - if os.getenv("EMAIL_ENABLED", "false").lower() == "true": - 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: - # Neuen Eintrag erstellen - 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: - print(f"Rate limiting error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - - return error_message - -def reset_login_attempts(ip_address): - """Setzt die Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute(""" - DELETE FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - conn.commit() - except Exception as e: - print(f"Reset attempts error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -def get_login_attempts(ip_address): - """Gibt die Anzahl der Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - return result[0] if result else 0 - -def send_security_alert_email(ip_address, username, attempt_count): - """Sendet eine Sicherheitswarnung per E-Mail""" - 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: E-Mail-Versand implementieren wenn SMTP konfiguriert - logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") - print(f"E-Mail würde gesendet: {subject}") - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = os.getenv('RECAPTCHA_SECRET_KEY') - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - -def generate_license_key(license_type='full'): - """ - Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ - - AF = Account Factory (Produktkennung) - F/T = F für Fullversion, T für Testversion - YYYY = Jahr - MM = Monat - XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen - """ - # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) - chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - - # Datum-Teil - now = datetime.now(ZoneInfo("Europe/Berlin")) - date_part = now.strftime('%Y%m') - type_char = 'F' if license_type == 'full' else 'T' - - # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) - parts = [] - for _ in range(3): - part = ''.join(secrets.choice(chars) for _ in range(4)) - parts.append(part) - - # Key zusammensetzen - key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" - - return key - -def validate_license_key(key): - """ - Validiert das License Key Format - Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ - """ - if not key: - return False - - # Pattern für das neue Format - # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen - pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' - - # Großbuchstaben für Vergleich - return bool(re.match(pattern, key.upper())) - -@app.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') - if attempt_count >= 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, 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, 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 - admin1_user = os.getenv("ADMIN1_USERNAME") - admin1_pass = os.getenv("ADMIN1_PASSWORD") - admin2_user = os.getenv("ADMIN2_USERNAME") - admin2_pass = os.getenv("ADMIN2_PASSWORD") - - if ((username == admin1_user and password == admin1_pass) or - (username == admin2_user and password == admin2_pass)): - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - error_type="failed", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -@app.route("/backups") -@login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -@app.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -@app.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -@app.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_before_blueprint.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_before_blueprint.py deleted file mode 100644 index f4a9bf2..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_before_blueprint.py +++ /dev/null @@ -1,4460 +0,0 @@ -import os -import sys -import time -import json -import logging -import requests -import re -import random -import base64 -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Import and register blueprints -from routes.auth_routes import auth_bp -from routes.admin_routes import admin_bp - -app.register_blueprint(auth_bp) -app.register_blueprint(admin_bp) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = config.RECAPTCHA_SITE_KEY - if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - error_type="failed", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -@app.route("/backups") -@login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -@app.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -@app.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -@app.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_new.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_new.py deleted file mode 100644 index c391073..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_new.py +++ /dev/null @@ -1,124 +0,0 @@ -import os -import time -import json -import logging -import requests -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - get_client_ip, check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -# Now copy all the route handlers from the original file -# Starting from line 693... \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_with_duplicates.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_with_duplicates.py deleted file mode 100644 index 5b203fe..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_with_duplicates.py +++ /dev/null @@ -1,4462 +0,0 @@ -import os -import sys -import time -import json -import logging -import requests -import re -import random -import base64 -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Import and register blueprints -from routes.auth_routes import auth_bp -from routes.admin_routes import admin_bp - -app.register_blueprint(auth_bp) -app.register_blueprint(admin_bp) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -# MOVED TO AUTH BLUEPRINT -# @app.route("/login", methods=["GET", "POST"]) -# def login(): -# # Timing-Attack Schutz - Start Zeit merken -# start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = config.RECAPTCHA_SITE_KEY - if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - error_type="failed", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -# MOVED TO AUTH BLUEPRINT -# @app.route("/verify-2fa", methods=["GET", "POST"]) -# def verify_2fa(): -# if not session.get('awaiting_2fa'): -# return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -@app.route("/backups") -@login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -@app.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -@app.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -@app.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/__init__.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/__init__.py deleted file mode 100644 index 8ca1225..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Auth module initialization \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/decorators.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/decorators.py deleted file mode 100644 index fda9c05..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/decorators.py +++ /dev/null @@ -1,44 +0,0 @@ -from functools import wraps -from flask import session, redirect, url_for, flash, request -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -import logging -from utils.audit import log_audit - -logger = logging.getLogger(__name__) - - -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'logged_in' not in session: - return redirect(url_for('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('login')) - - # Activity is NOT automatically updated - # Only on explicit user actions (done by heartbeat) - return f(*args, **kwargs) - return decorated_function \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/password.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/password.py deleted file mode 100644 index 785466f..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/password.py +++ /dev/null @@ -1,11 +0,0 @@ -import bcrypt - - -def hash_password(password): - """Hash a password using bcrypt""" - return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - - -def verify_password(password, hashed): - """Verify a password against its hash""" - return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/rate_limiting.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/rate_limiting.py deleted file mode 100644 index 8aca82b..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/rate_limiting.py +++ /dev/null @@ -1,124 +0,0 @@ -import random -import logging -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from flask import request -from db import execute_query, get_db_connection, get_db_cursor -from config import FAIL_MESSAGES, MAX_LOGIN_ATTEMPTS, BLOCK_DURATION_HOURS, EMAIL_ENABLED -from utils.audit import log_audit -from utils.network import get_client_ip - -logger = logging.getLogger(__name__) - - -def check_ip_blocked(ip_address): - """Check if an IP address is blocked""" - result = execute_query( - """ - SELECT blocked_until FROM login_attempts - WHERE ip_address = %s AND blocked_until IS NOT NULL - """, - (ip_address,), - fetch_one=True - ) - - if result and result[0]: - if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): - return True, result[0] - return False, None - - -def record_failed_attempt(ip_address, username): - """Record a failed login attempt""" - # Random error message - error_message = random.choice(FAIL_MESSAGES) - - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - try: - # Check if IP already exists - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - - if result: - # Update existing entry - new_count = result[0] + 1 - blocked_until = None - - if new_count >= MAX_LOGIN_ATTEMPTS: - blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) - # Email notification (if enabled) - if EMAIL_ENABLED: - send_security_alert_email(ip_address, username, new_count) - - cur.execute(""" - UPDATE login_attempts - SET attempt_count = %s, - last_attempt = CURRENT_TIMESTAMP, - blocked_until = %s, - last_username_tried = %s, - last_error_message = %s - WHERE ip_address = %s - """, (new_count, blocked_until, username, error_message, ip_address)) - else: - # Create new entry - cur.execute(""" - INSERT INTO login_attempts - (ip_address, attempt_count, last_username_tried, last_error_message) - VALUES (%s, 1, %s, %s) - """, (ip_address, username, error_message)) - - conn.commit() - - # Audit log - log_audit('LOGIN_FAILED', 'user', - additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") - - except Exception as e: - logger.error(f"Rate limiting error: {e}") - conn.rollback() - - return error_message - - -def reset_login_attempts(ip_address): - """Reset login attempts for an IP""" - execute_query( - "DELETE FROM login_attempts WHERE ip_address = %s", - (ip_address,) - ) - - -def get_login_attempts(ip_address): - """Get the number of login attempts for an IP""" - result = execute_query( - "SELECT attempt_count FROM login_attempts WHERE ip_address = %s", - (ip_address,), - fetch_one=True - ) - return result[0] if result else 0 - - -def send_security_alert_email(ip_address, username, attempt_count): - """Send a security alert email""" - subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" - body = f""" - WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! - - IP-Adresse: {ip_address} - Versuchter Benutzername: {username} - Anzahl Versuche: {attempt_count} - Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} - - Die IP-Adresse wurde für 24 Stunden gesperrt. - - Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. - """ - - # TODO: Email sending implementation when SMTP is configured - logger.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") - print(f"E-Mail würde gesendet: {subject}") \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/two_factor.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/two_factor.py deleted file mode 100644 index 474555d..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/two_factor.py +++ /dev/null @@ -1,57 +0,0 @@ -import pyotp -import qrcode -import random -import string -import hashlib -from io import BytesIO -import base64 - - -def generate_totp_secret(): - """Generate a new TOTP secret""" - return pyotp.random_base32() - - -def generate_qr_code(username, totp_secret): - """Generate QR code for TOTP setup""" - totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( - name=username, - issuer_name='V2 Admin Panel' - ) - - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(totp_uri) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - buf = BytesIO() - img.save(buf, format='PNG') - buf.seek(0) - - return base64.b64encode(buf.getvalue()).decode() - - -def verify_totp(totp_secret, token): - """Verify a TOTP token""" - totp = pyotp.TOTP(totp_secret) - return totp.verify(token, valid_window=1) - - -def generate_backup_codes(count=8): - """Generate backup codes for 2FA recovery""" - codes = [] - for _ in range(count): - code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) - codes.append(code) - return codes - - -def hash_backup_code(code): - """Hash a backup code for storage""" - return hashlib.sha256(code.encode()).hexdigest() - - -def verify_backup_code(code, hashed_codes): - """Verify a backup code against stored hashes""" - code_hash = hashlib.sha256(code.encode()).hexdigest() - return code_hash in hashed_codes \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/config.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/config.py deleted file mode 100644 index 9beeadb..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/config.py +++ /dev/null @@ -1,64 +0,0 @@ -import os -from datetime import timedelta -from pathlib import Path -from dotenv import load_dotenv - -load_dotenv() - -# Flask Configuration -SECRET_KEY = os.urandom(24) -SESSION_TYPE = 'filesystem' -JSON_AS_ASCII = False -JSONIFY_MIMETYPE = 'application/json; charset=utf-8' -PERMANENT_SESSION_LIFETIME = timedelta(minutes=5) -SESSION_COOKIE_HTTPONLY = True -SESSION_COOKIE_SECURE = False # Set to True when HTTPS (internal runs HTTP) -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 -} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/cookies.txt b/backups/refactoring_20250616_223724/v2_adminpanel_backup/cookies.txt deleted file mode 100644 index bc84d1a..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/cookies.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / FALSE 1749329847 admin_session aojqyq4GcSt5oT7NJPeg7UHPoEZUVkn-s1Kr-EAnJWM diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/create_users_table.sql b/backups/refactoring_20250616_223724/v2_adminpanel_backup/create_users_table.sql deleted file mode 100644 index 202f4e8..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/create_users_table.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Create users table if it doesn't exist -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; \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/db.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/db.py deleted file mode 100644 index be8284e..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/db.py +++ /dev/null @@ -1,84 +0,0 @@ -import psycopg2 -from psycopg2.extras import Json, RealDictCursor -from contextlib import contextmanager -from config import DATABASE_CONFIG - - -def get_connection(): - """Create and return a new database connection""" - conn = psycopg2.connect(**DATABASE_CONFIG) - conn.set_client_encoding('UTF8') - return conn - - -@contextmanager -def get_db_connection(): - """Context manager for database connections""" - conn = get_connection() - try: - yield conn - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.close() - - -@contextmanager -def get_db_cursor(conn=None): - """Context manager for database cursors""" - if conn is None: - with get_db_connection() as connection: - cur = connection.cursor() - try: - yield cur - finally: - cur.close() - else: - cur = conn.cursor() - try: - yield cur - finally: - cur.close() - - -@contextmanager -def get_dict_cursor(conn=None): - """Context manager for dictionary cursors""" - if conn is None: - with get_db_connection() as connection: - cur = connection.cursor(cursor_factory=RealDictCursor) - try: - yield cur - finally: - cur.close() - else: - cur = conn.cursor(cursor_factory=RealDictCursor) - try: - yield cur - finally: - cur.close() - - -def execute_query(query, params=None, fetch_one=False, fetch_all=False, as_dict=False): - """Execute a query and optionally fetch results""" - with get_db_connection() as conn: - cursor_func = get_dict_cursor if as_dict else get_db_cursor - with cursor_func(conn) as cur: - cur.execute(query, params) - - if fetch_one: - return cur.fetchone() - elif fetch_all: - return cur.fetchall() - else: - return cur.rowcount - - -def execute_many(query, params_list): - """Execute a query multiple times with different parameters""" - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.executemany(query, params_list) - return cur.rowcount \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/fix_license_keys.sql b/backups/refactoring_20250616_223724/v2_adminpanel_backup/fix_license_keys.sql deleted file mode 100644 index da6c431..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/fix_license_keys.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Fix für die fehlerhafte Migration - entfernt doppelte Bindestriche -UPDATE licenses -SET license_key = REPLACE(license_key, 'AF--', 'AF-') -WHERE license_key LIKE 'AF--%'; - -UPDATE licenses -SET license_key = REPLACE(license_key, '6--', '6-') -WHERE license_key LIKE '%6--%'; - --- Zeige die korrigierten Keys -SELECT id, license_key, license_type -FROM licenses -ORDER BY id; \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/init.sql b/backups/refactoring_20250616_223724/v2_adminpanel_backup/init.sql deleted file mode 100644 index fc91a66..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/init.sql +++ /dev/null @@ -1,282 +0,0 @@ --- UTF-8 Encoding für deutsche Sonderzeichen sicherstellen -SET client_encoding = 'UTF8'; - --- Zeitzone auf Europe/Berlin setzen -SET timezone = 'Europe/Berlin'; - -CREATE TABLE IF NOT EXISTS customers ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - email TEXT, - is_test 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_test 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), - session_id TEXT UNIQUE NOT NULL, - ip_address TEXT, - user_agent TEXT, - 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 -); - --- 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_test 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_test 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_test') THEN - ALTER TABLE licenses ADD COLUMN is_test BOOLEAN DEFAULT FALSE; - - -- Mark all existing licenses as test data - UPDATE licenses SET is_test = TRUE; - - -- Add index for better performance when filtering test data - CREATE INDEX idx_licenses_is_test ON licenses(is_test); - END IF; -END $$; - --- Migration: Add is_test 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_test') THEN - ALTER TABLE customers ADD COLUMN is_test BOOLEAN DEFAULT FALSE; - - -- Mark all existing customers as test data - UPDATE customers SET is_test = TRUE; - - -- Add index for better performance - CREATE INDEX idx_customers_is_test ON customers(is_test); - END IF; -END $$; - --- Migration: Add is_test 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_test') THEN - ALTER TABLE resource_pools ADD COLUMN is_test BOOLEAN DEFAULT FALSE; - - -- Mark all existing resources as test data - UPDATE resource_pools SET is_test = TRUE; - - -- Add index for better performance - CREATE INDEX idx_resource_pools_is_test ON resource_pools(is_test); - END IF; -END $$; diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/mark_resources_as_test.sql b/backups/refactoring_20250616_223724/v2_adminpanel_backup/mark_resources_as_test.sql deleted file mode 100644 index 2377e7a..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/mark_resources_as_test.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Markiere alle existierenden Ressourcen als Testdaten -UPDATE resource_pools SET is_test = TRUE WHERE is_test = FALSE OR is_test IS NULL; - --- Zeige Anzahl der aktualisierten Ressourcen -SELECT COUNT(*) as updated_resources FROM resource_pools WHERE is_test = TRUE; \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_device_limit.sql b/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_device_limit.sql deleted file mode 100644 index d62291f..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_device_limit.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Migration: Setze device_limit für bestehende Test-Lizenzen auf 3 --- Dieses Script wird nur einmal ausgeführt, um bestehende Lizenzen zu aktualisieren - --- Setze device_limit = 3 für alle bestehenden Lizenzen, die noch keinen Wert haben -UPDATE licenses -SET device_limit = 3 -WHERE device_limit IS NULL; - --- Bestätige die Änderung -SELECT COUNT(*) as updated_licenses, - COUNT(CASE WHEN is_test = TRUE THEN 1 END) as test_licenses_updated -FROM licenses -WHERE device_limit = 3; \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_license_keys.sql b/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_license_keys.sql deleted file mode 100644 index 6ff91e6..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_license_keys.sql +++ /dev/null @@ -1,54 +0,0 @@ --- Migration der Lizenzschlüssel vom alten Format zum neuen Format --- Alt: AF-YYYYMMFT-XXXX-YYYY-ZZZZ --- Neu: AF-F-YYYYMM-XXXX-YYYY-ZZZZ - --- Backup der aktuellen Schlüssel erstellen (für Sicherheit) -CREATE TEMP TABLE license_backup AS -SELECT id, license_key FROM licenses; - --- Update für Fullversion Keys (F) -UPDATE licenses -SET license_key = - CONCAT( - SUBSTRING(license_key, 1, 3), -- 'AF-' - '-F-', - SUBSTRING(license_key, 4, 6), -- 'YYYYMM' - '-', - SUBSTRING(license_key, 11) -- Rest des Keys - ) -WHERE license_key LIKE 'AF-%F-%' - AND license_type = 'full' - AND license_key NOT LIKE 'AF-F-%'; -- Nicht bereits migriert - --- Update für Testversion Keys (T) -UPDATE licenses -SET license_key = - CONCAT( - SUBSTRING(license_key, 1, 3), -- 'AF-' - '-T-', - SUBSTRING(license_key, 4, 6), -- 'YYYYMM' - '-', - SUBSTRING(license_key, 11) -- Rest des Keys - ) -WHERE license_key LIKE 'AF-%T-%' - AND license_type = 'test' - AND license_key NOT LIKE 'AF-T-%'; -- Nicht bereits migriert - --- Zeige die Änderungen -SELECT - b.license_key as old_key, - l.license_key as new_key, - l.license_type -FROM licenses l -JOIN license_backup b ON l.id = b.id -WHERE b.license_key != l.license_key -ORDER BY l.id; - --- Anzahl der migrierten Keys -SELECT - COUNT(*) as total_migrated, - SUM(CASE WHEN license_type = 'full' THEN 1 ELSE 0 END) as full_licenses, - SUM(CASE WHEN license_type = 'test' THEN 1 ELSE 0 END) as test_licenses -FROM licenses l -JOIN license_backup b ON l.id = b.id -WHERE b.license_key != l.license_key; \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_users.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_users.py deleted file mode 100644 index 106833d..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_users.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -""" -Migration script to create initial users in the database from environment variables -Run this once after creating the users table -""" - -import os -import psycopg2 -import bcrypt -from dotenv import load_dotenv -from datetime import datetime - -load_dotenv() - -def get_connection(): - return 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"), - options='-c client_encoding=UTF8' - ) - -def hash_password(password): - return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - -def migrate_users(): - conn = get_connection() - cur = conn.cursor() - - try: - # Check if users already exist - cur.execute("SELECT COUNT(*) FROM users") - user_count = cur.fetchone()[0] - - if user_count > 0: - print(f"Users table already contains {user_count} users. Skipping migration.") - return - - # Get admin users from environment - admin1_user = os.getenv("ADMIN1_USERNAME") - admin1_pass = os.getenv("ADMIN1_PASSWORD") - admin2_user = os.getenv("ADMIN2_USERNAME") - admin2_pass = os.getenv("ADMIN2_PASSWORD") - - if not all([admin1_user, admin1_pass, admin2_user, admin2_pass]): - print("ERROR: Admin credentials not found in environment variables!") - return - - # Insert admin users - users = [ - (admin1_user, hash_password(admin1_pass), f"{admin1_user}@v2-admin.local"), - (admin2_user, hash_password(admin2_pass), f"{admin2_user}@v2-admin.local") - ] - - for username, password_hash, email in users: - cur.execute(""" - INSERT INTO users (username, password_hash, email, totp_enabled, created_at) - VALUES (%s, %s, %s, %s, %s) - """, (username, password_hash, email, False, datetime.now())) - print(f"Created user: {username}") - - conn.commit() - print("\nMigration completed successfully!") - print("Users can now log in with their existing credentials.") - print("They can enable 2FA from their profile page.") - - except Exception as e: - conn.rollback() - print(f"ERROR during migration: {e}") - finally: - cur.close() - conn.close() - -if __name__ == "__main__": - print("Starting user migration...") - migrate_users() \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/models.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/models.py deleted file mode 100644 index 4c3bb1c..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/models.py +++ /dev/null @@ -1,29 +0,0 @@ -# Temporary models file - will be expanded in Phase 3 -from db import execute_query - - -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 \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/remove_duplicate_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/remove_duplicate_routes.py deleted file mode 100644 index 648aab0..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/remove_duplicate_routes.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -""" -Remove duplicate routes that have been moved to blueprints -""" - -import re - -# Read the current app.py -with open('app.py', 'r') as f: - content = f.read() - -# List of function names that have been moved to blueprints -moved_functions = [ - # Auth routes - 'login', - 'logout', - 'verify_2fa', - 'profile', - 'change_password', - 'setup_2fa', - 'enable_2fa', - 'disable_2fa', - 'heartbeat', - # Admin routes - 'dashboard', - 'audit_log', - 'backups', - 'create_backup_route', - 'restore_backup_route', - 'download_backup', - 'delete_backup', - 'blocked_ips', - 'unblock_ip', - 'clear_attempts' -] - -# Create a pattern to match route decorators and their functions -for func_name in moved_functions: - # Pattern to match from @app.route to the end of the function - pattern = rf'@app\.route\([^)]+\)\s*(?:@login_required\s*)?def {func_name}\([^)]*\):.*?(?=\n@app\.route|\n@[a-zA-Z]|\nif __name__|$)' - - # Replace with a comment - replacement = f'# Function {func_name} moved to blueprint' - - content = re.sub(pattern, replacement, content, flags=re.DOTALL) - -# Write the modified content -with open('app_no_duplicates.py', 'w') as f: - f.write(content) - -print("Created app_no_duplicates.py with duplicate routes removed") -print("Please review the file before using it") \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/requirements.txt b/backups/refactoring_20250616_223724/v2_adminpanel_backup/requirements.txt deleted file mode 100644 index f1fcc95..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/requirements.txt +++ /dev/null @@ -1,14 +0,0 @@ -flask -flask-session -psycopg2-binary -python-dotenv -pyopenssl -pandas -openpyxl -cryptography -apscheduler -requests -python-dateutil -bcrypt -pyotp -qrcode[pil] diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/__init__.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/__init__.py deleted file mode 100644 index 4f9ede3..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Routes module initialization -# This module contains all Flask blueprints organized by functionality \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/admin_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/admin_routes.py deleted file mode 100644 index cf3528f..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/admin_routes.py +++ /dev/null @@ -1,540 +0,0 @@ -import os -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file, jsonify - -import config -from auth.decorators import login_required -from utils.audit import log_audit -from utils.backup import create_backup, restore_backup -from utils.network import get_client_ip -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from utils.export import create_excel_export, prepare_audit_export_data - -# Create Blueprint -admin_bp = Blueprint('admin', __name__) - - -@admin_bp.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Statistiken - # Anzahl aktiver Lizenzen - cur.execute("SELECT COUNT(*) FROM licenses WHERE active = true") - active_licenses = cur.fetchone()[0] - - # Anzahl Kunden - cur.execute("SELECT COUNT(*) FROM customers") - total_customers = cur.fetchone()[0] - - # Anzahl aktiver Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE active = true") - active_sessions = cur.fetchone()[0] - - # Top 10 Lizenzen nach Nutzung (letzte 30 Tage) - cur.execute(""" - SELECT - l.license_key, - c.name as customer_name, - COUNT(DISTINCT s.id) as session_count, - COUNT(DISTINCT s.username) as unique_users, - MAX(s.last_activity) as last_activity - FROM licenses l - LEFT JOIN customers c ON l.customer_id = c.id - LEFT JOIN sessions s ON l.license_key = s.license_key - AND s.login_time >= CURRENT_TIMESTAMP - INTERVAL '30 days' - GROUP BY l.license_key, c.name - ORDER BY session_count DESC - LIMIT 10 - """) - top_licenses = cur.fetchall() - - # Letzte 10 Aktivitäten aus dem Audit Log - cur.execute(""" - SELECT - id, - timestamp AT TIME ZONE 'Europe/Berlin' as timestamp, - username, - action, - entity_type, - entity_id, - additional_info - FROM audit_log - ORDER BY timestamp DESC - LIMIT 10 - """) - recent_activities = cur.fetchall() - - # Lizenztyp-Verteilung - cur.execute(""" - SELECT - CASE - WHEN is_test_license THEN 'Test' - ELSE 'Full' - END as license_type, - COUNT(*) as count - FROM licenses - GROUP BY is_test_license - """) - license_distribution = cur.fetchall() - - # Sessions nach Stunden (letzte 24h) - cur.execute(""" - WITH hours AS ( - SELECT generate_series( - CURRENT_TIMESTAMP - INTERVAL '23 hours', - CURRENT_TIMESTAMP, - INTERVAL '1 hour' - ) AS hour - ) - SELECT - TO_CHAR(hours.hour AT TIME ZONE 'Europe/Berlin', 'HH24:00') as hour_label, - COUNT(DISTINCT s.id) as session_count - FROM hours - LEFT JOIN sessions s ON - s.login_time >= hours.hour AND - s.login_time < hours.hour + INTERVAL '1 hour' - GROUP BY hours.hour - ORDER BY hours.hour - """) - hourly_sessions = cur.fetchall() - - # System-Status - cur.execute("SELECT pg_database_size(current_database())") - db_size = cur.fetchone()[0] - - # Letzte Backup-Info - cur.execute(""" - SELECT filename, created_at, filesize, status - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Resource Statistiken - cur.execute(""" - SELECT - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'in_use') as in_use, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resources - """) - resource_stats = cur.fetchone() - - return render_template('dashboard.html', - active_licenses=active_licenses, - total_customers=total_customers, - active_sessions=active_sessions, - top_licenses=top_licenses, - recent_activities=recent_activities, - license_distribution=license_distribution, - hourly_sessions=hourly_sessions, - db_size=db_size, - last_backup=last_backup, - resource_stats=resource_stats, - username=session.get('username')) - - finally: - cur.close() - conn.close() - - -@admin_bp.route("/audit") -@login_required -def audit_log(): - page = request.args.get('page', 1, type=int) - per_page = 50 - search = request.args.get('search', '') - action_filter = request.args.get('action', '') - entity_filter = request.args.get('entity', '') - - conn = get_connection() - cur = conn.cursor() - - try: - # Base query - query = """ - SELECT - id, - timestamp AT TIME ZONE 'Europe/Berlin' as timestamp, - username, - action, - entity_type, - entity_id, - old_values::text, - new_values::text, - ip_address, - user_agent, - additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - # Suchfilter - if search: - query += """ AND ( - username ILIKE %s OR - action ILIKE %s OR - entity_type ILIKE %s OR - additional_info ILIKE %s OR - ip_address ILIKE %s - )""" - search_param = f"%{search}%" - params.extend([search_param] * 5) - - # Action Filter - if action_filter: - query += " AND action = %s" - params.append(action_filter) - - # Entity Filter - if entity_filter: - query += " AND entity_type = %s" - params.append(entity_filter) - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as filtered" - cur.execute(count_query, params) - total_count = cur.fetchone()[0] - - # Add pagination - query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s" - params.extend([per_page, (page - 1) * per_page]) - - cur.execute(query, params) - logs = cur.fetchall() - - # Get unique actions and entities for filters - cur.execute("SELECT DISTINCT action FROM audit_log ORDER BY action") - actions = [row[0] for row in cur.fetchall()] - - cur.execute("SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type") - entities = [row[0] for row in cur.fetchall()] - - # Pagination info - total_pages = (total_count + per_page - 1) // per_page - - # Convert to dictionaries for easier template access - audit_logs = [] - for log in logs: - audit_logs.append({ - 'id': log[0], - 'timestamp': log[1], - 'username': log[2], - 'action': log[3], - 'entity_type': log[4], - 'entity_id': log[5], - 'old_values': log[6], - 'new_values': log[7], - 'ip_address': log[8], - 'user_agent': log[9], - 'additional_info': log[10] - }) - - return render_template('audit_log.html', - logs=audit_logs, - page=page, - total_pages=total_pages, - total_count=total_count, - search=search, - action_filter=action_filter, - entity_filter=entity_filter, - actions=actions, - entities=entities, - username=session.get('username')) - - finally: - cur.close() - conn.close() - - -@admin_bp.route("/backups") -@login_required -def backups(): - conn = get_connection() - cur = conn.cursor() - - try: - # Hole alle Backups - cur.execute(""" - SELECT - id, - filename, - created_at AT TIME ZONE 'Europe/Berlin' as created_at, - filesize, - backup_type, - status, - created_by, - duration_seconds, - tables_count, - records_count, - error_message, - is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - # Prüfe ob Dateien noch existieren - backups_with_status = [] - for backup in backups: - backup_dict = { - 'id': backup[0], - 'filename': backup[1], - 'created_at': backup[2], - 'filesize': backup[3], - 'backup_type': backup[4], - 'status': backup[5], - 'created_by': backup[6], - 'duration_seconds': backup[7], - 'tables_count': backup[8], - 'records_count': backup[9], - 'error_message': backup[10], - 'is_encrypted': backup[11], - 'file_exists': False - } - - # Prüfe ob Datei existiert - if backup[1]: # filename - filepath = config.BACKUP_DIR / backup[1] - backup_dict['file_exists'] = filepath.exists() - - backups_with_status.append(backup_dict) - - return render_template('backups.html', - backups=backups_with_status, - username=session.get('username')) - - finally: - cur.close() - conn.close() - - -@admin_bp.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Manuelles Backup erstellen""" - success, result = create_backup(backup_type="manual", created_by=session.get('username')) - - if success: - flash(f'Backup erfolgreich erstellt: {result}', 'success') - else: - flash(f'Backup fehlgeschlagen: {result}', 'error') - - return redirect(url_for('admin.backups')) - - -@admin_bp.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Backup wiederherstellen""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - flash(message, 'success') - else: - flash(f'Wiederherstellung fehlgeschlagen: {message}', 'error') - - return redirect(url_for('admin.backups')) - - -@admin_bp.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Backup herunterladen""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Backup-Info - cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,)) - result = cur.fetchone() - - if not result: - flash('Backup nicht gefunden', 'error') - return redirect(url_for('admin.backups')) - - filename, filepath = result - filepath = Path(filepath) - - if not filepath.exists(): - flash('Backup-Datei nicht gefunden', 'error') - return redirect(url_for('admin.backups')) - - # Audit-Log - log_audit('BACKUP_DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - - finally: - cur.close() - conn.close() - - -@admin_bp.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Backup löschen""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Backup-Info - cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,)) - result = cur.fetchone() - - if not result: - return jsonify({'success': False, 'message': 'Backup nicht gefunden'}), 404 - - filename, filepath = result - filepath = Path(filepath) - - # Lösche Datei wenn vorhanden - if filepath.exists(): - try: - filepath.unlink() - except Exception as e: - return jsonify({'success': False, 'message': f'Fehler beim Löschen der Datei: {str(e)}'}), 500 - - # Lösche Datenbank-Eintrag - cur.execute("DELETE FROM backup_history WHERE id = %s", (backup_id,)) - conn.commit() - - # Audit-Log - log_audit('BACKUP_DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({'success': True, 'message': 'Backup erfolgreich gelöscht'}) - - except Exception as e: - conn.rollback() - return jsonify({'success': False, 'message': str(e)}), 500 - - finally: - cur.close() - conn.close() - - -@admin_bp.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt gesperrte IP-Adressen""" - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute(""" - SELECT - ip_address, - attempt_count, - last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt, - blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - ORDER BY blocked_until DESC - """) - blocked = cur.fetchall() - - # Alle Login-Versuche (auch nicht gesperrte) - cur.execute(""" - SELECT - ip_address, - attempt_count, - last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt, - blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - ORDER BY last_attempt DESC - LIMIT 100 - """) - all_attempts = cur.fetchall() - - return render_template('blocked_ips.html', - blocked_ips=blocked, - all_attempts=all_attempts, - username=session.get('username')) - - finally: - cur.close() - conn.close() - - -@admin_bp.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if not ip_address: - flash('Keine IP-Adresse angegeben', 'error') - return redirect(url_for('admin.blocked_ips')) - - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - if cur.rowcount > 0: - conn.commit() - flash(f'IP-Adresse {ip_address} wurde entsperrt', 'success') - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP-Adresse entsperrt: {ip_address}") - else: - flash(f'IP-Adresse {ip_address} nicht gefunden', 'warning') - - except Exception as e: - conn.rollback() - flash(f'Fehler beim Entsperren: {str(e)}', 'error') - - finally: - cur.close() - conn.close() - - return redirect(url_for('admin.blocked_ips')) - - -@admin_bp.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche""" - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute("DELETE FROM login_attempts") - count = cur.rowcount - conn.commit() - - flash(f'{count} Login-Versuche wurden gelöscht', 'success') - log_audit('CLEAR_LOGIN_ATTEMPTS', 'security', - additional_info=f"{count} Login-Versuche gelöscht") - - except Exception as e: - conn.rollback() - flash(f'Fehler beim Löschen: {str(e)}', 'error') - - finally: - cur.close() - conn.close() - - return redirect(url_for('admin.blocked_ips')) \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/api_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/api_routes.py deleted file mode 100644 index 0964a90..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/api_routes.py +++ /dev/null @@ -1,906 +0,0 @@ -import logging -from datetime import datetime -from zoneinfo import ZoneInfo -from flask import Blueprint, request, jsonify, session - -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 generate_license_key -from db import get_connection, get_db_connection, get_db_cursor -from models import get_license_by_id - -# Create Blueprint -api_bp = Blueprint('api', __name__, url_prefix='/api') - - -@api_bp.route("/license//toggle", methods=["POST"]) -@login_required -def toggle_license(license_id): - """Toggle license active status""" - conn = get_connection() - cur = conn.cursor() - - try: - # Get current status - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - new_status = not license_data['active'] - - # Update status - cur.execute("UPDATE licenses SET active = %s WHERE id = %s", (new_status, license_id)) - conn.commit() - - # Log change - log_audit('TOGGLE', 'license', license_id, - old_values={'active': license_data['active']}, - new_values={'active': new_status}) - - return jsonify({'success': True, 'active': new_status}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Umschalten der Lizenz: {str(e)}") - return jsonify({'error': 'Fehler beim Umschalten der Lizenz'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Aktiviere mehrere Lizenzen gleichzeitig""" - data = request.get_json() - license_ids = data.get('license_ids', []) - - if not license_ids: - return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Update all selected licenses - cur.execute(""" - UPDATE licenses - SET active = true - WHERE id = ANY(%s) AND active = false - RETURNING id - """, (license_ids,)) - - updated_ids = [row[0] for row in cur.fetchall()] - conn.commit() - - # Log changes - for license_id in updated_ids: - log_audit('BULK_ACTIVATE', 'license', license_id, - new_values={'active': True}) - - return jsonify({ - 'success': True, - 'updated_count': len(updated_ids) - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Bulk-Aktivieren: {str(e)}") - return jsonify({'error': 'Fehler beim Aktivieren der Lizenzen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deaktiviere mehrere Lizenzen gleichzeitig""" - data = request.get_json() - license_ids = data.get('license_ids', []) - - if not license_ids: - return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Update all selected licenses - cur.execute(""" - UPDATE licenses - SET active = false - WHERE id = ANY(%s) AND active = true - RETURNING id - """, (license_ids,)) - - updated_ids = [row[0] for row in cur.fetchall()] - conn.commit() - - # Log changes - for license_id in updated_ids: - log_audit('BULK_DEACTIVATE', 'license', license_id, - new_values={'active': False}) - - return jsonify({ - 'success': True, - 'updated_count': len(updated_ids) - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Bulk-Deaktivieren: {str(e)}") - return jsonify({'error': 'Fehler beim Deaktivieren der Lizenzen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle Geräte einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Lizenz-Info - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Hole registrierte Geräte - cur.execute(""" - SELECT - dr.id, - dr.device_id, - dr.device_name, - dr.device_type, - dr.registration_date, - dr.last_seen, - dr.is_active, - (SELECT COUNT(*) FROM sessions s - WHERE s.license_key = dr.license_key - AND s.device_id = dr.device_id - AND s.active = true) as active_sessions - FROM device_registrations dr - WHERE dr.license_key = %s - ORDER BY dr.registration_date DESC - """, (license_data['license_key'],)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'device_id': row[1], - 'device_name': row[2], - 'device_type': row[3], - 'registration_date': row[4].isoformat() if row[4] else None, - 'last_seen': row[5].isoformat() if row[5] else None, - 'is_active': row[6], - 'active_sessions': row[7] - }) - - return jsonify({ - 'license_key': license_data['license_key'], - 'device_limit': license_data['device_limit'], - 'devices': devices, - 'device_count': len(devices) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'error': 'Fehler beim Abrufen der Geräte'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//register-device", methods=["POST"]) -@login_required -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - data = request.get_json() - - device_id = data.get('device_id') - device_name = data.get('device_name') - device_type = data.get('device_type', 'unknown') - - if not device_id or not device_name: - return jsonify({'error': 'Geräte-ID und Name erforderlich'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Lizenz-Info - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_key = %s AND is_active = true - """, (license_data['license_key'],)) - - active_device_count = cur.fetchone()[0] - - if active_device_count >= license_data['device_limit']: - return jsonify({'error': 'Gerätelimit erreicht'}), 400 - - # Prüfe ob Gerät bereits registriert - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_key = %s AND device_id = %s - """, (license_data['license_key'], device_id)) - - existing = cur.fetchone() - - if existing: - if existing[1]: # is_active - return jsonify({'error': 'Gerät bereits registriert'}), 400 - else: - # Reaktiviere Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = true, last_seen = CURRENT_TIMESTAMP - WHERE id = %s - """, (existing[0],)) - else: - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_key, device_id, device_name, device_type, is_active) - VALUES (%s, %s, %s, %s, true) - """, (license_data['license_key'], device_id, device_name, device_type)) - - conn.commit() - - # Audit-Log - log_audit('DEVICE_REGISTER', 'license', license_id, - additional_info=f"Gerät {device_name} ({device_id}) registriert") - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Registrieren des Geräts: {str(e)}") - return jsonify({'error': 'Fehler beim Registrieren des Geräts'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein Gerät einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob Gerät zur Lizenz gehört - cur.execute(""" - SELECT dr.device_name, dr.device_id, l.license_key - FROM device_registrations dr - JOIN licenses l ON dr.license_key = l.license_key - WHERE dr.id = %s AND l.id = %s - """, (device_id, license_id)) - - device = cur.fetchone() - if not device: - return jsonify({'error': 'Gerät nicht gefunden'}), 404 - - # Deaktiviere Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = false - WHERE id = %s - """, (device_id,)) - - # Beende aktive Sessions - cur.execute(""" - UPDATE sessions - SET active = false, logout_time = CURRENT_TIMESTAMP - WHERE license_key = %s AND device_id = %s AND active = true - """, (device[2], device[1])) - - conn.commit() - - # Audit-Log - log_audit('DEVICE_DEACTIVATE', 'license', license_id, - additional_info=f"Gerät {device[0]} ({device[1]}) deaktiviert") - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'error': 'Fehler beim Deaktivieren des Geräts'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Lösche mehrere Lizenzen gleichzeitig""" - data = request.get_json() - license_ids = data.get('license_ids', []) - - if not license_ids: - return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - deleted_count = 0 - - for license_id in license_ids: - # Hole Lizenz-Info für Audit - cur.execute("SELECT license_key FROM licenses WHERE id = %s", (license_id,)) - result = cur.fetchone() - - if result: - license_key = result[0] - - # Lösche Sessions - cur.execute("DELETE FROM sessions WHERE license_key = %s", (license_key,)) - - # Lösche Geräte-Registrierungen - cur.execute("DELETE FROM device_registrations WHERE license_key = %s", (license_key,)) - - # Lösche Lizenz - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - # Audit-Log - log_audit('BULK_DELETE', 'license', license_id, - old_values={'license_key': license_key}) - - deleted_count += 1 - - conn.commit() - - return jsonify({ - 'success': True, - 'deleted_count': deleted_count - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Bulk-Löschen: {str(e)}") - return jsonify({'error': 'Fehler beim Löschen der Lizenzen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//quick-edit", methods=['POST']) -@login_required -def quick_edit_license(license_id): - """Schnellbearbeitung einer Lizenz""" - data = request.get_json() - - conn = get_connection() - cur = conn.cursor() - - try: - # Hole aktuelle Lizenz für Vergleich - current_license = get_license_by_id(license_id) - if not current_license: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Update nur die übergebenen Felder - updates = [] - params = [] - old_values = {} - new_values = {} - - if 'device_limit' in data: - updates.append("device_limit = %s") - params.append(int(data['device_limit'])) - old_values['device_limit'] = current_license['device_limit'] - new_values['device_limit'] = int(data['device_limit']) - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - old_values['valid_until'] = str(current_license['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'active' in data: - updates.append("active = %s") - params.append(bool(data['active'])) - old_values['active'] = current_license['active'] - new_values['active'] = bool(data['active']) - - if not updates: - return jsonify({'error': 'Keine Änderungen angegeben'}), 400 - - # Führe Update aus - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('QUICK_EDIT', 'license', license_id, - old_values=old_values, - new_values=new_values) - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Schnellbearbeitung: {str(e)}") - return jsonify({'error': 'Fehler bei der Bearbeitung'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//resources") -@login_required -def get_license_resources(license_id): - """Hole alle Ressourcen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Lizenz-Info - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Hole zugewiesene Ressourcen - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.is_test, - rp.status_changed_at, - lr.assigned_at, - lr.assigned_by - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'type': row[1], - 'value': row[2], - 'is_test': row[3], - 'status_changed_at': row[4].isoformat() if row[4] else None, - 'assigned_at': row[5].isoformat() if row[5] else None, - 'assigned_by': row[6] - }) - - # Gruppiere nach Typ - grouped = {} - for resource in resources: - res_type = resource['type'] - if res_type not in grouped: - grouped[res_type] = [] - grouped[res_type].append(resource) - - return jsonify({ - 'license_key': license_data['license_key'], - 'resources': resources, - 'grouped': grouped, - 'total_count': len(resources) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Ressourcen: {str(e)}") - return jsonify({'error': 'Fehler beim Abrufen der Ressourcen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/resources/allocate", methods=['POST']) -@login_required -def allocate_resources(): - """Weise Ressourcen einer Lizenz zu""" - data = request.get_json() - - license_id = data.get('license_id') - resource_ids = data.get('resource_ids', []) - - if not license_id or not resource_ids: - return jsonify({'error': 'Lizenz-ID und Ressourcen erforderlich'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe Lizenz - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - allocated_count = 0 - errors = [] - - for resource_id in resource_ids: - try: - # Prüfe ob Ressource verfügbar ist - cur.execute(""" - SELECT resource_value, status, is_test - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - - resource = cur.fetchone() - if not resource: - errors.append(f"Ressource {resource_id} nicht gefunden") - continue - - if resource[1] != 'available': - errors.append(f"Ressource {resource[0]} ist nicht verfügbar") - continue - - # Prüfe Test/Produktion Kompatibilität - if resource[2] != license_data['is_test']: - errors.append(f"Ressource {resource[0]} ist {'Test' if resource[2] else 'Produktion'}, Lizenz ist {'Test' if license_data['is_test'] else 'Produktion'}") - continue - - # Weise Ressource zu - 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)) - - # Erstelle Verknüpfung - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - # History-Eintrag - 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())) - - allocated_count += 1 - - except Exception as e: - errors.append(f"Fehler bei Ressource {resource_id}: {str(e)}") - - conn.commit() - - # Audit-Log - if allocated_count > 0: - log_audit('RESOURCE_ALLOCATE', 'license', license_id, - additional_info=f"{allocated_count} Ressourcen zugewiesen") - - return jsonify({ - 'success': True, - 'allocated_count': allocated_count, - 'errors': errors - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Zuweisen der Ressourcen: {str(e)}") - return jsonify({'error': 'Fehler beim Zuweisen der Ressourcen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/resources/check-availability", methods=['GET']) -@login_required -def check_resource_availability(): - """Prüfe Verfügbarkeit von Ressourcen""" - resource_type = request.args.get('type') - count = int(request.args.get('count', 1)) - is_test = request.args.get('is_test', 'false') == 'true' - - if not resource_type: - return jsonify({'error': 'Ressourcen-Typ erforderlich'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Zähle verfügbare Ressourcen - cur.execute(""" - SELECT COUNT(*) - FROM resource_pools - WHERE resource_type = %s - AND status = 'available' - AND is_test = %s - """, (resource_type, is_test)) - - available_count = cur.fetchone()[0] - - return jsonify({ - 'resource_type': resource_type, - 'requested': count, - 'available': available_count, - 'sufficient': available_count >= count, - 'is_test': is_test - }) - - except Exception as e: - logging.error(f"Fehler beim Prüfen der Verfügbarkeit: {str(e)}") - return jsonify({'error': 'Fehler beim Prüfen der Verfügbarkeit'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/global-search", methods=['GET']) -@login_required -def global_search(): - """Globale Suche über alle Entitäten""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 3: - return jsonify({'error': 'Suchbegriff muss mindestens 3 Zeichen haben'}), 400 - - conn = get_connection() - cur = conn.cursor() - - results = { - 'licenses': [], - 'customers': [], - 'resources': [], - 'sessions': [] - } - - try: - # Suche in Lizenzen - cur.execute(""" - SELECT id, license_key, customer_name, active - FROM licenses - WHERE license_key ILIKE %s - OR customer_name ILIKE %s - OR customer_email ILIKE %s - LIMIT 10 - """, (f'%{query}%', f'%{query}%', f'%{query}%')) - - for row in cur.fetchall(): - results['licenses'].append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2], - 'active': row[3] - }) - - # Suche in Kunden - cur.execute(""" - SELECT id, name, email - FROM customers - WHERE name ILIKE %s OR email ILIKE %s - LIMIT 10 - """, (f'%{query}%', f'%{query}%')) - - for row in cur.fetchall(): - results['customers'].append({ - 'id': row[0], - 'name': row[1], - 'email': row[2] - }) - - # Suche in Ressourcen - cur.execute(""" - SELECT id, resource_type, resource_value, status - FROM resource_pools - WHERE resource_value ILIKE %s - LIMIT 10 - """, (f'%{query}%',)) - - for row in cur.fetchall(): - results['resources'].append({ - 'id': row[0], - 'type': row[1], - 'value': row[2], - 'status': row[3] - }) - - # Suche in Sessions - cur.execute(""" - SELECT id, license_key, username, device_id, active - FROM sessions - WHERE username ILIKE %s OR device_id ILIKE %s - ORDER BY login_time DESC - LIMIT 10 - """, (f'%{query}%', f'%{query}%')) - - for row in cur.fetchall(): - results['sessions'].append({ - 'id': row[0], - 'license_key': row[1], - 'username': row[2], - 'device_id': row[3], - 'active': row[4] - }) - - return jsonify(results) - - except Exception as e: - logging.error(f"Fehler bei der globalen Suche: {str(e)}") - return jsonify({'error': 'Fehler bei der Suche'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - - -@api_bp.route("/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'pagination': {'more': False}, - 'error': str(e) - }), 500 \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/auth_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/auth_routes.py deleted file mode 100644 index 69a5c7d..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/auth_routes.py +++ /dev/null @@ -1,377 +0,0 @@ -import time -import json -from datetime import datetime -from zoneinfo import ZoneInfo -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify - -import config -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from models import get_user_by_username -from db import get_db_connection, get_db_cursor -from utils.recaptcha import verify_recaptcha - -# Create Blueprint -auth_bp = Blueprint('auth', __name__) - - -@auth_bp.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = config.RECAPTCHA_SITE_KEY - if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('auth.verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('admin.dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - error_type="failed", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - -@auth_bp.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('auth.login')) - - -@auth_bp.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('auth.login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('auth.login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('auth.login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('admin.dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('admin.dashboard')) - - # Failed verification - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - - -@auth_bp.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('admin.dashboard')) - return render_template('profile.html', user=user) - - -@auth_bp.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('auth.profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('auth.profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('auth.profile')) - - # Update password - new_hash = hash_password(new_password) - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('auth.profile')) - - -@auth_bp.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('auth.profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - - -@auth_bp.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('auth.setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('auth.setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - backup_codes_hashed = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA for user - user = get_user_by_username(session['username']) - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = true, backup_codes = %s - WHERE id = %s - """, (totp_secret, json.dumps(backup_codes_hashed), user['id'])) - - # Clear temp secret - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', entity_id=user['id'], - additional_info="2FA successfully enabled") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - - -@auth_bp.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password. 2FA was not disabled.', 'error') - return redirect(url_for('auth.profile')) - - # Disable 2FA - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute(""" - UPDATE users - SET totp_enabled = false, totp_secret = NULL, backup_codes = NULL - WHERE id = %s - """, (user['id'],)) - - log_audit('2FA_DISABLED', 'user', entity_id=user['id'], - additional_info="2FA disabled by user") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('auth.profile')) - - -@auth_bp.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/batch_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/batch_routes.py deleted file mode 100644 index 15ec50e..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/batch_routes.py +++ /dev/null @@ -1,377 +0,0 @@ -import os -import logging -import secrets -import string -from datetime import datetime, timedelta -from pathlib import Path -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file - -import config -from auth.decorators import login_required -from utils.audit import log_audit -from utils.network import get_client_ip -from utils.export import create_batch_export -from db import get_connection, get_db_connection, get_db_cursor -from models import get_customers - -# Create Blueprint -batch_bp = Blueprint('batch', __name__) - - -def generate_license_key(): - """Generiert einen zufälligen Lizenzschlüssel""" - chars = string.ascii_uppercase + string.digits - return '-'.join([''.join(secrets.choice(chars) for _ in range(4)) for _ in range(4)]) - - -@batch_bp.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_create(): - """Batch-Erstellung von Lizenzen""" - customers = get_customers() - - if request.method == "POST": - conn = get_connection() - cur = conn.cursor() - - try: - # Form data - customer_id = int(request.form['customer_id']) - license_type = request.form['license_type'] - count = int(request.form['count']) - valid_from = request.form['valid_from'] - valid_until = request.form['valid_until'] - device_limit = int(request.form['device_limit']) - is_test = 'is_test' in request.form - - # 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 - cur.execute("SELECT name, email 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')) - - 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, customer_name, customer_email, - license_type, valid_from, valid_until, device_limit, - is_test, created_at, created_by - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """, ( - license_key, customer_id, customer[0], customer[1], - license_type, valid_from, valid_until, device_limit, - is_test, datetime.now(), session['username'] - )) - - license_id = cur.fetchone()[0] - created_licenses.append({ - 'id': license_id, - 'license_key': license_key - }) - - # 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 - - 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_create.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, l.customer_name, l.customer_email, - l.license_type, l.valid_from, l.valid_until, - l.device_limit, l.is_test, l.created_at - FROM licenses l - 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_test': row[7], - 'created_at': row[8] - }) - - # Erstelle Excel-Export - excel_file = create_batch_export(licenses) - - # Lösche aus Session - session.pop('batch_created_licenses', None) - - # Sende Datei - filename = f"batch_licenses_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" - return send_file( - excel_file, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=filename - ) - - 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("active = %s") - params.append('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]) - cur.execute(""" - INSERT INTO customers (name, email, created_at) - VALUES (%s, %s, %s) - RETURNING id - """, (name, email, datetime.now())) - customer_id = cur.fetchone()[0] - customer_name = name - else: - customer_id = customer[0] - customer_name = customer[1] - - # Generiere Lizenzschlüssel - license_key = row.get('license_key', generate_license_key()) - - # Erstelle Lizenz - cur.execute(""" - INSERT INTO licenses ( - license_key, customer_id, customer_name, customer_email, - license_type, valid_from, valid_until, device_limit, - is_test, created_at, created_by - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """, ( - license_key, customer_id, customer_name, email, - row['license_type'], row['valid_from'], row['valid_until'], - int(row['device_limit']), row.get('is_test', False), - datetime.now(), session['username'] - )) - - 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") \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/customer_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/customer_routes.py deleted file mode 100644 index 2f84c22..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/customer_routes.py +++ /dev/null @@ -1,338 +0,0 @@ -import logging -from datetime import datetime -from zoneinfo import ZoneInfo -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify - -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__) - - -@customer_bp.route("/customers") -@login_required -def customers(): - customers_list = get_customers() - return render_template("customers.html", customers=customers_list) - - -@customer_bp.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - 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')) - - # Update customer data - new_values = { - 'name': request.form['name'], - 'email': request.form['email'], - 'phone': request.form.get('phone', ''), - 'address': request.form.get('address', ''), - 'notes': request.form.get('notes', '') - } - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, phone = %s, address = %s, notes = %s - WHERE id = %s - """, ( - new_values['name'], - new_values['email'], - new_values['phone'], - new_values['address'], - new_values['notes'], - customer_id - )) - - conn.commit() - - # Log changes - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': current_customer['name'], - 'email': current_customer['email'], - 'phone': current_customer.get('phone', ''), - 'address': current_customer.get('address', ''), - 'notes': current_customer.get('notes', '') - }, - new_values=new_values) - - flash('Kunde erfolgreich aktualisiert!', 'success') - return redirect(url_for('customers.customers')) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Aktualisieren des Kunden: {str(e)}") - flash('Fehler beim Aktualisieren des Kunden!', 'error') - finally: - cur.close() - conn.close() - - # 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')) - - 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'] - phone = request.form.get('phone', '') - address = request.form.get('address', '') - notes = request.form.get('notes', '') - - cur.execute(""" - INSERT INTO customers (name, email, phone, address, notes, created_at) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (name, email, phone, address, notes, datetime.now())) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Log creation - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'phone': phone, - 'address': address, - 'notes': notes - }) - - flash(f'Kunde {name} erfolgreich erstellt!', 'success') - return redirect(url_for('customers.customers')) - - 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/", 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')) - - # 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')) - - # 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')) - - -@customer_bp.route("/customers-licenses") -@login_required -def customers_licenses(): - """Zeigt die Übersicht von Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole alle Kunden mit ihren Lizenzen - cur.execute(""" - SELECT - c.id as customer_id, - c.name as customer_name, - c.email as customer_email, - c.created_at as customer_created, - COUNT(l.id) as license_count, - COUNT(CASE WHEN l.active = true THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.is_test = true THEN 1 END) as test_licenses, - MAX(l.created_at) as last_license_created - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """) - - customers = [] - for row in cur.fetchall(): - 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] - }) - - return render_template("customers_licenses.html", customers=customers) - - except Exception as e: - logging.error(f"Fehler beim Laden der Kunden-Lizenz-Übersicht: {str(e)}") - flash('Fehler beim Laden der Daten!', 'error') - return redirect(url_for('admin.dashboard')) - finally: - cur.close() - conn.close() - - -@customer_bp.route("/api/customer//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 - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.active, - l.is_test, - l.valid_from, - l.valid_until, - l.device_limit, - l.created_at, - (SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.active = true) as active_sessions, - (SELECT COUNT(DISTINCT device_id) FROM sessions s WHERE s.license_key = l.license_key) as registered_devices, - CASE - WHEN l.valid_until < CURRENT_DATE THEN 'expired' - WHEN l.active = false THEN 'inactive' - ELSE 'active' - END as status - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'active': row[3], - 'is_test': 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, - 'active_sessions': row[9], - 'registered_devices': row[10], - 'status': row[11] - }) - - return jsonify({ - 'customer': { - 'id': customer['id'], - 'name': customer['name'], - 'email': customer['email'] - }, - 'licenses': licenses - }) - - except Exception as e: - logging.error(f"Fehler beim Laden der Kundenlizenzen: {str(e)}") - return jsonify({'error': 'Fehler beim Laden der Daten'}), 500 - finally: - cur.close() - conn.close() - - -@customer_bp.route("/api/customer//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.active = true THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.is_test = 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() \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/export_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/export_routes.py deleted file mode 100644 index bd184d0..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/export_routes.py +++ /dev/null @@ -1,364 +0,0 @@ -import logging -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from flask import Blueprint, request, send_file - -import config -from auth.decorators import login_required -from utils.export import create_excel_export, prepare_audit_export_data -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: - # Filter aus Request - show_test = request.args.get('show_test', 'false') == 'true' - - # SQL Query mit optionalem Test-Filter - if show_test: - 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.active, - l.device_limit, - l.created_at, - l.is_test, - CASE - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.active = false THEN 'Deaktiviert' - ELSE 'Aktiv' - END as status, - (SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.active = true) as active_sessions, - (SELECT COUNT(DISTINCT device_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 - ORDER BY l.created_at DESC - """ - else: - 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.active, - l.device_limit, - l.created_at, - l.is_test, - CASE - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.active = false THEN 'Deaktiviert' - ELSE 'Aktiv' - END as status, - (SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.active = true) as active_sessions, - (SELECT COUNT(DISTINCT device_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_test = 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', 'Test-Lizenz', - 'Status', 'Aktive Sessions', 'Registrierte Geräte'] - - for row in cur.fetchall(): - data.append(list(row)) - - # Excel-Datei erstellen - excel_file = create_excel_export(data, columns, 'Lizenzen') - - # Datei senden - filename = f"lizenzen_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" - return send_file( - excel_file, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=filename - ) - - 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', '') - - # Daten für Export vorbereiten - data = prepare_audit_export_data(days, action_filter, entity_type_filter) - - # Excel-Datei erstellen - columns = ['Zeitstempel', 'Benutzer', 'Aktion', 'Entität', 'Entität ID', - 'IP-Adresse', 'Alte Werte', 'Neue Werte', 'Zusatzinfo'] - - excel_file = create_excel_export(data, columns, 'Audit-Log') - - # Datei senden - filename = f"audit_log_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" - return send_file( - excel_file, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=filename - ) - - 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 - cur.execute(""" - SELECT - c.id, - c.name, - c.email, - c.phone, - c.address, - c.created_at, - c.is_test, - COUNT(l.id) as license_count, - COUNT(CASE WHEN l.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 - GROUP BY c.id, c.name, c.email, c.phone, c.address, c.created_at, c.is_test - 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(): - data.append(list(row)) - - # Excel-Datei erstellen - excel_file = create_excel_export(data, columns, 'Kunden') - - # Datei senden - filename = f"kunden_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" - return send_file( - excel_file, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=filename - ) - - 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.device_id, - s.login_time, - s.logout_time, - s.last_activity, - s.active, - l.license_type, - l.is_test - FROM sessions s - LEFT JOIN licenses l ON s.license_key = l.license_key - WHERE s.active = true - ORDER BY s.login_time DESC - """ - cur.execute(query) - else: - query = """ - SELECT - s.id, - s.license_key, - l.customer_name, - s.username, - s.device_id, - s.login_time, - s.logout_time, - s.last_activity, - s.active, - l.license_type, - l.is_test - FROM sessions s - LEFT JOIN licenses l ON s.license_key = l.license_key - WHERE s.login_time >= CURRENT_TIMESTAMP - INTERVAL '%s days' - ORDER BY s.login_time 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', 'Test-Lizenz'] - - for row in cur.fetchall(): - data.append(list(row)) - - # Excel-Datei erstellen - excel_file = create_excel_export(data, columns, 'Sessions') - - # Datei senden - filename = f"sessions_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" - return send_file( - excel_file, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=filename - ) - - 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') - show_test = request.args.get('show_test', 'false') == 'true' - - # SQL Query aufbauen - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.is_test, - 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) - - if not show_test: - query += " AND rp.is_test = 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(): - data.append(list(row)) - - # Excel-Datei erstellen - excel_file = create_excel_export(data, columns, 'Ressourcen') - - # Datei senden - filename = f"ressourcen_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" - return send_file( - excel_file, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=filename - ) - - except Exception as e: - logging.error(f"Fehler beim Export: {str(e)}") - return "Fehler beim Exportieren der Ressourcen", 500 - finally: - cur.close() - conn.close() \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/license_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/license_routes.py deleted file mode 100644 index 1139e26..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/license_routes.py +++ /dev/null @@ -1,374 +0,0 @@ -import os -import logging -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from dateutil.relativedelta import relativedelta -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify - -import config -from auth.decorators import login_required -from utils.audit import log_audit -from utils.network import get_client_ip -from utils.license import validate_license_key -from db import get_connection, get_db_connection, get_db_cursor -from models import get_licenses, get_license_by_id - -# Create Blueprint -license_bp = Blueprint('licenses', __name__) - - -@license_bp.route("/licenses") -@login_required -def licenses(): - show_test = request.args.get('show_test', 'false') == 'true' - licenses_list = get_licenses(show_test=show_test) - return render_template("licenses.html", licenses=licenses_list, show_test=show_test) - - -@license_bp.route("/license/edit/", 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 = { - 'customer_name': request.form['customer_name'], - 'customer_email': request.form['customer_email'], - 'valid_from': request.form['valid_from'], - 'valid_until': request.form['valid_until'], - 'device_limit': int(request.form['device_limit']), - 'active': 'active' in request.form - } - - cur.execute(""" - UPDATE licenses - SET customer_name = %s, customer_email = %s, valid_from = %s, - valid_until = %s, device_limit = %s, active = %s - WHERE id = %s - """, ( - new_values['customer_name'], - new_values['customer_email'], - new_values['valid_from'], - new_values['valid_until'], - new_values['device_limit'], - new_values['active'], - license_id - )) - - conn.commit() - - # Log changes - log_audit('UPDATE', 'license', license_id, - old_values={ - 'customer_name': current_license['customer_name'], - 'customer_email': current_license['customer_email'], - 'valid_from': str(current_license['valid_from']), - 'valid_until': str(current_license['valid_until']), - 'device_limit': current_license['device_limit'], - 'active': current_license['active'] - }, - 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/", methods=["POST"]) -@login_required -def delete_license(license_id): - 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')) - - # Delete from sessions first - cur.execute("DELETE FROM sessions WHERE license_key = %s", (license_data['license_key'],)) - - # Delete the license - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Log deletion - 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'] - }) - - 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_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - 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')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('licenses.create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('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_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = 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) \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/resource_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/resource_routes.py deleted file mode 100644 index fe2dc0b..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/resource_routes.py +++ /dev/null @@ -1,617 +0,0 @@ -import logging -from datetime import datetime -from zoneinfo import ZoneInfo -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify - -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""" - 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_test = request.args.get('show_test', 'false') == 'true' - - # Basis-Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.is_test, - rp.allocated_to_license, - rp.created_at, - rp.status_changed_at, - rp.status_changed_by, - l.customer_name, - l.license_type - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.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 l.customer_name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%']) - - if not show_test: - query += " AND rp.is_test = false" - - query += " ORDER BY rp.resource_type, rp.resource_value" - - cur.execute(query, params) - - resources_list = [] - for row in cur.fetchall(): - resources_list.append({ - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'is_test': 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 - cur.execute(""" - SELECT - resource_type, - status, - is_test, - COUNT(*) as count - FROM resource_pools - GROUP BY resource_type, status, is_test - """) - - stats = {} - for row in cur.fetchall(): - res_type = row[0] - status = row[1] - is_test = row[2] - count = row[3] - - if res_type not in stats: - stats[res_type] = {'available': 0, 'allocated': 0, 'quarantined': 0, 'test': 0, 'prod': 0} - - stats[res_type][status] = stats[res_type].get(status, 0) + count - if is_test: - stats[res_type]['test'] += count - else: - stats[res_type]['prod'] += count - - return render_template('resources.html', - resources=resources_list, - stats=stats, - resource_type=resource_type, - status_filter=status_filter, - search_query=search_query, - show_test=show_test) - - except Exception as e: - logging.error(f"Fehler beim Laden der Ressourcen: {str(e)}") - flash('Fehler beim Laden der Ressourcen!', 'error') - return redirect(url_for('admin.dashboard')) - finally: - cur.close() - conn.close() - - -@resource_bp.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resource(): - """Neue Ressource hinzufügen""" - if request.method == 'POST': - conn = get_connection() - cur = conn.cursor() - - try: - resource_type = request.form['resource_type'] - resource_value = request.form['resource_value'].strip() - is_test = 'is_test' in request.form - - # Prüfe ob Ressource bereits existiert - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = %s AND resource_value = %s - """, (resource_type, resource_value)) - - if cur.fetchone(): - flash(f'Ressource {resource_value} existiert bereits!', 'error') - return redirect(url_for('resources.add_resource')) - - # Füge neue Ressource hinzu - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status, is_test, created_by) - VALUES (%s, %s, 'available', %s, %s) - RETURNING id - """, (resource_type, resource_value, is_test, session['username'])) - - resource_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'resource', resource_id, - new_values={ - 'resource_type': resource_type, - 'resource_value': resource_value, - 'is_test': is_test - }) - - flash(f'Ressource {resource_value} erfolgreich hinzugefügt!', 'success') - return redirect(url_for('resources.resources')) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Hinzufügen der Ressource: {str(e)}") - flash('Fehler beim Hinzufügen der Ressource!', 'error') - finally: - cur.close() - conn.close() - - return render_template('add_resource.html') - - -@resource_bp.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(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_resources(): - """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/') -@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_test - 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_test': 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_test, - COUNT(*) as count - FROM resource_pools - GROUP BY resource_type, status, is_test - 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 resource_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_test = true THEN 1 END) as test, - COUNT(CASE WHEN is_test = 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_test, - 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() \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/session_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/session_routes.py deleted file mode 100644 index b7135f2..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/session_routes.py +++ /dev/null @@ -1,388 +0,0 @@ -import logging -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from flask import Blueprint, render_template, request, redirect, session, url_for, flash - -import config -from auth.decorators import login_required -from utils.audit import log_audit -from utils.network import get_client_ip -from db import get_connection, get_db_connection, get_db_cursor -from models import get_active_sessions - -# Create Blueprint -session_bp = Blueprint('sessions', __name__) - - -@session_bp.route("/sessions") -@login_required -def sessions(): - active_sessions = get_active_sessions() - return render_template("sessions.html", sessions=active_sessions) - - -@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.device_id, - s.login_time, - s.logout_time, - s.last_activity, - s.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.login_time >= CURRENT_TIMESTAMP - INTERVAL '%s days'" - params.append(days) - - query += " ORDER BY s.login_time DESC LIMIT 1000" - - cur.execute(query, params) - - sessions_list = [] - for row in cur.fetchall(): - session_duration = None - if row[4] and row[5]: # login_time and logout_time - 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]: # login_time and 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], - 'device_id': row[3], - 'login_time': row[4], - 'logout_time': row[5], - 'last_activity': row[6], - '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.login_time >= 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/terminate/", 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, device_id - FROM sessions - WHERE id = %s AND 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 active = false, logout_time = 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/", methods=["POST"]) -@login_required -def terminate_all_sessions(license_key): - """Beendet alle aktiven Sessions einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Count active sessions - cur.execute(""" - SELECT COUNT(*) FROM sessions - WHERE license_key = %s AND 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 active = false, logout_time = CURRENT_TIMESTAMP - WHERE license_key = %s AND 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 active = false - AND logout_time < 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.device_id) as unique_devices, - COUNT(*) as total_active_sessions - FROM sessions s - WHERE s.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.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.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(login_time) as date, - COUNT(*) as login_count, - COUNT(DISTINCT license_key) as unique_licenses, - COUNT(DISTINCT username) as unique_users - FROM sessions - WHERE login_time >= CURRENT_DATE - INTERVAL '7 days' - GROUP BY DATE(login_time) - 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 (logout_time - login_time))/3600) as avg_duration_hours - FROM sessions - WHERE active = false - AND logout_time IS NOT NULL - AND logout_time - login_time < INTERVAL '24 hours' - AND login_time >= 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() \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/add_resources.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/add_resources.html deleted file mode 100644 index 66cafcc..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/add_resources.html +++ /dev/null @@ -1,439 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Ressourcen hinzufügen{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-
- -
-
-

Ressourcen hinzufügen

-

Fügen Sie neue Domains, IPs oder Telefonnummern zum Pool hinzu

-
- - ← Zurück zur Übersicht - -
- -
- -
-
-
1️⃣ Ressourcentyp wählen
-
-
- -
-
-
🌐
-
Domain
- Webseiten-Adressen -
-
-
🖥️
-
IPv4
- IP-Adressen -
-
-
📱
-
Telefon
- Telefonnummern -
-
-
-
- - -
-
-
2️⃣ Ressourcen eingeben
-
-
-
- - -
- - Geben Sie jede Ressource in eine neue Zeile ein. Duplikate werden automatisch übersprungen. -
-
- - -
-
📊 Live-Vorschau
-
-
-
0
-
Gültig
-
-
-
0
-
Duplikate
-
-
-
0
-
Ungültig
-
-
- -
-
-
- - -
-
-
💡 Format-Beispiele
-
-
-
-
-
-
-
- 🌐 Domains -
-
example.com
-test-domain.net
-meine-seite.de
-subdomain.example.org
-my-website.io
-
- - Format: Ohne http(s)://
- Erlaubt: Buchstaben, Zahlen, Punkt, Bindestrich -
-
-
-
-
-
-
-
-
- 🖥️ IPv4-Adressen -
-
192.168.1.10
-192.168.1.11
-10.0.0.1
-172.16.0.5
-8.8.8.8
-
- - Format: xxx.xxx.xxx.xxx
- Bereich: 0-255 pro Oktett -
-
-
-
-
-
-
-
-
- 📱 Telefonnummern -
-
+491701234567
-+493012345678
-+33123456789
-+441234567890
-+12125551234
-
- - Format: Mit Ländervorwahl
- Start: Immer mit + -
-
-
-
-
-
- - -
- - -
-
-
- - -
- - -
-
-
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/audit_log.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/audit_log.html deleted file mode 100644 index cfcd996..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/audit_log.html +++ /dev/null @@ -1,318 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Log{% endblock %} - -{% macro sortable_header(label, field, current_sort, current_order) %} - - {% if current_sort == field %} - - {% else %} - - {% endif %} - {{ label }} - - {% if current_sort == field %} - {% if current_order == 'asc' %}↑{% else %}↓{% endif %} - {% else %} - ↕ - {% endif %} - - - -{% endmacro %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

📝 Log

-
- - -
-
-
-
-
- - -
-
- - -
-
- - -
-
-
- Zurücksetzen - -
-
-
-
-
-
- - -
-
-
- - - - {{ sortable_header('Zeitstempel', 'timestamp', sort, order) }} - {{ sortable_header('Benutzer', 'username', sort, order) }} - {{ sortable_header('Aktion', 'action', sort, order) }} - {{ sortable_header('Entität', 'entity', sort, order) }} - - {{ sortable_header('IP-Adresse', 'ip', sort, order) }} - - - - {% for log in logs %} - - - - - - - - - {% endfor %} - -
Details
{{ log[1].strftime('%d.%m.%Y %H:%M:%S') }}{{ log[2] }} - - {% if log[3] == 'CREATE' %}➕ Erstellt - {% elif log[3] == 'UPDATE' %}✏️ Bearbeitet - {% elif log[3] == 'DELETE' %}🗑️ Gelöscht - {% elif log[3] == 'LOGIN' %}🔑 Anmeldung - {% elif log[3] == 'LOGOUT' %}🚪 Abmeldung - {% elif log[3] == 'AUTO_LOGOUT' %}⏰ Auto-Logout - {% elif log[3] == 'EXPORT' %}📥 Export - {% elif log[3] == 'GENERATE_KEY' %}🔑 Key generiert - {% elif log[3] == 'CREATE_BATCH' %}🔑 Batch erstellt - {% elif log[3] == 'BACKUP' %}💾 Backup erstellt - {% elif log[3] == 'LOGIN_2FA_SUCCESS' %}🔐 2FA-Anmeldung - {% elif log[3] == 'LOGIN_2FA_BACKUP' %}🔒 2FA-Backup-Code - {% elif log[3] == 'LOGIN_2FA_FAILED' %}⛔ 2FA-Fehlgeschlagen - {% elif log[3] == 'LOGIN_BLOCKED' %}🚫 Login-Blockiert - {% elif log[3] == 'RESTORE' %}🔄 Wiederhergestellt - {% elif log[3] == 'PASSWORD_CHANGE' %}🔐 Passwort geändert - {% elif log[3] == '2FA_ENABLED' %}✅ 2FA aktiviert - {% elif log[3] == '2FA_DISABLED' %}❌ 2FA deaktiviert - {% else %}{{ log[3] }} - {% endif %} - - - {{ log[4] }} - {% if log[5] %} - #{{ log[5] }} - {% endif %} - - {% if log[10] %} -
{{ log[10] }}
- {% endif %} - - {% if log[6] and log[3] == 'DELETE' %} -
- Gelöschte Werte -
- {% for key, value in log[6].items() %} - {{ key }}: {{ value }}
- {% endfor %} -
-
- {% elif log[6] and log[7] and log[3] == 'UPDATE' %} -
- Änderungen anzeigen -
- Vorher:
- {% for key, value in log[6].items() %} - {% if log[7][key] != value %} - {{ key }}: {{ value }}
- {% endif %} - {% endfor %} -
- Nachher:
- {% for key, value in log[7].items() %} - {% if log[6][key] != value %} - {{ key }}: {{ value }}
- {% endif %} - {% endfor %} -
-
- {% elif log[7] and log[3] == 'CREATE' %} -
- Erstellte Werte -
- {% for key, value in log[7].items() %} - {{ key }}: {{ value }}
- {% endfor %} -
-
- {% endif %} -
- {{ log[8] or '-' }} -
- - {% if not logs %} -
-

Keine Audit-Log-Einträge gefunden.

-
- {% endif %} -
- - - {% if total_pages > 1 %} - - {% endif %} -
-
-
-{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/backup_codes.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/backup_codes.html deleted file mode 100644 index f62c4db..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/backup_codes.html +++ /dev/null @@ -1,228 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Backup-Codes{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-
- -
-
-

2FA erfolgreich aktiviert!

-

Ihre Zwei-Faktor-Authentifizierung ist jetzt aktiv.

-
- - -
-
-

- ⚠️ - Wichtig: Ihre Backup-Codes -

-
-
-
- Was sind Backup-Codes?
- Diese Codes ermöglichen Ihnen den Zugang zu Ihrem Account, falls Sie keinen Zugriff auf Ihre Authenticator-App haben. - Jeder Code kann nur einmal verwendet werden. -
- - -
-
Ihre 8 Backup-Codes:
-
- {% for code in backup_codes %} -
-
{{ code }}
-
- {% endfor %} -
-
- - -
- - - -
- -
- - -
-
-
-
❌ Nicht empfohlen:
-
    -
  • Im selben Passwort-Manager wie Ihr Passwort
  • -
  • Als Foto auf Ihrem Handy
  • -
  • In einer unverschlüsselten Datei
  • -
  • Per E-Mail an sich selbst
  • -
-
-
-
-
-
✅ Empfohlen:
-
    -
  • Ausgedruckt in einem Safe
  • -
  • In einem separaten Passwort-Manager
  • -
  • Verschlüsselt auf einem USB-Stick
  • -
  • An einem sicheren Ort zu Hause
  • -
-
-
-
- - -
-
- - -
- -
-
-
-
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/backups.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/backups.html deleted file mode 100644 index 0211ecd..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/backups.html +++ /dev/null @@ -1,301 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Backup-Verwaltung{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

💾 Backup-Verwaltung

-
-
-
- - -
-
-
-
-
📅 Letztes erfolgreiches Backup
- {% if last_backup %} -

Zeitpunkt: {{ last_backup[0].strftime('%d.%m.%Y %H:%M:%S') }}

-

Größe: {{ (last_backup[1] / 1024 / 1024)|round(2) }} MB

-

Dauer: {{ last_backup[2]|round(1) }} Sekunden

- {% else %} -

Noch kein Backup vorhanden

- {% endif %} -
-
-
-
-
-
-
🔧 Backup-Aktionen
- -

- Automatische Backups: Täglich um 03:00 Uhr -

-
-
-
-
- - -
-
-
📋 Backup-Historie
-
-
-
- - - - - - - - - - - - - - - {% for backup in backups %} - - - - - - - - - - - {% endfor %} - -
ZeitstempelDateinameGrößeTypStatusErstellt vonDetailsAktionen
{{ backup[6].strftime('%d.%m.%Y %H:%M:%S') }} - {{ backup[1] }} - {% if backup[11] %} - 🔒 Verschlüsselt - {% endif %} - - {% if backup[2] %} - {{ (backup[2] / 1024 / 1024)|round(2) }} MB - {% else %} - - - {% endif %} - - {% if backup[3] == 'manual' %} - Manuell - {% else %} - Automatisch - {% endif %} - - {% if backup[4] == 'success' %} - ✅ Erfolgreich - {% elif backup[4] == 'failed' %} - ❌ Fehlgeschlagen - {% else %} - ⏳ In Bearbeitung - {% endif %} - {{ backup[7] }} - {% if backup[8] and backup[9] %} - - {{ backup[8] }} Tabellen
- {{ backup[9] }} Datensätze
- {% if backup[10] %} - {{ backup[10]|round(1) }}s - {% endif %} -
- {% else %} - - - {% endif %} -
- {% if backup[4] == 'success' %} -
- - 📥 Download - - - -
- {% endif %} -
- - {% if not backups %} -
-

Noch keine Backups vorhanden.

-
- {% endif %} -
-
-
-
- - - -{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/base.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/base.html deleted file mode 100644 index af1bba6..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/base.html +++ /dev/null @@ -1,679 +0,0 @@ - - - - - - {% block title %}Admin Panel{% endblock %} - Lizenzverwaltung - - - - - {% block extra_css %}{% endblock %} - - - - - - - - - -
- -
- {% block content %}{% endblock %} -
-
- - - - - - - - - - - - - - {% block extra_js %}{% endblock %} - - \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/batch_form.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/batch_form.html deleted file mode 100644 index 0a5901e..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/batch_form.html +++ /dev/null @@ -1,464 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Batch-Lizenzen erstellen{% endblock %} - -{% block content %} -
-
-

🔑 Batch-Lizenzen erstellen

- ← Zurück zur Übersicht -
- -
- ℹ️ Batch-Generierung: Erstellen Sie mehrere Lizenzen auf einmal für einen Kunden. - Die Lizenzen werden automatisch generiert und können anschließend als CSV exportiert werden. -
- - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - - {% endfor %} - {% endif %} - {% endwith %} - -
-
-
- - -
- - - -
- - -
Max. 100 Lizenzen pro Batch
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
- - -
-
-
- Ressourcen-Zuweisung pro Lizenz - -
-
-
-
-
- - - - Verfügbar: - - | Benötigt: - - -
-
- - - - Verfügbar: - - | Benötigt: - - -
-
- - - - Verfügbar: - - | Benötigt: - - -
-
- -
-
- - -
-
-
- Gerätelimit pro Lizenz -
-
-
-
-
- - - - Jede generierte Lizenz kann auf maximal dieser Anzahl von Geräten gleichzeitig aktiviert werden. - -
-
-
-
- - -
- - -
- -
- - -
-
- - - -
- - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/batch_result.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/batch_result.html deleted file mode 100644 index 9ebf0dd..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/batch_result.html +++ /dev/null @@ -1,156 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Batch-Lizenzen generiert{% endblock %} - -{% block content %} -
-
-

✅ Batch-Lizenzen erfolgreich generiert

- -
- -
-
🎉 {{ licenses|length }} Lizenzen wurden erfolgreich generiert!
-

Die Lizenzen wurden in der Datenbank gespeichert und dem Kunden zugeordnet.

-
- - -
-
-
📋 Kundeninformationen
-
-
-
-
-

Kunde: {{ customer }}

-

E-Mail: {{ email or 'Nicht angegeben' }}

-
-
-

Gültig von: {{ valid_from }}

-

Gültig bis: {{ valid_until }}

-
-
-
-
- - -
-
-
📥 Export-Optionen
-
-
-

Exportieren Sie die generierten Lizenzen für den Kunden:

-
- - 📄 Als CSV exportieren - - - -
-
-
- - -
-
-
🔑 Generierte Lizenzen
-
-
-
- - - - - - - - - - - {% for license in licenses %} - - - - - - - {% endfor %} - -
#LizenzschlüsselTypAktionen
{{ loop.index }}{{ license.key }} - {% if license.type == 'full' %} - Vollversion - {% else %} - Testversion - {% endif %} - - -
-
-
-
- - -
- 💡 Tipp: Die generierten Lizenzen sind sofort aktiv und können verwendet werden. - Sie finden alle Lizenzen auch in der Lizenzübersicht. -
-
- - - - - - - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/blocked_ips.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/blocked_ips.html deleted file mode 100644 index f0d80c6..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/blocked_ips.html +++ /dev/null @@ -1,98 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Gesperrte IPs{% endblock %} - -{% block content %} -
-
-

🔒 Gesperrte IPs

-
-
-
- -
-
-
IP-Sperrverwaltung
-
-
- {% if blocked_ips %} -
- - - - - - - - - - - - - - - - {% for ip in blocked_ips %} - - - - - - - - - - - - {% endfor %} - -
IP-AdresseVersucheErster VersuchLetzter VersuchGesperrt bisLetzter UserLetzte MeldungStatusAktionen
{{ ip.ip_address }}{{ ip.attempt_count }}{{ ip.first_attempt }}{{ ip.last_attempt }}{{ ip.blocked_until }}{{ ip.last_username or '-' }}{{ ip.last_error or '-' }} - {% if ip.is_active %} - GESPERRT - {% else %} - ABGELAUFEN - {% endif %} - -
- {% if ip.is_active %} -
- - -
- {% endif %} -
- - -
-
-
-
- {% else %} -
- Keine gesperrten IPs vorhanden. - Das System läuft ohne Sicherheitsvorfälle. -
- {% endif %} -
-
- -
-
-
ℹ️ Informationen
-
    -
  • IPs werden nach {{ 5 }} fehlgeschlagenen Login-Versuchen für 24 Stunden gesperrt.
  • -
  • Nach 2 Versuchen wird ein CAPTCHA angezeigt.
  • -
  • Bei 5 Versuchen wird eine E-Mail-Benachrichtigung gesendet (wenn aktiviert).
  • -
  • Gesperrte IPs können manuell entsperrt werden.
  • -
  • Die Fehlermeldungen werden zufällig ausgewählt für zusätzliche Verwirrung.
  • -
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/create_customer.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/create_customer.html deleted file mode 100644 index 55518fc..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/create_customer.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Neuer Kunde{% endblock %} - -{% block content %} -
-
-

👤 Neuer Kunde anlegen

- ← Zurück zur Übersicht -
- -
-
-
-
-
- - -
Der Name des Kunden oder der Firma
-
-
- - -
Kontakt-E-Mail-Adresse des Kunden
-
-
- -
- - -
- - - -
- - Abbrechen -
-
-
-
-
- - -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} - - {% endfor %} -
- {% endif %} -{% endwith %} -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers.html deleted file mode 100644 index 684d0af..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers.html +++ /dev/null @@ -1,176 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Kundenverwaltung{% endblock %} - -{% macro sortable_header(label, field, current_sort, current_order) %} - - {% if current_sort == field %} - - {% else %} - - {% endif %} - {{ label }} - - {% if current_sort == field %} - {% if current_order == 'asc' %}↑{% else %}↓{% endif %} - {% else %} - ↕ - {% endif %} - - - -{% endmacro %} - -{% block content %} -
-
-

Kundenverwaltung

-
- - -
-
-
-
- - -
- -
- {% if search %} -
- Suchergebnisse für: {{ search }} - ✖ Suche zurücksetzen -
- {% endif %} -
-
- -
-
-
- - - - {{ 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) }} - - - - - {% for customer in customers %} - - - - - - - - - {% endfor %} - -
Aktionen
{{ customer[0] }} - {{ customer[1] }} - {% if customer[4] %} - 🧪 - {% endif %} - {{ customer[2] or '-' }}{{ customer[3].strftime('%d.%m.%Y %H:%M') }} - {{ customer[6] }}/{{ customer[5] }} - -
- ✏️ Bearbeiten - {% if customer[5] == 0 %} -
- -
- {% else %} - - {% endif %} -
-
- - {% if not customers %} -
- {% if search %} -

Keine Kunden gefunden für: {{ search }}

- Alle Kunden anzeigen - {% else %} -

Noch keine Kunden vorhanden.

- Erste Lizenz erstellen - {% endif %} -
- {% endif %} -
- - - {% if total_pages > 1 %} - - {% endif %} -
-
-
-{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers_licenses.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers_licenses.html deleted file mode 100644 index ff92edd..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers_licenses.html +++ /dev/null @@ -1,1219 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Kunden & Lizenzen - AccountForger Admin{% endblock %} - - - -{% block content %} -
-
-

Kunden & Lizenzen

- -
- -
- -
-
-
-
- Kunden - {{ customers|length if customers else 0 }} -
-
-
- -
- -
- - -
-
- - -
- {% if customers %} - {% for customer in customers %} -
-
-
-
{{ customer[1] }}
- {{ customer[2] }} -
-
- {{ customer[4] }} - {% if customer[5] > 0 %} - {{ customer[5] }} - {% endif %} - {% if customer[6] > 0 %} - {{ customer[6] }} - {% endif %} -
-
-
- {% endfor %} - {% else %} -
- -

Keine Kunden vorhanden

- Erstellen Sie eine neue Lizenz, um automatisch einen Kunden anzulegen. - - Neue Lizenz erstellen - -
- {% endif %} -
-
-
-
- - -
-
-
- {% if selected_customer %} -
-
-
{{ selected_customer[1] }}
- {{ selected_customer[2] }} -
- -
- {% else %} -
Wählen Sie einen Kunden aus
- {% endif %} -
-
-
- {% if selected_customer %} - {% if licenses %} -
- - - - - - - - - - - - - - {% for license in licenses %} - - - - - - - - - - {% endfor %} - -
LizenzschlüsselTypGültig vonGültig bisStatusRessourcenAktionen
- {{ license[1] }} - - - - {{ license[2]|upper }} - - {{ license[3].strftime('%d.%m.%Y') if license[3] else '-' }}{{ license[4].strftime('%d.%m.%Y') if license[4] else '-' }} - - {{ license[6] }} - - -
-
- 🌐 {{ license[12] }} -
-
- 📡 {{ license[13] }} -
-
- 📱 {{ license[14] }} -
-
- 💻 {{ license[11] }}/{{ license[10] }} -
-
-
-
- - - - - -
-
-
- {% else %} -
- -

Keine Lizenzen für diesen Kunden vorhanden

- -
- {% endif %} - {% else %} -
- -

Wählen Sie einen Kunden aus der Liste aus

-
- {% endif %} -
-
-
-
-
-
- - - - - - - - - - - -{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers_licenses_old.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers_licenses_old.html deleted file mode 100644 index 40ec906..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers_licenses_old.html +++ /dev/null @@ -1,488 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Kunden & Lizenzen - AccountForger Admin{% endblock %} - -{% block content %} -
-
-

Kunden & Lizenzen

- -
- -
- -
-
-
-
- Kunden - {{ customers|length if customers else 0 }} -
-
-
- -
- -
- - -
-
- - -
- {% if customers %} - {% for customer in customers %} -
-
-
-
{{ customer[1] }}
- {{ customer[2] }} -
-
- {{ customer[4] }} - {% if customer[5] > 0 %} - {{ customer[5] }} - {% endif %} - {% if customer[6] > 0 %} - {{ customer[6] }} - {% endif %} -
-
-
- {% endfor %} - {% else %} -
- -

Keine Kunden vorhanden

- Erstellen Sie eine neue Lizenz, um automatisch einen Kunden anzulegen. - - Neue Lizenz erstellen - -
- {% endif %} -
-
-
-
- - -
-
-
- {% if selected_customer %} -
-
-
{{ selected_customer[1] }}
- {{ selected_customer[2] }} -
-
- - Bearbeiten - - -
-
- {% else %} -
Wählen Sie einen Kunden aus
- {% endif %} -
-
-
- {% if selected_customer %} - {% if licenses %} -
- - - - - - - - - - - - - - {% for license in licenses %} - - - - - - - - - - {% endfor %} - -
LizenzschlüsselTypGültig vonGültig bisStatusRessourcenAktionen
- {{ license[1] }} - - - - {{ license[2]|upper }} - - {{ license[3].strftime('%d.%m.%Y') if license[3] else '-' }}{{ license[4].strftime('%d.%m.%Y') if license[4] else '-' }} - - {{ license[6] }} - - - {% if license[7] > 0 %}🌐 {{ license[7] }}{% endif %} - {% if license[8] > 0 %}📡 {{ license[8] }}{% endif %} - {% if license[9] > 0 %}📱 {{ license[9] }}{% endif %} - -
- - - - -
-
-
- {% else %} -
- -

Keine Lizenzen für diesen Kunden vorhanden

- -
- {% endif %} - {% else %} -
- -

Wählen Sie einen Kunden aus der Liste aus

-
- {% endif %} -
-
-
-
-
-
- - - - - - -{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/dashboard.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/dashboard.html deleted file mode 100644 index e445290..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/dashboard.html +++ /dev/null @@ -1,433 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Dashboard{% endblock %} - - - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-

Dashboard

- - - - - -
-
-
-
-
Lizenztypen
-
-
-

{{ stats.full_licenses }}

-

Vollversionen

-
-
-

{{ stats.test_licenses }}

-

Testversionen

-
-
- {% if stats.test_data_count > 0 or stats.test_customers_count > 0 or stats.test_resources_count > 0 %} -
- - Testdaten: - {{ stats.test_data_count }} Lizenzen, - {{ stats.test_customers_count }} Kunden, - {{ stats.test_resources_count }} Ressourcen - -
- {% endif %} -
-
-
-
-
-
-
Lizenzstatus
-
-
-

{{ stats.active_licenses }}

-

Aktiv

-
-
-

{{ stats.expired_licenses }}

-

Abgelaufen

-
-
-

{{ stats.inactive_licenses }}

-

Deaktiviert

-
-
-
-
-
-
- - - - - - {% if stats.recent_security_events %} -
-
-
-
-
🚨 Letzte Sicherheitsereignisse
-
-
-
- - - - - - - - - - - - {% for event in stats.recent_security_events %} - - - - - - - - {% endfor %} - -
ZeitIP-AdresseVersucheFehlermeldungStatus
{{ event.last_attempt }}{{ event.ip_address }}{{ event.attempt_count }}{{ event.error_message }} - {% if event.blocked_until %} - Gesperrt bis {{ event.blocked_until }} - {% else %} - Aktiv - {% endif %} -
-
-
-
-
-
- {% endif %} - - -
-
-
-
-
- Resource Pool Status -
-
-
-
- {% if resource_stats %} - {% for type, data in resource_stats.items() %} -
-
-
- - - -
-
-
- {{ type|upper }} -
-
- - {{ data.available }} / {{ data.total }} verfügbar - - - {{ data.available_percent }}% - -
-
-
-
-
- - - {{ data.allocated }} zugeteilt - - - {% if data.quarantine > 0 %} - - - {{ data.quarantine }} in Quarantäne - - - {% endif %} -
-
-
-
- {% endfor %} - {% else %} -
- -

Keine Ressourcen im Pool vorhanden.

- - Ressourcen hinzufügen - -
- {% endif %} -
- {% if resource_warning %} - - {% endif %} -
-
-
-
- -
- -
-
-
-
⏰ Bald ablaufende Lizenzen
-
-
- {% if stats.expiring_licenses %} -
- - - - - - - - - - {% for license in stats.expiring_licenses %} - - - - - - {% endfor %} - -
KundeLizenzTage
{{ license[2] }}{{ license[1][:8] }}...{{ license[4] }} Tage
-
- {% else %} -

Keine Lizenzen laufen in den nächsten 30 Tagen ab.

- {% endif %} -
-
-
- - -
-
-
-
🆕 Zuletzt erstellte Lizenzen
-
-
- {% if stats.recent_licenses %} -
- - - - - - - - - - {% for license in stats.recent_licenses %} - - - - - - {% endfor %} - -
KundeLizenzStatus
{{ license[2] }}{{ license[1][:8] }}... - {% if license[4] == 'deaktiviert' %} - 🚫 Deaktiviert - {% elif license[4] == 'abgelaufen' %} - ⚠️ Abgelaufen - {% elif license[4] == 'läuft bald ab' %} - ⏰ Läuft bald ab - {% else %} - ✅ Aktiv - {% endif %} -
-
- {% else %} -

Noch keine Lizenzen erstellt.

- {% endif %} -
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/edit_customer.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/edit_customer.html deleted file mode 100644 index ec7f744..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/edit_customer.html +++ /dev/null @@ -1,103 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Kunde bearbeiten{% endblock %} - -{% block content %} -
-
-

Kunde bearbeiten

- -
- -
-
-
- {% if request.args.get('show_test') == 'true' %} - - {% endif %} -
-
- - -
-
- - -
-
- -

{{ customer[3].strftime('%d.%m.%Y %H:%M') }}

-
-
- -
- - -
- -
- - Abbrechen -
-
-
-
- -
-
-
Lizenzen des Kunden
-
-
- {% if licenses %} -
- - - - - - - - - - - - - {% for license in licenses %} - - - - - - - - - {% endfor %} - -
LizenzschlüsselTypGültig vonGültig bisAktivAktionen
{{ license[1] }} - {% if license[2] == 'full' %} - Vollversion - {% else %} - Testversion - {% endif %} - {{ license[3].strftime('%d.%m.%Y') }}{{ license[4].strftime('%d.%m.%Y') }} - {% if license[5] %} - - {% else %} - - {% endif %} - - Bearbeiten -
-
- {% else %} -

Dieser Kunde hat noch keine Lizenzen.

- {% endif %} -
-
-
-{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/edit_license.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/edit_license.html deleted file mode 100644 index fce45eb..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/edit_license.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Lizenz bearbeiten{% endblock %} - -{% block content %} -
-
-

Lizenz bearbeiten

- -
- -
-
-
- {% if request.args.get('show_test') == 'true' %} - - {% endif %} -
-
- - - Kunde kann nicht geändert werden -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- - - Maximale Anzahl gleichzeitig aktiver Geräte -
-
- -
- - -
- -
- - Abbrechen -
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/index.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/index.html deleted file mode 100644 index a14329a..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/index.html +++ /dev/null @@ -1,533 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Admin Panel{% endblock %} - -{% block content %} -
-
-

Neue Lizenz erstellen

- ← Zurück zur Übersicht -
- -
-
-
- - -
- - -
- -
- - -
-
Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- Ressourcen-Zuweisung - -
-
-
-
-
- - - - Verfügbar: - - -
-
- - - - Verfügbar: - - -
-
- - - - Verfügbar: - - -
-
- -
-
- - -
-
-
- Gerätelimit -
-
-
-
-
- - - - Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann. - -
-
-
-
- - -
- - -
- -
- -
-
-
- - -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} - - {% endfor %} -
- {% endif %} -{% endwith %} - - -{% endblock %} diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/licenses.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/licenses.html deleted file mode 100644 index eb397af..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/licenses.html +++ /dev/null @@ -1,375 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Lizenzübersicht{% endblock %} - -{% macro sortable_header(label, field, current_sort, current_order) %} - - {% if current_sort == field %} - - {% else %} - - {% endif %} - {{ label }} - - {% if current_sort == field %} - {% if current_order == 'asc' %}↑{% else %}↓{% endif %} - {% else %} - ↕ - {% endif %} - - - -{% endmacro %} - -{% block extra_css %} -{% endblock %} - -{% block content %} -
-
-

Lizenzübersicht

-
- - -
-
-
-
-
- - -
-
- - -
-
- - -
- -
-
- {% if search or filter_type or filter_status %} -
- - Gefiltert: {{ total }} Ergebnisse - {% if search %} | Suche: {{ search }}{% endif %} - {% if filter_type %} | Typ: {{ 'Vollversion' if filter_type == 'full' else 'Testversion' }}{% endif %} - {% if filter_status %} | Status: {{ filter_status }}{% endif %} - -
- {% endif %} -
-
- -
-
-
- - - - - {{ 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) }} - - - - - {% for license in licenses %} - - - - - - - - - - - - - - {% endfor %} - -
- - Aktionen
- - {{ license[0] }} -
- {{ license[1] }} - -
-
- {{ license[2] }} - {% if license[8] %} - 🧪 - {% endif %} - {{ license[3] or '-' }} - {% if license[4] == 'full' %} - Vollversion - {% else %} - Testversion - {% endif %} - {{ license[5].strftime('%d.%m.%Y') }}{{ license[6].strftime('%d.%m.%Y') }} - {% if license[9] == 'abgelaufen' %} - ⚠️ Abgelaufen - {% elif license[9] == 'läuft bald ab' %} - ⏰ Läuft bald ab - {% elif license[9] == 'deaktiviert' %} - ❌ Deaktiviert - {% else %} - ✅ Aktiv - {% endif %} - -
- -
-
-
- ✏️ Bearbeiten -
- -
-
-
- - {% if not licenses %} -
- {% if search %} -

Keine Lizenzen gefunden für: {{ search }}

- Alle Lizenzen anzeigen - {% else %} -

Noch keine Lizenzen vorhanden.

- Erste Lizenz erstellen - {% endif %} -
- {% endif %} -
- - - {% if total_pages > 1 %} - - {% endif %} -
-
-
- - -
-
- 0 Lizenzen ausgewählt -
-
- - - -
-
-{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/login.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/login.html deleted file mode 100644 index f98cd96..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/login.html +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - Admin Login - Lizenzverwaltung - - - - -
-
-
-
-
-

🔐 Admin Login

- - {% if error %} -
- {{ error }} -
- {% endif %} - - {% if attempts_left is defined and attempts_left > 0 and attempts_left < 5 %} -
- ⚠️ Noch {{ attempts_left }} Versuch(e) bis zur IP-Sperre! -
- {% endif %} - -
-
- - -
-
- - -
- - {% if show_captcha and recaptcha_site_key %} -
-
-
- {% endif %} - - -
- -
- 🛡️ Geschützt durch Rate-Limiting und IP-Sperre -
-
-
-
-
-
- - {% if show_captcha and recaptcha_site_key %} - - {% endif %} - - \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/profile.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/profile.html deleted file mode 100644 index 3448e6b..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/profile.html +++ /dev/null @@ -1,216 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Benutzerprofil{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

👤 Benutzerprofil

-
- - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - - {% endfor %} - {% endif %} - {% endwith %} - - -
-
-
-
-
👤
-
{{ user.username }}
-

{{ user.email or 'Keine E-Mail angegeben' }}

- Mitglied seit: {{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'Unbekannt' }} -
-
-
-
-
-
-
🔐
-
Sicherheitsstatus
- {% if user.totp_enabled %} - 2FA Aktiv - {% else %} - 2FA Inaktiv - {% endif %} -

- Letztes Passwort-Update:
{{ user.last_password_change.strftime('%d.%m.%Y') if user.last_password_change else 'Noch nie' }}
-

-
-
-
-
- - -
-
-
- 🔑 - Passwort ändern -
-
-
-
- - -
-
- - -
-
Mindestens 8 Zeichen
-
-
- - -
Passwörter stimmen nicht überein
-
- -
-
-
- - -
-
-
- 🔐 - Zwei-Faktor-Authentifizierung (2FA) -
-
- {% if user.totp_enabled %} -
-
-
Status: Aktiv
-

Ihr Account ist durch 2FA geschützt

-
-
-
-
-
- - -
- -
- {% else %} -
-
-
Status: Inaktiv
-

Aktivieren Sie 2FA für zusätzliche Sicherheit

-
-
⚠️
-
-

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

- ✨ 2FA einrichten - {% endif %} -
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_history.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_history.html deleted file mode 100644 index 9174760..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_history.html +++ /dev/null @@ -1,365 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Resource Historie{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
- -
-
-

Resource Historie

-

Detaillierte Aktivitätshistorie

-
- - Zurück zur Übersicht - -
- - -
-
-
📋 Resource Details
-
-
- -
-
- {% if resource.resource_type == 'domain' %} - 🌐 - {% elif resource.resource_type == 'ipv4' %} - 🖥️ - {% else %} - 📱 - {% endif %} -
-
{{ resource.resource_value }}
-
- {% if resource.status == 'available' %} - - ✅ Verfügbar - - {% elif resource.status == 'allocated' %} - - 🔗 Zugeteilt - - {% else %} - - ⚠️ Quarantäne - - {% endif %} -
-
- - -
-
-
Ressourcentyp
-
{{ resource.resource_type|upper }}
-
- -
-
Erstellt am
-
- {{ resource.created_at.strftime('%d.%m.%Y %H:%M') if resource.created_at else '-' }} -
-
- -
-
Status geändert
-
- {{ resource.status_changed_at.strftime('%d.%m.%Y %H:%M') if resource.status_changed_at else '-' }} -
-
- - {% if resource.allocated_to_license %} - - {% endif %} - - {% if resource.quarantine_reason %} -
-
Quarantäne-Grund
-
- {{ resource.quarantine_reason }} -
-
- {% endif %} - - {% if resource.quarantine_until %} -
-
Quarantäne bis
-
- {{ resource.quarantine_until.strftime('%d.%m.%Y') }} -
-
- {% endif %} -
- - {% if resource.notes %} -
-
-
📝 Notizen
-

{{ resource.notes }}

-
-
- {% endif %} -
-
- - -
-
-
⏱️ Aktivitäts-Historie
-
-
- {% if history %} -
- {% for event in history %} -
-
-
-
-
-
-
- {% if event.action == 'created' %} - - - -
Ressource erstellt
- {% elif event.action == 'allocated' %} - - - -
An Lizenz zugeteilt
- {% elif event.action == 'deallocated' %} - - - -
Von Lizenz freigegeben
- {% elif event.action == 'quarantined' %} - - - -
In Quarantäne gesetzt
- {% elif event.action == 'released' %} - - - -
Aus Quarantäne entlassen
- {% elif event.action == 'deleted' %} - - - -
Ressource gelöscht
- {% else %} -
{{ event.action }}
- {% endif %} -
- -
- {{ event.action_by }} - {% if event.ip_address %} -  •  {{ event.ip_address }} - {% endif %} - {% if event.license_id %} -  •  - - - Lizenz #{{ event.license_id }} - - {% endif %} -
- - {% if event.details %} -
- Details: -
{{ event.details|tojson(indent=2) }}
-
- {% endif %} -
-
-
- {{ event.action_at.strftime('%d.%m.%Y') }} -
-
- {{ event.action_at.strftime('%H:%M:%S') }} -
-
-
-
-
- {% endfor %} -
- {% else %} -
- -

Keine Historie-Einträge vorhanden.

-
- {% endif %} -
-
-
-{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_metrics.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_metrics.html deleted file mode 100644 index d34205a..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_metrics.html +++ /dev/null @@ -1,559 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Resource Metriken{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
- -
-
-

Performance Dashboard

-

Resource Pool Metriken und Analysen

-
- -
- - -
-
-
-
-
- 📊 -
-
Ressourcen gesamt
-
{{ stats.total_resources or 0 }}
-
Aktive Ressourcen
-
-
-
- -
-
-
-
- 📈 -
-
Ø Performance
-
- {{ "%.1f"|format(stats.avg_performance or 0) }}% -
-
Letzte 30 Tage
- {% if stats.performance_trend %} -
- - {% if stats.performance_trend == 'up' %} - Steigend - {% elif stats.performance_trend == 'down' %} - Fallend - {% else %} - Stabil - {% endif %} - -
- {% endif %} -
-
-
- -
-
-
-
- 💰 -
-
ROI
-
- {{ "%.2f"|format(stats.roi) }}x -
-
Revenue / Cost
-
-
-
- -
-
-
-
- ⚠️ -
-
Probleme
-
- {{ stats.total_issues or 0 }} -
-
Letzte 30 Tage
-
-
-
-
- - -
-
-
-
-
📊 Performance nach Ressourcentyp
-
-
- -
-
-
-
-
-
-
🎯 Auslastung nach Typ
-
-
- -
-
-
-
- - -
-
-
-
-
🏆 Top Performer
-
-
-
- - - - - - - - - - - {% for resource in top_performers %} - - - - - - - {% endfor %} - {% if not top_performers %} - - - - {% endif %} - -
RessourceTypScoreROI
-
- {{ resource.resource_value }} - - - -
-
- - {% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %} - {{ resource.resource_type|upper }} - - -
-
- {{ "%.1f"|format(resource.avg_score) }}% -
-
-
- - {{ "%.2f"|format(resource.roi) }}x - -
- Keine Performance-Daten verfügbar -
-
-
-
-
- -
-
-
-
⚠️ Problematische Ressourcen
-
-
-
- - - - - - - - - - - {% for resource in problem_resources %} - - - - - - - {% endfor %} - {% if not problem_resources %} - - - - {% endif %} - -
RessourceTypProblemeStatus
-
- {{ resource.resource_value }} - - - -
-
- - {% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %} - {{ resource.resource_type|upper }} - - - - {{ resource.total_issues }} - - - {% if resource.status == 'quarantine' %} - - ⚠️ Quarantäne - - {% elif resource.status == 'allocated' %} - - 🔗 Zugeteilt - - {% else %} - - ✅ Verfügbar - - {% endif %} -
- Keine problematischen Ressourcen gefunden -
-
-
-
-
-
- - -
-
-
📈 30-Tage Performance Trend
-
-
- -
-
-
- - - - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_report.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_report.html deleted file mode 100644 index 23d86f6..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_report.html +++ /dev/null @@ -1,212 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Resource Report Generator{% endblock %} - -{% block content %} -
-
-

Resource Report Generator

- - Zurück - -
- -
-
-
-
-
Report-Einstellungen
-
-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
Report-Beschreibungen:
-
-
-
Auslastungsreport
-

Zeigt die Nutzung aller Ressourcen im gewählten Zeitraum. - Enthält Allokations-Historie, durchschnittliche Auslastung und Trends.

-
- - - -
-
- -
- - -
-
-
-
- - -
-
-
Letzte generierte Reports
-
-
-
-
-
-
Auslastungsreport_2025-06-01.xlsx
- vor 5 Tagen -
-

Zeitraum: 01.05.2025 - 01.06.2025

- Generiert von: {{ username }} -
-
-
-
-
-
-
- - - - - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resources.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resources.html deleted file mode 100644 index a55cc01..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resources.html +++ /dev/null @@ -1,898 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Resource Pool{% endblock %} - - - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
- -
-

Resource Pool

-

Verwalten Sie Domains, IPs und Telefonnummern

-
- - -
-
-
- - -
-
-
- - -
- {% for type, data in stats.items() %} -
-
-
-
- {% if type == 'domain' %} - 🌐 - {% elif type == 'ipv4' %} - 🖥️ - {% else %} - 📱 - {% endif %} -
-
{{ type|upper }}
-
{{ data.available }}
-
von {{ data.total }} verfügbar
- -
-
- {{ data.available_percent }}% -
-
- {% if data.allocated > 0 %}{{ data.allocated }}{% endif %} -
-
- {% if data.quarantine > 0 %}{{ data.quarantine }}{% endif %} -
-
- -
- {% if data.available_percent < 20 %} - ⚠️ Niedriger Bestand - {% elif data.available_percent < 50 %} - ⚡ Bestand prüfen - {% else %} - ✅ Gut gefüllt - {% endif %} -
-
-
-
- {% endfor %} -
- - -
-
-
- -
-
- - -
-
- - -
-
- - -
- -
-
-
-
- - -
-
-
-
📋 Ressourcen-Liste
-
- - {{ total }} Einträge -
-
-
-
- {% if resources %} -
- - - - - - - - - - - - - - {% for resource in resources %} - - - - - - - - - - {% endfor %} - -
- - ID - {% if sort_by == 'id' %} - - {% else %} - - {% endif %} - - - - Typ - {% if sort_by == 'type' %} - - {% else %} - - {% endif %} - - - - Ressource - {% if sort_by == 'resource' %} - - {% else %} - - {% endif %} - - - - Status - {% if sort_by == 'status' %} - - {% else %} - - {% endif %} - - - - Zugewiesen an - {% if sort_by == 'assigned' %} - - {% else %} - - {% endif %} - - - - Letzte Änderung - {% if sort_by == 'changed' %} - - {% else %} - - {% endif %} - - Aktionen
- #{{ resource[0] }} - -
- {% if resource[1] == 'domain' %} - 🌐 - {% elif resource[1] == 'ipv4' %} - 🖥️ - {% else %} - 📱 - {% endif %} -
-
-
- {{ resource[2] }} - -
-
- {% if resource[3] == 'available' %} - - ✅ Verfügbar - - {% elif resource[3] == 'allocated' %} - - 🔗 Zugeteilt - - {% else %} - - ⚠️ Quarantäne - - {% if resource[8] %} -
{{ resource[8] }}
- {% endif %} - {% endif %} -
- {% if resource[5] %} - - - {% else %} - - - {% endif %} - - {% if resource[7] %} -
-
{{ resource[7].strftime('%d.%m.%Y') }}
-
{{ resource[7].strftime('%H:%M Uhr') }}
-
- {% else %} - - - {% endif %} -
- {% if resource[3] == 'quarantine' %} - -
- - - -
- {% endif %} - - - -
-
- {% else %} -
- -

Keine Ressourcen gefunden

-

Ändern Sie Ihre Filterkriterien oder fügen Sie neue Ressourcen hinzu.

-
- {% endif %} -
-
- - - {% if total_pages > 1 %} - - {% endif %} - - - {% if recent_activities %} -
-
-
⏰ Kürzliche Aktivitäten
-
-
-
- {% for activity in recent_activities %} -
-
- {% if activity[0] == 'created' %} - - {% elif activity[0] == 'allocated' %} - 🔗 - {% elif activity[0] == 'deallocated' %} - 🔓 - {% elif activity[0] == 'quarantined' %} - ⚠️ - {% else %} - ℹ️ - {% endif %} -
-
-
-
- {{ activity[4] }} ({{ activity[3] }}) - {{ activity[0] }} - {% if activity[1] %} - von {{ activity[1] }} - {% endif %} -
- - {{ activity[2].strftime('%d.%m.%Y %H:%M') if activity[2] else '' }} - -
-
-
- {% endfor %} -
-
-
- {% endif %} -
- - - - - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/sessions.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/sessions.html deleted file mode 100644 index 0c85100..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/sessions.html +++ /dev/null @@ -1,183 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Session-Tracking{% endblock %} - -{% macro active_sortable_header(label, field, current_sort, current_order) %} - - {% if current_sort == field %} - - {% else %} - - {% endif %} - {{ label }} - - {% if current_sort == field %} - {% if current_order == 'asc' %}↑{% else %}↓{% endif %} - {% else %} - ↕ - {% endif %} - - - -{% endmacro %} - -{% macro ended_sortable_header(label, field, current_sort, current_order) %} - - {% if current_sort == field %} - - {% else %} - - {% endif %} - {{ label }} - - {% if current_sort == field %} - {% if current_order == 'asc' %}↑{% else %}↓{% endif %} - {% else %} - ↕ - {% endif %} - - - -{% endmacro %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

Session-Tracking

- -
- - -
-
-
🟢 Aktive Sessions ({{ active_sessions|length }})
-
-
- {% if active_sessions %} -
- - - - {{ active_sortable_header('Kunde', 'customer', active_sort, active_order) }} - {{ active_sortable_header('Lizenz', 'license', active_sort, active_order) }} - {{ active_sortable_header('IP-Adresse', 'ip', active_sort, active_order) }} - {{ active_sortable_header('Gestartet', 'started', active_sort, active_order) }} - {{ active_sortable_header('Letzter Heartbeat', 'last_heartbeat', active_sort, active_order) }} - {{ active_sortable_header('Inaktiv seit', 'inactive', active_sort, active_order) }} - - - - - {% for session in active_sessions %} - - - - - - - - - - {% endfor %} - -
Aktion
{{ session[3] }}{{ session[2][:12] }}...{{ session[4] or '-' }}{{ session[6].strftime('%d.%m %H:%M') }}{{ session[7].strftime('%d.%m %H:%M') }} - {% if session[8] < 1 %} - Aktiv - {% elif session[8] < 5 %} - {{ session[8]|round|int }} Min. - {% else %} - {{ session[8]|round|int }} Min. - {% endif %} - -
- -
-
-
- - Sessions gelten als inaktiv nach 5 Minuten ohne Heartbeat - - {% else %} -

Keine aktiven Sessions vorhanden.

- {% endif %} -
-
- - -
-
-
⏸️ Beendete Sessions (letzte 24 Stunden)
-
-
- {% if recent_sessions %} -
- - - - {{ ended_sortable_header('Kunde', 'customer', ended_sort, ended_order) }} - {{ ended_sortable_header('Lizenz', 'license', ended_sort, ended_order) }} - {{ ended_sortable_header('IP-Adresse', 'ip', ended_sort, ended_order) }} - {{ ended_sortable_header('Gestartet', 'started', ended_sort, ended_order) }} - {{ ended_sortable_header('Beendet', 'ended_at', ended_sort, ended_order) }} - {{ ended_sortable_header('Dauer', 'duration', ended_sort, ended_order) }} - - - - {% for session in recent_sessions %} - - - - - - - - - {% endfor %} - -
{{ session[3] }}{{ session[2][:12] }}...{{ session[4] or '-' }}{{ session[5].strftime('%d.%m %H:%M') }}{{ session[6].strftime('%d.%m %H:%M') }} - {% if session[7] < 60 %} - {{ session[7]|round|int }} Min. - {% else %} - {{ (session[7]/60)|round(1) }} Std. - {% endif %} -
-
- {% else %} -

Keine beendeten Sessions in den letzten 24 Stunden.

- {% endif %} -
-
-
-{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/setup_2fa.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/setup_2fa.html deleted file mode 100644 index f30af6b..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/setup_2fa.html +++ /dev/null @@ -1,210 +0,0 @@ -{% extends "base.html" %} - -{% block title %}2FA Einrichten{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

🔐 2FA einrichten

- ← Zurück zum Profil -
- - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - - {% endfor %} - {% endif %} - {% endwith %} - - -
-
-
- 1 - Authenticator-App installieren -
-

Wählen Sie eine der folgenden Apps für Ihr Smartphone:

-
-
-
- 📱 -
- Google Authenticator
- Android / iOS -
-
-
-
-
- 🔷 -
- Microsoft Authenticator
- Android / iOS -
-
-
-
-
- 🔴 -
- Authy
- Android / iOS / Desktop -
-
-
-
-
-
- - -
-
-
- 2 - QR-Code scannen oder Code eingeben -
-
-
-

Option A: QR-Code scannen

-
- 2FA QR Code -
-

- Öffnen Sie Ihre Authenticator-App und scannen Sie diesen Code -

-
-
-

Option B: Code manuell eingeben

-
- -
- V2 Admin Panel -
-
-
- -
{{ totp_secret }}
- -
-
- - ⚠️ Wichtiger Hinweis:
- Speichern Sie diesen Code sicher. Er ist Ihre einzige Möglichkeit, - 2FA auf einem neuen Gerät einzurichten. -
-
-
-
-
-
- - -
-
-
- 3 - Code verifizieren -
-

Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein:

- -
-
-
- -
Der Code ändert sich alle 30 Sekunden
-
-
- -
-
-
-
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/verify_2fa.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/verify_2fa.html deleted file mode 100644 index 6ab8f70..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/verify_2fa.html +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - 2FA Verifizierung - Admin Panel - - - - - - - - - - \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/test_blueprint_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/test_blueprint_routes.py deleted file mode 100644 index fcca661..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/test_blueprint_routes.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -""" -Test-Skript zur Verifizierung aller Blueprint-Routes -Prüft ob alle Routes korrekt registriert sind und erreichbar sind -""" - -import sys -import os -sys.path.append(os.path.dirname(os.path.abspath(__file__))) - -from app import app -from flask import url_for -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -def test_all_routes(): - """Test alle registrierten Routes""" - - print("=== Blueprint Route Test ===\n") - - # Sammle alle Routes - routes_by_blueprint = {} - - with app.test_request_context(): - for rule in app.url_map.iter_rules(): - # Skip static files - if rule.endpoint == 'static': - continue - - # Blueprint name is before the dot - parts = rule.endpoint.split('.') - if len(parts) == 2: - blueprint_name = parts[0] - function_name = parts[1] - else: - blueprint_name = 'app' - function_name = rule.endpoint - - if blueprint_name not in routes_by_blueprint: - routes_by_blueprint[blueprint_name] = [] - - routes_by_blueprint[blueprint_name].append({ - 'rule': str(rule), - 'endpoint': rule.endpoint, - 'methods': sorted(rule.methods - {'HEAD', 'OPTIONS'}), - 'function': function_name - }) - - # Sortiere und zeige Routes nach Blueprint - for blueprint_name in sorted(routes_by_blueprint.keys()): - routes = routes_by_blueprint[blueprint_name] - print(f"\n📦 Blueprint: {blueprint_name}") - print(f" Anzahl Routes: {len(routes)}") - print(" " + "-" * 50) - - for route in sorted(routes, key=lambda x: x['rule']): - methods = ', '.join(route['methods']) - print(f" {route['rule']:<40} [{methods:<15}] -> {route['function']}") - - # Zusammenfassung - print("\n=== Zusammenfassung ===") - total_routes = sum(len(routes) for routes in routes_by_blueprint.values()) - print(f"Gesamt Blueprints: {len(routes_by_blueprint)}") - print(f"Gesamt Routes: {total_routes}") - - # Erwartete Blueprints prüfen - expected_blueprints = ['auth', 'admin', 'license', 'customer', 'resource', - 'session', 'batch', 'api', 'export'] - - print("\n=== Blueprint Status ===") - for bp in expected_blueprints: - if bp in routes_by_blueprint: - print(f"✅ {bp:<10} - {len(routes_by_blueprint[bp])} routes") - else: - print(f"❌ {bp:<10} - FEHLT!") - - # Prüfe ob noch Routes direkt in app.py sind - if 'app' in routes_by_blueprint: - print(f"\n⚠️ WARNUNG: {len(routes_by_blueprint['app'])} Routes sind noch direkt in app.py!") - for route in routes_by_blueprint['app']: - print(f" - {route['rule']}") - -def test_route_accessibility(): - """Test ob wichtige Routes erreichbar sind""" - print("\n\n=== Route Erreichbarkeits-Test ===\n") - - test_client = app.test_client() - - # Wichtige Routes zum Testen (ohne Login) - public_routes = [ - ('GET', '/login', 'Login-Seite'), - ('GET', '/heartbeat', 'Session Heartbeat'), - ] - - for method, route, description in public_routes: - try: - if method == 'GET': - response = test_client.get(route) - elif method == 'POST': - response = test_client.post(route) - - status = "✅" if response.status_code in [200, 302, 401] else "❌" - print(f"{status} {method:<6} {route:<30} - Status: {response.status_code} ({description})") - - # Bei Fehler mehr Details - if response.status_code >= 400 and response.status_code != 401: - print(f" ⚠️ Fehler-Details: {response.data[:200]}") - - except Exception as e: - print(f"❌ {method:<6} {route:<30} - FEHLER: {str(e)}") - -def check_duplicate_routes(): - """Prüfe ob es doppelte Route-Definitionen gibt""" - print("\n\n=== Doppelte Routes Check ===\n") - - route_paths = {} - duplicates_found = False - - for rule in app.url_map.iter_rules(): - if rule.endpoint == 'static': - continue - - path = str(rule) - if path in route_paths: - print(f"⚠️ DUPLIKAT gefunden:") - print(f" Route: {path}") - print(f" 1. Endpoint: {route_paths[path]}") - print(f" 2. Endpoint: {rule.endpoint}") - duplicates_found = True - else: - route_paths[path] = rule.endpoint - - if not duplicates_found: - print("✅ Keine doppelten Routes gefunden!") - -def check_template_references(): - """Prüfe ob Template-Dateien für die Routes existieren""" - print("\n\n=== Template Verfügbarkeits-Check ===\n") - - template_dir = os.path.join(os.path.dirname(__file__), 'templates') - - # Sammle alle verfügbaren Templates - available_templates = [] - if os.path.exists(template_dir): - for root, dirs, files in os.walk(template_dir): - for file in files: - if file.endswith(('.html', '.jinja2')): - rel_path = os.path.relpath(os.path.join(root, file), template_dir) - available_templates.append(rel_path.replace('\\', '/')) - - print(f"Gefundene Templates: {len(available_templates)}") - - # Wichtige Templates prüfen - required_templates = [ - 'login.html', - 'index.html', - 'profile.html', - 'licenses.html', - 'customers.html', - 'resources.html', - 'sessions.html', - 'audit.html', - 'backups.html' - ] - - for template in required_templates: - if template in available_templates: - print(f"✅ {template}") - else: - print(f"❌ {template} - FEHLT!") - -if __name__ == "__main__": - print("🔍 Starte Blueprint-Verifizierung...\n") - - try: - test_all_routes() - test_route_accessibility() - check_duplicate_routes() - check_template_references() - - print("\n\n✅ Test abgeschlossen!") - - except Exception as e: - print(f"\n\n❌ Fehler beim Test: {str(e)}") - import traceback - traceback.print_exc() \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/test_blueprints.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/test_blueprints.py deleted file mode 100644 index 30710b4..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/test_blueprints.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -"""Test if blueprints can be imported successfully""" - -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -try: - from routes.auth_routes import auth_bp - print("✓ auth_routes blueprint imported successfully") - print(f" Routes: {[str(r) for r in auth_bp.url_values_defaults]}") -except Exception as e: - print(f"✗ Error importing auth_routes: {e}") - -try: - from routes.admin_routes import admin_bp - print("✓ admin_routes blueprint imported successfully") -except Exception as e: - print(f"✗ Error importing admin_routes: {e}") - -print("\nBlueprints are ready to use!") \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/__init__.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/__init__.py deleted file mode 100644 index 921d9bb..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Utils module initialization \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/audit.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/audit.py deleted file mode 100644 index b480547..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/audit.py +++ /dev/null @@ -1,37 +0,0 @@ -import logging -from flask import session, request -from psycopg2.extras import Json -from db import get_db_connection, get_db_cursor -from utils.network import get_client_ip - -logger = logging.getLogger(__name__) - - -def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): - """Log changes to the audit log""" - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - try: - username = session.get('username', 'system') - ip_address = get_client_ip() if request else None - user_agent = request.headers.get('User-Agent') if request else None - - # Debug logging - logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") - - # Convert dictionaries to JSONB - old_json = Json(old_values) if old_values else None - new_json = Json(new_values) if new_values else None - - cur.execute(""" - INSERT INTO audit_log - (username, action, entity_type, entity_id, old_values, new_values, - ip_address, user_agent, additional_info) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, (username, action, entity_type, entity_id, old_json, new_json, - ip_address, user_agent, additional_info)) - - conn.commit() - except Exception as e: - logger.error(f"Audit log error: {e}") - conn.rollback() \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/backup.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/backup.py deleted file mode 100644 index def2648..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/backup.py +++ /dev/null @@ -1,223 +0,0 @@ -import os -import time -import gzip -import logging -import subprocess -from pathlib import Path -from datetime import datetime -from zoneinfo import ZoneInfo -from cryptography.fernet import Fernet -from db import get_db_connection, get_db_cursor -from config import BACKUP_DIR, DATABASE_CONFIG, EMAIL_ENABLED, BACKUP_ENCRYPTION_KEY -from utils.audit import log_audit - -logger = logging.getLogger(__name__) - - -def get_or_create_encryption_key(): - """Get or create an encryption key""" - key_file = BACKUP_DIR / ".backup_key" - - # Try to read key from environment variable - if BACKUP_ENCRYPTION_KEY: - try: - # Validate the key - Fernet(BACKUP_ENCRYPTION_KEY.encode()) - return BACKUP_ENCRYPTION_KEY.encode() - except: - pass - - # If no valid key in ENV, check file - if key_file.exists(): - return key_file.read_bytes() - - # Create new key - key = Fernet.generate_key() - key_file.write_bytes(key) - logger.info("New backup encryption key created") - return key - - -def create_backup(backup_type="manual", created_by=None): - """Create an encrypted database backup""" - start_time = time.time() - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") - filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" - filepath = BACKUP_DIR / filename - - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - # Create backup entry - cur.execute(""" - INSERT INTO backup_history - (filename, filepath, backup_type, status, created_by, is_encrypted) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (filename, str(filepath), backup_type, 'in_progress', - created_by or 'system', True)) - backup_id = cur.fetchone()[0] - conn.commit() - - try: - # PostgreSQL dump command - dump_command = [ - 'pg_dump', - '-h', DATABASE_CONFIG['host'], - '-p', DATABASE_CONFIG['port'], - '-U', DATABASE_CONFIG['user'], - '-d', DATABASE_CONFIG['dbname'], - '--no-password', - '--verbose' - ] - - # Set PGPASSWORD - env = os.environ.copy() - env['PGPASSWORD'] = DATABASE_CONFIG['password'] - - # Execute dump - result = subprocess.run(dump_command, capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"pg_dump failed: {result.stderr}") - - dump_data = result.stdout.encode('utf-8') - - # Compress - compressed_data = gzip.compress(dump_data) - - # Encrypt - key = get_or_create_encryption_key() - f = Fernet(key) - encrypted_data = f.encrypt(compressed_data) - - # Save - filepath.write_bytes(encrypted_data) - - # Collect statistics - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") - tables_count = cur.fetchone()[0] - - cur.execute("SELECT SUM(n_live_tup) FROM pg_stat_user_tables") - records_count = cur.fetchone()[0] or 0 - - duration = time.time() - start_time - filesize = filepath.stat().st_size - - # Update backup entry - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute(""" - UPDATE backup_history - SET status = %s, filesize = %s, tables_count = %s, - records_count = %s, duration_seconds = %s - WHERE id = %s - """, ('success', filesize, tables_count, records_count, duration, backup_id)) - conn.commit() - - # Audit log - log_audit('BACKUP', 'database', backup_id, - additional_info=f"Backup created: {filename} ({filesize} bytes)") - - # Email notification (if configured) - send_backup_notification(True, filename, filesize, duration) - - logger.info(f"Backup successfully created: {filename}") - return True, filename - - except Exception as e: - # Log error - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute(""" - UPDATE backup_history - SET status = %s, error_message = %s, duration_seconds = %s - WHERE id = %s - """, ('failed', str(e), time.time() - start_time, backup_id)) - conn.commit() - - logger.error(f"Backup failed: {e}") - send_backup_notification(False, filename, error=str(e)) - - return False, str(e) - - -def restore_backup(backup_id, encryption_key=None): - """Restore a backup""" - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - # Get backup info - cur.execute(""" - SELECT filename, filepath, is_encrypted - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - raise Exception("Backup not found") - - filename, filepath, is_encrypted = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - raise Exception("Backup file not found") - - try: - # Read file - encrypted_data = filepath.read_bytes() - - # Decrypt - if is_encrypted: - key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() - try: - f = Fernet(key) - compressed_data = f.decrypt(encrypted_data) - except: - raise Exception("Decryption failed. Wrong password?") - else: - compressed_data = encrypted_data - - # Decompress - dump_data = gzip.decompress(compressed_data) - sql_commands = dump_data.decode('utf-8') - - # Restore database - restore_command = [ - 'psql', - '-h', DATABASE_CONFIG['host'], - '-p', DATABASE_CONFIG['port'], - '-U', DATABASE_CONFIG['user'], - '-d', DATABASE_CONFIG['dbname'], - '--no-password' - ] - - env = os.environ.copy() - env['PGPASSWORD'] = DATABASE_CONFIG['password'] - - result = subprocess.run(restore_command, input=sql_commands, - capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"Restore failed: {result.stderr}") - - # Audit log - log_audit('RESTORE', 'database', backup_id, - additional_info=f"Backup restored: {filename}") - - return True, "Backup successfully restored" - - except Exception as e: - logger.error(f"Restore failed: {e}") - return False, str(e) - - -def send_backup_notification(success, filename, filesize=None, duration=None, error=None): - """Send email notification (if configured)""" - if not EMAIL_ENABLED: - return - - # Email function prepared but disabled - # TODO: Implement when email server is configured - logger.info(f"Email notification prepared: Backup {'successful' if success else 'failed'}") \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/export.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/export.py deleted file mode 100644 index 0ccbd31..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/export.py +++ /dev/null @@ -1,127 +0,0 @@ -import pandas as pd -from io import BytesIO -from datetime import datetime -from zoneinfo import ZoneInfo -from openpyxl.utils import get_column_letter -from flask import send_file - - -def create_excel_export(data, columns, filename_prefix="export"): - """Create an Excel file from data""" - df = pd.DataFrame(data, columns=columns) - - # Create Excel file in memory - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Data') - - # Auto-adjust column widths - worksheet = writer.sheets['Data'] - for idx, col in enumerate(df.columns): - max_length = max(df[col].astype(str).map(len).max(), len(col)) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - # Generate filename with timestamp - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"{filename_prefix}_{timestamp}.xlsx" - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=filename - ) - - -def format_datetime_for_export(dt): - """Format datetime for export""" - if dt: - if isinstance(dt, str): - try: - dt = datetime.fromisoformat(dt) - except: - return dt - return dt.strftime('%Y-%m-%d %H:%M:%S') - return '' - - -def prepare_license_export_data(licenses): - """Prepare license data for export""" - export_data = [] - for license in licenses: - export_data.append([ - license[0], # ID - license[1], # Key - license[2], # Customer Name - license[3], # Email - 'Aktiv' if license[4] else 'Inaktiv', # Active - license[5], # Max Users - format_datetime_for_export(license[6]), # Valid From - format_datetime_for_export(license[7]), # Valid Until - format_datetime_for_export(license[8]), # Created At - license[9], # Device Limit - license[10] or 0, # Current Devices - 'Test' if license[11] else 'Full' # Is Test License - ]) - return export_data - - -def prepare_customer_export_data(customers): - """Prepare customer data for export""" - export_data = [] - for customer in customers: - export_data.append([ - customer[0], # ID - customer[1], # Name - customer[2], # Email - customer[3], # Company - customer[4], # Address - customer[5], # Phone - format_datetime_for_export(customer[6]), # Created At - customer[7] or 0, # License Count - customer[8] or 0 # Active License Count - ]) - return export_data - - -def prepare_session_export_data(sessions): - """Prepare session data for export""" - export_data = [] - for session in sessions: - export_data.append([ - session[0], # ID - session[1], # License Key - session[2], # Username - session[3], # Computer Name - session[4], # Hardware ID - format_datetime_for_export(session[5]), # Login Time - format_datetime_for_export(session[6]), # Last Activity - 'Aktiv' if session[7] else 'Beendet', # Active - session[8], # IP Address - session[9], # App Version - session[10], # Customer Name - session[11] # Email - ]) - return export_data - - -def prepare_audit_export_data(audit_logs): - """Prepare audit log data for export""" - export_data = [] - for log in audit_logs: - export_data.append([ - log['id'], - format_datetime_for_export(log['timestamp']), - log['username'], - log['action'], - log['entity_type'], - log['entity_id'] or '', - log['ip_address'] or '', - log['user_agent'] or '', - str(log['old_values']) if log['old_values'] else '', - str(log['new_values']) if log['new_values'] else '', - log['additional_info'] or '' - ]) - return export_data \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/license.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/license.py deleted file mode 100644 index 6c5cb7d..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/license.py +++ /dev/null @@ -1,50 +0,0 @@ -import re -import secrets -from datetime import datetime -from zoneinfo import ZoneInfo - - -def generate_license_key(license_type='full'): - """ - Generate a license key in format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ - - AF = Account Factory (Product identifier) - F/T = F for Full version, T for Test version - YYYY = Year - MM = Month - XXXX-YYYY-ZZZZ = Random alphanumeric characters - """ - # Allowed characters (without confusing ones like 0/O, 1/I/l) - chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - - # Date part - now = datetime.now(ZoneInfo("Europe/Berlin")) - date_part = now.strftime('%Y%m') - type_char = 'F' if license_type == 'full' else 'T' - - # Generate random parts (3 blocks of 4 characters) - parts = [] - for _ in range(3): - part = ''.join(secrets.choice(chars) for _ in range(4)) - parts.append(part) - - # Assemble key - key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" - - return key - - -def validate_license_key(key): - """ - Validate the License Key Format - Expected: AF-F-YYYYMM-XXXX-YYYY-ZZZZ or AF-T-YYYYMM-XXXX-YYYY-ZZZZ - """ - if not key: - return False - - # Pattern for the new format - # AF- (fixed) + F or T + - + 6 digits (YYYYMM) + - + 4 characters + - + 4 characters + - + 4 characters - pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' - - # Uppercase for comparison - return bool(re.match(pattern, key.upper())) \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/network.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/network.py deleted file mode 100644 index 3714331..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/network.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging -from flask import request - -logger = logging.getLogger(__name__) - - -def get_client_ip(): - """Get the real IP address of the client""" - # Debug logging - logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, " - f"X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, " - f"Remote-Addr: {request.remote_addr}") - - # Try X-Real-IP first (set by nginx) - if request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - # Then X-Forwarded-For - elif request.headers.get('X-Forwarded-For'): - # X-Forwarded-For can contain multiple IPs, take the first one - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - # Fallback to remote_addr - else: - return request.remote_addr \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/recaptcha.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/recaptcha.py deleted file mode 100644 index 344b545..0000000 --- a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/recaptcha.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging -import requests -import config - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False \ No newline at end of file diff --git a/cloud-init.yaml b/cloud-init.yaml deleted file mode 100644 index f9d3385..0000000 --- a/cloud-init.yaml +++ /dev/null @@ -1,255 +0,0 @@ -#cloud-config -package_update: true -package_upgrade: true -packages: - - apt-transport-https - - ca-certificates - - curl - - gnupg - - lsb-release - - ufw - - fail2ban - - git - -write_files: - - path: /root/install-docker.sh - permissions: '0755' - content: | - #!/bin/bash - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list - apt-get update - apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin - systemctl enable docker - systemctl start docker - - - path: /etc/ssl/certs/fullchain.pem - permissions: '0644' - content: | - -----BEGIN CERTIFICATE----- - MIIFKDCCBBCgAwIBAgISA3yPyKBqrYewZDI8pFbjQgs5MA0GCSqGSIb3DQEBCwUA - MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD - EwJSMzAeFw0yNTA2MjYyMjQ5MDJaFw0yNTA5MjQyMjQ5MDFaMBkxFzAVBgNVBAMT - DmludGVsc2lnaHQuZGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDC - 1HLwsBdUBayNJaJ7Wy1n8AeM6F7K0JAw6UQdW0sI8TNtOyZKaOrfTmKBgdxpBnFx - nj7QiIVu8bUczZGcQcKoOLH6X5cJtOvUQRBGzYHlWhCGi7M3JAKjQoKyGiT2uRiZ - P4JsJaVVOJyq1eO5c77TJa9jvAA0qfuWVTzLUDWM1oIJr8zyDHNTM7gK17c1p3XB - F3gGDGCdIj5o1oXJxdNzDgLTqJeqSGKLfLwOTsFiCCjntyVjcQCHaceCdGx4tC+F - Kcx/d5p+Jc6xj7pVvQoqP0Kg1YA6VkX9hLKUCiNlSHhQJbnj8rhfLPtMfHRoZjQT - oazP3Sq6DLGdKJ7TdL2nAgMBAAGjggJNMIICSTAOBgNVHQ8BAf8EBAMCBaAwHQYD - VR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0O - BBYEFHl38d4egKf7gkUvW3XKKNOmhQtzMB8GA1UdIwQYMBaAFBQusxe3WFbLrlAJ - QOYfr52LFMLGMFUGCCsGAQUFBwEBBEkwRzAhBggrBgEFBQcwAYYVaHR0cDovL3Iz - Lm8ubGVuY3Iub3JnMCIGCCsGAQUFBzAChhZodHRwOi8vcjMuaS5sZW5jci5vcmcv - MIGFBgNVHREEfjB8gg5pbnRlbHNpZ2h0LmRlgidhZG1pbi1wYW5lbC11bmRzby5p - bnRlbHNpZ2h0LmRlgidwa2ktc29mdHdhcmUtdW5kc28uaW50ZWxzaWdodC5kZYIS - d3d3LmludGVsc2lnaHQuZGWCHmNkOS03YTMyMS5pbnRlbHNpZ2h0LmRlMBMGA1Ud - IAQMMAowCAYGZ4EMAQIBMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHcAzxFPn/xF - z4pBaLc8BWh7G7KQJ7WUYYJapBgTyBmOSwAAAZA2NCNCAAAEAwBIMEYCIQCb4Rfu - RJTLkAqV8aG6HqQBFJBGqsLOd5a4cQQE8aAM0QIhAKRY5M8/HuDz8oSI3w0SyAKB - IPZ1cOyEaR2BcLc8JqsEAHUA8aLLMkJi8F4QbRcE7GL7GQZQ7ypXK5Wtj5jqF1FC - H0MAAAGQNjQjQwAABAMARjBEAiAdqzfZkNGBGWGQ8kfKQtE7iiAa6FNHnEhjW1Nu - GlYAFgIgCjRD9awGfJ4lMM8e2TBaA5dKkSsEgWKtGKTjvxkz2VEwDQYJKoZIhvcN - AQELBQADggEBAJX3KxSxdOBOiqW3pJTSEsABKh0h8B8kP5vUAXRzxVcGJY3aJb5Y - DqcTI9ykBQyJM1mB1/VFWZKkINB4p5KqLoY2EBxRj2qXnAhHzNrEptYFk16VQJcc - Xfhv6XKD9yPQTMsHBnfWGQxMYOZbLa5lZM0QLo7T+f8fBOl7u8CwRJZa7wA3Z3F3 - Kw0+0FHjBZOu9wt2U0B0BmUIe8GGNacTbP3JCUOQpMQJbhWnGJtVpEL8HT01qWcl - oZA3nSQm9yD1G6l5aJyIDGdQ4C3/VJ0T3ZlQGXECnQWxCuU6v2lOQXvnQGcSvN+v - kNiRMCT3tXgLhCcr/6daDKYNOJ3EAVIvNx0= - -----END CERTIFICATE----- - -----BEGIN CERTIFICATE----- - MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw - TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh - cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw - WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg - RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK - AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP - R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx - sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm - NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg - Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG - /kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC - AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB - Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA - FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw - AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw - Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB - gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W - PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl - ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz - CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm - lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4 - avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2 - yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O - yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids - hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+ - HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv - MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX - nLRbwHOoq7hHwg== - -----END CERTIFICATE----- - -----BEGIN CERTIFICATE----- - MIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/ - MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT - DkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow - TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh - cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwggIiMA0GCSqGSIb3DQEB - AQUAA4ICDwAwggIKAoICAQCt6CRz9BQ385ueK1coHIe+3LffOJCMbjzmV6B493XC - ov71am72AE8o295ohmxEk7axY/0UEmu/H9LqMZshwLLezUmgD5HwmJAp32sIGkeG - VPMDCa/Lr+TyTjnhOWgjf7lJJhiaYFBSqygRz0t0IQ1GRomrn1Ktu3R7DJK0bhrP - 4x6+wLpTABEZaHQKxZNljWhJXgxvTNKK6NXBmfAhYZ4+l4W0aMa8kU2Cz8lhCM6i - JnyYcPc9w9YaYJ2Gy1t3wgezPpNTItzPRMpT7p/NnDhqI9/gJvdFfZxgdmdPnTBw - Q5XgZbBB9X3YD8LhI8NsHL1A7a0u8UdL6fkv8R9p7RfC8IA3llXevPS11wUAZcBF - QYJxk4qN9bDYcBdQ0OZ2dOVFBLdCFPuS+iqQBFH2N5fjb9LKgIFrdWJaXEGz70kD - Dq6gIx1SBLyooZKwYvG3Di2E7GvcbnyLqHtCPF/Ky1r3eMZTLZ8PAJhyvggYgOn8 - aNT1+Fo/7+yzFKP8HUlTBRBqKu+8dacN2tGHKjWuiLkahY/xGpPwlKz1wP+4lBEB - VHM9I1cLH+2d7fkBATMqQQMmIaulslYkCBVHeZCDleVQpkq7T2RgwADVb8J3stW3 - e0MZF9HckdZXQPKPYK29oJi7xr5nTMPQDz3FuNhqNYY7JLdWkoLuuONFDgrHLRmd - TwIDAQABo4IBRjCCAUIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw - SwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5pZGVudHJ1 - c3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTEp7Gkeyxx - +tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEB - ATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQu - b3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0LmNvbS9E - U1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFHm0WeZ7tuXkAXOACIjIGlj26Ztu - MA0GCSqGSIb3DQEBCwUAA4IBAQBg4WZmUUxiK3EiwSr1mSWPpnDHVD1GVVxbOyZC - S8+Pf6vDf6tSgqYJ/mLDNtjfLwKy8RBcKwMxkBq5c1FqcTB4tL7IzCOLMCDH4XYP - K0LQ1d5sQNaKZBiJOUPb7oqfwJQVjDuTXl3hcqBhyz2HDvAPkCIPfcIwyhVhucHH - yN9mqPNgYWVGKF3cWQqEQ9ombqCr5ASCvSoEZL/YQM1Zv0j/RdZ5qf+ZwJttL3dP - +t4cpNAl0z7ly6XF/FMwkRFanNg56TjB8aXq0mEJPGBWQgOw7hCYPKNaBaHRPQUH - Lb6XBWI3p2gqQjFJ5KhSMN8mPgqhm8RlJmWWJUMlGsiVr3WE - -----END CERTIFICATE----- - - - path: /etc/ssl/private/privkey.pem - permissions: '0600' - content: | - -----BEGIN PRIVATE KEY----- - MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDC1HLwsBdUBayN - JaJ7Wy1n8AeM6F7K0JAw6UQdW0sI8TNtOyZKaOrfTmKBgdxpBnFxnj7QiIVu8bUc - zZGcQcKoOLH6X5cJtOvUQRBGzYHlWhCGi7M3JAKjQoKyGiT2uRiZP4JsJaVVOJyq - 1eO5c77TJa9jvAA0qfuWVTzLUDWM1oIJr8zyDHNTM7gK17c1p3XBF3gGDGCdIj5o - 1oXJxdNzDgLTqJeqSGKLfLwOTsFiCCjntyVjcQCHaceCdGx4tC+FKcx/d5p+Jc6x - j7pVvQoqP0Kg1YA6VkX9hLKUCiNlSHhQJbnj8rhfLPtMfHRoZjQToazP3Sq6DLGd - KJ7TdL2nAgMBAAECggEAAKJosDxdA6AQ1CvwQp8N1JL9ZAVqYf4Y9c9n6s+HFOBX - wPEsABHNdNAYQJnX5X8rcdXfQhwFKRBqR/0OKtaBEJ2yh9IzO6DKHsKcAsX2aEo8 - 2b+DFCJz7Ty2R7LJBt2oKJxLaVCJlH7nP2VglLK3oAMv9R0+9y1u7bxp4B5Xqkzm - LXnqkiN4MrnLJWLh2eIYcf0fJvL0xUmTQNXZa6PHzv8hfRcOkdJZGLFGRgABBXzi - Ek9/fTNwH0Rg8e6eTZdPzXOgkyQdRsHLQQa3j6DHKJKzP8kI1MKJ2yQELm15LT+E - 0U3QIDgxcKHBzOoKJFE/MzL+NXQ9s+vdT3f1mzLJiQKBgQDgfwOQLm2lUaNcDNgf - A+WLaL1a6ysEG2cDUiwsBSRRUH/5llMEbyFxdPw2sqdVsRkBBaHdJCINkDJGm/kI - /xvJxD3KcBVLSdmHq/qO4pbGxBDRNvzrRO5Yoaiv5xDk2rQF3lm1m3vWdI6YFhq3 - j8qxE4/YjHNQOqfr7a0j+3j9dQKBgQDeBcQD2y7k7KAyRAv5Sh8AjbDSNjvFz3hE - TnJcjeeuTfmKdOBCm5mDCH+vGhBczRoHO9vVnqxLO3dJOWHqf8z7BPTBU4Bpm6zt - 5CJWP5jCbQU8+S0g1vgdUBzRrXFE4I9ZxCvJ5k6mfzVOvPcb0OV2gJGcxPbg2xT5 - uTn7VRTq6wKBgQCGF5yE6DVdMoqh5kjQBjjIObKtXRtJpGxuJ2VDfNYP8Klu6zAZ - zP3hKrUQO0IKJBxOwT/D8VZ4IKLK7y0q3Fb8+rsCxJzPM7J5UtKbQPPOdAbRFPCA - J4fE/YJu4g/sUpTdxq3lVqJ9P4rJyg3JJfn8aRAMOuhhNu6VJ9BlBTe3rQKBgQCv - OHXzS9VV9WMfhpN/UR4Q+LAqwQUKW0HFCkkYiDK/jJ2YNMU+m9e8JUrZOxZ9N1gF - IHJyGppZTxI5y1swCRqfGf+JuR7TKzHD7RK0L7F1q8hJwFjJA4xflg0RRvk5hfQa - WX3rA7SnC2T7b7DlxnVu+j2KNz0BnmKlhEFVOx7CnQKBgCdHRsDGXJGmGqhG1sH8 - PHdT1vA0iKLiouI+/WxtJwA2Y3FKcHjzJz+lX6ucsW5V+dKZuIWKDvuJQsJb1qJb - yiuEZdWy5iLOON0m10AX3WyfxT8A5NWkCBVH6K6IYOiJcBFGVfGXpP3kc1g8NqKd - K1DU5qILAZENMZLGKJfrwyxm - -----END PRIVATE KEY----- - - - path: /root/deploy.sh - permissions: '0755' - content: | - #!/bin/bash - set -e - - # Clone repository - cd /opt - # IMPORTANT: Replace YOUR_GITHUB_TOKEN with a valid GitHub Personal Access Token with 'repo' permissions - GITHUB_TOKEN="YOUR_GITHUB_TOKEN" - git clone https://${GITHUB_TOKEN}@github.com/UserIsMH/v2-Docker.git - cd v2-Docker - - # Remove token from git config - git remote set-url origin https://github.com/UserIsMH/v2-Docker.git - - # Update nginx.conf with correct domains - sed -i 's/admin-panel-undso\.z5m7q9dk3ah2v1plx6ju\.com/admin-panel-undso.intelsight.de/g' v2_nginx/nginx.conf - sed -i 's/api-software-undso\.z5m7q9dk3ah2v1plx6ju\.com/api-software-undso.intelsight.de/g' v2_nginx/nginx.conf - - # Update .env file - sed -i 's/API_DOMAIN=.*/API_DOMAIN=api-software-undso.intelsight.de/' v2/.env - sed -i 's/ADMIN_PANEL_DOMAIN=.*/ADMIN_PANEL_DOMAIN=admin-panel-undso.intelsight.de/' v2/.env - - # Copy SSL certificates - mkdir -p v2_nginx/ssl - cp /etc/ssl/certs/fullchain.pem v2_nginx/ssl/ - cp /etc/ssl/private/privkey.pem v2_nginx/ssl/ - chmod 644 v2_nginx/ssl/fullchain.pem - chmod 600 v2_nginx/ssl/privkey.pem - - # Generate DH parameters if not exist - if [ ! -f v2_nginx/ssl/dhparam.pem ]; then - openssl dhparam -out v2_nginx/ssl/dhparam.pem 2048 - fi - - # Start Docker services - cd v2 - docker compose pull - docker compose up -d - - # Wait for services to be ready - sleep 30 - - # Check if services are running - docker compose ps - - # Enable auto-start - cat > /etc/systemd/system/docker-compose-app.service < /root/deployment.log - - reboot - -final_message: "The system is finally up, after $UPTIME seconds" \ No newline at end of file diff --git a/generate-secrets.py b/generate-secrets.py deleted file mode 100644 index 5259dc8..0000000 --- a/generate-secrets.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -import secrets -import string - -def generate_password(length=16): - """Generate a secure random password""" - alphabet = string.ascii_letters + string.digits + "!@#$%^&*" - return ''.join(secrets.choice(alphabet) for _ in range(length)) - -def generate_jwt_secret(length=64): - """Generate a secure JWT secret""" - return secrets.token_urlsafe(length) - -print("=== Generated Secure Secrets for Production ===") -print() -print("# PostgreSQL Database") -print(f"POSTGRES_PASSWORD={generate_password(20)}") -print() -print("# Admin Panel Users (save these securely!)") -print(f"ADMIN1_PASSWORD={generate_password(16)}") -print(f"ADMIN2_PASSWORD={generate_password(16)}") -print() -print("# JWT Secret") -print(f"JWT_SECRET={generate_jwt_secret()}") -print() -print("# Grafana") -print(f"GRAFANA_PASSWORD={generate_password(16)}") -print() -print("# For v2_lizenzserver/.env") -print(f"SECRET_KEY={secrets.token_hex(32)}") -print() -print("=== IMPORTANT ===") -print("1. Save these passwords securely") -print("2. Update both .env files with these values") -print("3. Never commit these to git") \ No newline at end of file diff --git a/lizenzserver/.env.example b/lizenzserver/.env.example deleted file mode 100644 index faf20c2..0000000 --- a/lizenzserver/.env.example +++ /dev/null @@ -1,30 +0,0 @@ -# Database Configuration -DB_PASSWORD=secure_password_change_this - -# Redis Configuration -REDIS_PASSWORD=redis_password_change_this - -# RabbitMQ Configuration -RABBITMQ_USER=admin -RABBITMQ_PASS=admin_password_change_this - -# JWT Configuration -JWT_SECRET=change_this_very_secret_key_in_production - -# Admin Configuration -ADMIN_SECRET=change_this_admin_secret -ADMIN_API_KEY=admin-key-change-in-production - -# Flask Environment -FLASK_ENV=production - -# Rate Limiting (optional overrides) -# DEFAULT_RATE_LIMIT_PER_MINUTE=60 -# DEFAULT_RATE_LIMIT_PER_HOUR=1000 -# DEFAULT_RATE_LIMIT_PER_DAY=10000 - -# Service URLs (for external access) -# AUTH_SERVICE_URL=http://localhost:5001 -# LICENSE_API_URL=http://localhost:5002 -# ANALYTICS_SERVICE_URL=http://localhost:5003 -# ADMIN_API_URL=http://localhost:5004 \ No newline at end of file diff --git a/lizenzserver/API_DOCUMENTATION.md b/lizenzserver/API_DOCUMENTATION.md deleted file mode 100644 index 380ea0b..0000000 --- a/lizenzserver/API_DOCUMENTATION.md +++ /dev/null @@ -1,561 +0,0 @@ -# License Server API Documentation - -## Overview - -The License Server provides a comprehensive API for managing software licenses, validating license keys, and tracking usage. The system consists of four main services: - -1. **Auth Service** - JWT token management and API authentication -2. **License API** - License validation and activation -3. **Admin API** - License management and administration -4. **Analytics Service** - Usage analytics and anomaly detection - -## Base URLs - -- Auth Service: `http://localhost:5001` -- License API: `http://localhost:5002` -- Analytics Service: `http://localhost:5003` -- Admin API: `http://localhost:5004` - -## Authentication - -### API Key Authentication - -Most endpoints require an API key in the `X-API-Key` header: - -``` -X-API-Key: sk_your_api_key_here -``` - -### JWT Authentication - -Some endpoints use JWT bearer tokens: - -``` -Authorization: Bearer your_jwt_token_here -``` - -## Auth Service Endpoints - -### Create Access Token - -Create JWT access token for license validation. - -**POST** `/api/v1/auth/token` - -**Headers:** -- `X-API-Key: required` - -**Request Body:** -```json -{ - "license_id": "string", - "hardware_id": "string" -} -``` - -**Response:** -```json -{ - "access_token": "string", - "refresh_token": "string", - "token_type": "Bearer", - "expires_in": 3600 -} -``` - -### Refresh Token - -Refresh an expired access token. - -**POST** `/api/v1/auth/refresh` - -**Request Body:** -```json -{ - "refresh_token": "string" -} -``` - -### Verify Token - -Verify token validity. - -**POST** `/api/v1/auth/verify` - -**Headers:** -- `Authorization: Bearer ` - -### Create API Key (Admin) - -Create new API key for client applications. - -**POST** `/api/v1/auth/api-key` - -**Headers:** -- `X-Admin-Secret: required` - -**Request Body:** -```json -{ - "client_name": "string", - "allowed_endpoints": ["array", "of", "endpoints"] -} -``` - -## License API Endpoints - -### Validate License - -Validate a license key with hardware ID. - -**POST** `/api/v1/license/validate` - -**Headers:** -- `X-API-Key: required` - -**Request Body:** -```json -{ - "license_key": "string", - "hardware_id": "string", - "app_version": "string (optional)" -} -``` - -**Response:** -```json -{ - "valid": true, - "license_id": "string", - "expires_at": "2024-12-31T23:59:59Z", - "features": ["feature1", "feature2"], - "limits": { - "max_devices": 5, - "current_devices": 2 - } -} -``` - -### Activate License - -Activate license on a new device. - -**POST** `/api/v1/license/activate` - -**Headers:** -- `X-API-Key: required` - -**Request Body:** -```json -{ - "license_key": "string", - "hardware_id": "string", - "device_name": "string (optional)", - "os_info": { - "name": "Windows", - "version": "10" - } -} -``` - -### Heartbeat - -Record license heartbeat (requires JWT). - -**POST** `/api/v1/license/heartbeat` - -**Headers:** -- `Authorization: Bearer ` - -**Request Body:** -```json -{ - "session_data": { - "custom": "data" - } -} -``` - -### Create Offline Token - -Generate offline validation token. - -**POST** `/api/v1/license/offline-token` - -**Headers:** -- `Authorization: Bearer ` - -**Request Body:** -```json -{ - "duration_hours": 24 -} -``` - -### Validate Offline Token - -Validate an offline token. - -**POST** `/api/v1/license/validate-offline` - -**Request Body:** -```json -{ - "token": "string" -} -``` - -## Admin API Endpoints - -### Create License - -Create a new license. - -**POST** `/api/v1/admin/licenses` - -**Headers:** -- `X-Admin-API-Key: required` - -**Request Body:** -```json -{ - "customer_id": "string", - "max_devices": 5, - "expires_in_days": 365, - "features": ["feature1", "feature2"], - "is_test": false, - "metadata": { - "custom": "data" - } -} -``` - -### Get License - -Get license details with statistics. - -**GET** `/api/v1/admin/licenses/{license_id}` - -**Headers:** -- `X-Admin-API-Key: required` - -### Update License - -Update license properties. - -**PATCH** `/api/v1/admin/licenses/{license_id}` - -**Headers:** -- `X-Admin-API-Key: required` - -**Request Body:** -```json -{ - "max_devices": 10, - "is_active": true, - "expires_at": "2025-12-31T23:59:59Z", - "features": ["new_feature"], - "metadata": {} -} -``` - -### Delete License - -Soft delete (deactivate) a license. - -**DELETE** `/api/v1/admin/licenses/{license_id}` - -**Headers:** -- `X-Admin-API-Key: required` - -### List Licenses - -Search and list licenses with filters. - -**GET** `/api/v1/admin/licenses` - -**Headers:** -- `X-Admin-API-Key: required` - -**Query Parameters:** -- `customer_id`: Filter by customer -- `is_active`: Filter by active status -- `is_test`: Filter test licenses -- `created_after`: Filter by creation date -- `created_before`: Filter by creation date -- `expires_after`: Filter by expiration -- `expires_before`: Filter by expiration -- `page`: Page number (default: 1) -- `per_page`: Items per page (default: 50, max: 100) - -### Get License Devices - -Get all devices for a license. - -**GET** `/api/v1/admin/licenses/{license_id}/devices` - -**Headers:** -- `X-Admin-API-Key: required` - -### Deactivate Device - -Deactivate a specific device. - -**POST** `/api/v1/admin/licenses/{license_id}/devices/deactivate` - -**Headers:** -- `X-Admin-API-Key: required` - -**Request Body:** -```json -{ - "hardware_id": "string", - "reason": "string (optional)" -} -``` - -### Transfer License - -Transfer license between devices. - -**POST** `/api/v1/admin/licenses/{license_id}/transfer` - -**Headers:** -- `X-Admin-API-Key: required` - -**Request Body:** -```json -{ - "from_hardware_id": "string", - "to_hardware_id": "string" -} -``` - -### Get License Events - -Get activation events for a license. - -**GET** `/api/v1/admin/licenses/{license_id}/events` - -**Headers:** -- `X-Admin-API-Key: required` - -**Query Parameters:** -- `hours`: Hours to look back (default: 24) - -### Get License Usage - -Get usage statistics for a license. - -**GET** `/api/v1/admin/licenses/{license_id}/usage` - -**Headers:** -- `X-Admin-API-Key: required` - -**Query Parameters:** -- `days`: Days to analyze (default: 30) - -### Bulk Create Licenses - -Create multiple licenses at once. - -**POST** `/api/v1/admin/licenses/bulk-create` - -**Headers:** -- `X-Admin-API-Key: required` - -**Request Body:** -```json -{ - "licenses": [ - { - "customer_id": "string", - "max_devices": 5, - "expires_in_days": 365 - } - ] -} -``` - -### Get Statistics - -Get overall license statistics. - -**GET** `/api/v1/admin/statistics` - -**Headers:** -- `X-Admin-API-Key: required` - -## Analytics Service Endpoints - -### Analyze Patterns - -Analyze usage patterns for a license. - -**GET** `/api/v1/analytics/licenses/{license_id}/patterns` - -**Headers:** -- `X-API-Key: required` - -**Query Parameters:** -- `days`: Days to analyze (default: 30) - -### Detect Anomalies - -Manually trigger anomaly detection. - -**POST** `/api/v1/analytics/licenses/{license_id}/anomalies/detect` - -**Headers:** -- `X-API-Key: required` - -### Get Risk Score - -Calculate risk score for a license. - -**GET** `/api/v1/analytics/licenses/{license_id}/risk-score` - -**Headers:** -- `X-API-Key: required` - -### Generate Usage Report - -Generate usage report for all licenses. - -**GET** `/api/v1/analytics/reports/usage` - -**Headers:** -- `X-API-Key: required` - -**Query Parameters:** -- `days`: Days to include (default: 30) - -### Get Dashboard Data - -Get analytics dashboard data. - -**GET** `/api/v1/analytics/dashboard` - -**Headers:** -- `X-API-Key: required` - -## Error Responses - -All endpoints use standard HTTP status codes and return errors in this format: - -```json -{ - "error": "Error message", - "error_code": "ERROR_CODE", - "details": {} -} -``` - -### Common Error Codes - -- `LICENSE_NOT_FOUND` - License key not found -- `LICENSE_INACTIVE` - License is deactivated -- `LICENSE_EXPIRED` - License has expired -- `DEVICE_LIMIT_EXCEEDED` - Device limit reached -- `ALREADY_ACTIVATED` - Already activated on device -- `INVALID_TOKEN` - Invalid JWT token -- `RATE_LIMIT_EXCEEDED` - Rate limit exceeded - -## Rate Limiting - -API requests are rate limited based on API key configuration: - -- Default: 60 requests per minute, 1000 per hour -- Rate limit headers are included in responses: - - `X-RateLimit-Limit`: Requests per minute - - `X-RateLimit-Remaining`: Remaining requests - - `Retry-After`: Seconds until retry (on 429 errors) - -## Webhooks - -The system publishes events to RabbitMQ for real-time processing: - -- `license.validated` - License validation successful -- `license.validation.failed` - License validation failed -- `license.activated` - New device activated -- `license.deactivated` - License deactivated -- `license.transferred` - License transferred -- `anomaly.detected` - Anomaly detected -- `device.deactivated` - Device deactivated - -## SDK Examples - -### Python - -```python -import requests - -# Initialize client -api_key = "sk_your_api_key" -base_url = "http://localhost:5002" - -# Validate license -response = requests.post( - f"{base_url}/api/v1/license/validate", - headers={"X-API-Key": api_key}, - json={ - "license_key": "LIC-XXXXXXXXXXXX", - "hardware_id": "device-123" - } -) - -if response.status_code == 200: - data = response.json() - if data["valid"]: - print("License is valid!") -``` - -### JavaScript - -```javascript -const apiKey = 'sk_your_api_key'; -const baseUrl = 'http://localhost:5002'; - -// Validate license -fetch(`${baseUrl}/api/v1/license/validate`, { - method: 'POST', - headers: { - 'X-API-Key': apiKey, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - license_key: 'LIC-XXXXXXXXXXXX', - hardware_id: 'device-123' - }) -}) -.then(response => response.json()) -.then(data => { - if (data.valid) { - console.log('License is valid!'); - } -}); -``` - -## Best Practices - -1. **Caching**: Validation results are cached for 5 minutes. Use heartbeats for real-time tracking. - -2. **Offline Support**: Generate offline tokens for temporary offline validation. - -3. **Security**: - - Always use HTTPS in production - - Rotate API keys regularly - - Monitor for anomalies - -4. **Rate Limiting**: Implement exponential backoff on 429 errors. - -5. **Error Handling**: Always check error codes and handle appropriately. - -## Migration from v1 - -If migrating from a previous version: - -1. Update API endpoints to v1 paths -2. Add API key authentication -3. Update response parsing for new format -4. Implement heartbeat for session tracking \ No newline at end of file diff --git a/lizenzserver/Dockerfile.admin b/lizenzserver/Dockerfile.admin deleted file mode 100644 index 7745445..0000000 --- a/lizenzserver/Dockerfile.admin +++ /dev/null @@ -1,31 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - postgresql-client \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . /app/ - -# Create non-root user -RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app -USER appuser - -# Expose port -EXPOSE 5004 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:5004/health || exit 1 - -# Run the application -CMD ["python", "services/admin_api/app.py"] \ No newline at end of file diff --git a/lizenzserver/Dockerfile.analytics b/lizenzserver/Dockerfile.analytics deleted file mode 100644 index 10c99c1..0000000 --- a/lizenzserver/Dockerfile.analytics +++ /dev/null @@ -1,31 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - postgresql-client \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . /app/ - -# Create non-root user -RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app -USER appuser - -# Expose port -EXPOSE 5003 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:5003/health || exit 1 - -# Run the application -CMD ["python", "services/analytics/app.py"] \ No newline at end of file diff --git a/lizenzserver/Dockerfile.auth b/lizenzserver/Dockerfile.auth deleted file mode 100644 index 79dd22d..0000000 --- a/lizenzserver/Dockerfile.auth +++ /dev/null @@ -1,31 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - postgresql-client \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . /app/ - -# Create non-root user -RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app -USER appuser - -# Expose port -EXPOSE 5001 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:5001/health || exit 1 - -# Run the application -CMD ["python", "services/auth/app.py"] \ No newline at end of file diff --git a/lizenzserver/Dockerfile.license b/lizenzserver/Dockerfile.license deleted file mode 100644 index 3cf555b..0000000 --- a/lizenzserver/Dockerfile.license +++ /dev/null @@ -1,31 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - postgresql-client \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . /app/ - -# Create non-root user -RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app -USER appuser - -# Expose port -EXPOSE 5002 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:5002/health || exit 1 - -# Run the application -CMD ["python", "services/license_api/app.py"] \ No newline at end of file diff --git a/lizenzserver/Makefile b/lizenzserver/Makefile deleted file mode 100644 index 1976dec..0000000 --- a/lizenzserver/Makefile +++ /dev/null @@ -1,86 +0,0 @@ -.PHONY: help build up down restart logs ps clean test - -# Default target -help: - @echo "License Server Management Commands:" - @echo " make build - Build all Docker images" - @echo " make up - Start all services" - @echo " make down - Stop all services" - @echo " make restart - Restart all services" - @echo " make logs - View logs from all services" - @echo " make ps - List running containers" - @echo " make clean - Remove containers and volumes" - @echo " make test - Run tests" - @echo " make init-db - Initialize database schema" - -# Build all Docker images -build: - docker-compose build - -# Start all services -up: - docker-compose up -d - @echo "Waiting for services to be healthy..." - @sleep 10 - @echo "Services are running!" - @echo "Auth Service: http://localhost:5001" - @echo "License API: http://localhost:5002" - @echo "Analytics: http://localhost:5003" - @echo "Admin API: http://localhost:5004" - @echo "RabbitMQ Management: http://localhost:15672" - -# Stop all services -down: - docker-compose down - -# Restart all services -restart: down up - -# View logs -logs: - docker-compose logs -f - -# List containers -ps: - docker-compose ps - -# Clean up everything -clean: - docker-compose down -v - docker system prune -f - -# Run tests -test: - @echo "Running API tests..." - @python tests/test_api.py - -# Initialize database -init-db: - @echo "Initializing database schema..." - docker-compose exec postgres psql -U license_admin -d licenses -f /docker-entrypoint-initdb.d/init.sql - -# Service-specific commands -logs-auth: - docker-compose logs -f auth_service - -logs-license: - docker-compose logs -f license_api - -logs-analytics: - docker-compose logs -f analytics_service - -logs-admin: - docker-compose logs -f admin_api - -# Development commands -dev: - docker-compose -f docker-compose.yml -f docker-compose.dev.yml up - -shell-auth: - docker-compose exec auth_service /bin/bash - -shell-license: - docker-compose exec license_api /bin/bash - -shell-db: - docker-compose exec postgres psql -U license_admin -d licenses \ No newline at end of file diff --git a/lizenzserver/README.md b/lizenzserver/README.md deleted file mode 100644 index 59dda9d..0000000 --- a/lizenzserver/README.md +++ /dev/null @@ -1,244 +0,0 @@ -# License Server - -A comprehensive microservices-based license management system for software licensing, validation, and analytics. - -## Features - -- **License Management**: Create, update, and manage software licenses -- **Hardware-based Validation**: Bind licenses to specific devices -- **Offline Support**: Generate offline validation tokens -- **Analytics**: Track usage patterns and detect anomalies -- **Rate Limiting**: Protect APIs with configurable rate limits -- **Event-driven Architecture**: Real-time event processing with RabbitMQ -- **Caching**: Redis-based caching for improved performance -- **Security**: JWT authentication, API key management, and audit logging - -## Architecture - -The system consists of four microservices: - -1. **Auth Service** (Port 5001): JWT token management and API authentication -2. **License API** (Port 5002): License validation and activation -3. **Analytics Service** (Port 5003): Usage analytics and anomaly detection -4. **Admin API** (Port 5004): License administration and management - -## Quick Start - -### Prerequisites - -- Docker and Docker Compose -- Make (optional, for using Makefile commands) -- Python 3.11+ (for local development) - -### Installation - -1. Clone the repository: -```bash -git clone -cd lizenzserver -``` - -2. Copy environment variables: -```bash -cp .env.example .env -# Edit .env with your configuration -``` - -3. Build and start services: -```bash -make build -make up -``` - -Or without Make: -```bash -docker-compose build -docker-compose up -d -``` - -4. Initialize the database: -```bash -make init-db -``` - -### Verify Installation - -Check service health: -```bash -curl http://localhost:5001/health -curl http://localhost:5002/health -curl http://localhost:5003/health -curl http://localhost:5004/health -``` - -## Usage - -### Creating a License - -```bash -curl -X POST http://localhost:5004/api/v1/admin/licenses \ - -H "X-Admin-API-Key: your-admin-key" \ - -H "Content-Type: application/json" \ - -d '{ - "customer_id": "cust-123", - "max_devices": 5, - "expires_in_days": 365, - "features": ["premium", "support"] - }' -``` - -### Validating a License - -```bash -curl -X POST http://localhost:5002/api/v1/license/validate \ - -H "X-API-Key: your-api-key" \ - -H "Content-Type: application/json" \ - -d '{ - "license_key": "LIC-XXXXXXXXXXXX", - "hardware_id": "device-123" - }' -``` - -## API Documentation - -Detailed API documentation is available in [API_DOCUMENTATION.md](API_DOCUMENTATION.md). - -## Configuration - -### Environment Variables - -Key configuration options in `.env`: - -- `DB_PASSWORD`: PostgreSQL password -- `REDIS_PASSWORD`: Redis password -- `JWT_SECRET`: Secret key for JWT tokens -- `ADMIN_API_KEY`: Admin API authentication key -- `FLASK_ENV`: Flask environment (development/production) - -### Rate Limiting - -Default rate limits: -- 60 requests per minute -- 1000 requests per hour -- 10000 requests per day - -Configure per API key in the database. - -## Development - -### Running Locally - -1. Install dependencies: -```bash -pip install -r requirements.txt -``` - -2. Set environment variables: -```bash -export DATABASE_URL=postgresql://user:pass@localhost:5432/licenses -export REDIS_URL=redis://localhost:6379 -export RABBITMQ_URL=amqp://guest:guest@localhost:5672 -``` - -3. Run a service: -```bash -python services/license_api/app.py -``` - -### Testing - -Run tests: -```bash -make test -``` - -### Database Migrations - -The database schema is in `init.sql`. Apply migrations: -```bash -docker-compose exec postgres psql -U license_admin -d licenses -f /path/to/migration.sql -``` - -## Monitoring - -### Logs - -View logs for all services: -```bash -make logs -``` - -View logs for specific service: -```bash -make logs-auth -make logs-license -make logs-analytics -make logs-admin -``` - -### Metrics - -Services expose Prometheus metrics at `/metrics` endpoint. - -### RabbitMQ Management - -Access RabbitMQ management UI at http://localhost:15672 -- Username: admin (or configured value) -- Password: admin_password (or configured value) - -## Security - -### Best Practices - -1. **Change default passwords** in production -2. **Use HTTPS** in production (configure in nginx.conf) -3. **Rotate API keys** regularly -4. **Monitor anomalies** through the analytics service -5. **Set up IP whitelisting** for admin endpoints -6. **Enable audit logging** for compliance - -### API Key Management - -Create API keys through the Auth Service: -```bash -curl -X POST http://localhost:5001/api/v1/auth/api-key \ - -H "X-Admin-Secret: your-admin-secret" \ - -H "Content-Type: application/json" \ - -d '{ - "client_name": "My Application", - "allowed_endpoints": ["license.validate", "license.activate"] - }' -``` - -## Troubleshooting - -### Common Issues - -1. **Services not starting**: Check logs with `docker-compose logs ` -2. **Database connection errors**: Ensure PostgreSQL is healthy and credentials are correct -3. **Rate limit errors**: Check rate limit configuration and API key limits -4. **Cache misses**: Verify Redis connection and TTL settings - -### Health Checks - -All services provide health endpoints: -- Auth: http://localhost:5001/health -- License: http://localhost:5002/health -- Analytics: http://localhost:5003/health -- Admin: http://localhost:5004/health - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Commit your changes -4. Push to the branch -5. Create a Pull Request - -## License - -[Your License Here] - -## Support - -For support, please contact [support@example.com] or create an issue in the repository. \ No newline at end of file diff --git a/lizenzserver/config.py b/lizenzserver/config.py deleted file mode 100644 index ef90505..0000000 --- a/lizenzserver/config.py +++ /dev/null @@ -1,89 +0,0 @@ -import os -from datetime import timedelta - -class Config: - """Base configuration with sensible defaults""" - - # Database - DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql://admin:adminpass@localhost:5432/v2') - - # Redis - REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379') - - # RabbitMQ - RABBITMQ_URL = os.getenv('RABBITMQ_URL', 'amqp://guest:guest@localhost:5672') - - # JWT - JWT_SECRET = os.getenv('JWT_SECRET', 'change-this-in-production') - JWT_ALGORITHM = 'HS256' - JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) - JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) - - # API Rate Limiting - DEFAULT_RATE_LIMIT_PER_MINUTE = 60 - DEFAULT_RATE_LIMIT_PER_HOUR = 1000 - DEFAULT_RATE_LIMIT_PER_DAY = 10000 - - # Offline tokens - MAX_OFFLINE_TOKEN_DURATION_HOURS = 72 - DEFAULT_OFFLINE_TOKEN_DURATION_HOURS = 24 - - # Heartbeat settings - HEARTBEAT_INTERVAL_SECONDS = 300 # 5 minutes - HEARTBEAT_TIMEOUT_SECONDS = 900 # 15 minutes - - # Session settings - MAX_CONCURRENT_SESSIONS = 1 - SESSION_TIMEOUT_MINUTES = 30 - - # Cache TTL - CACHE_TTL_VALIDATION = 300 # 5 minutes - CACHE_TTL_LICENSE_STATUS = 60 # 1 minute - CACHE_TTL_DEVICE_LIST = 300 # 5 minutes - - # Anomaly detection thresholds - ANOMALY_RAPID_HARDWARE_CHANGE_MINUTES = 10 - ANOMALY_MULTIPLE_IPS_THRESHOLD = 5 - ANOMALY_GEO_DISTANCE_KM = 1000 - - # Logging - LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') - LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - - # Service ports - AUTH_SERVICE_PORT = int(os.getenv('PORT', 5001)) - LICENSE_API_PORT = int(os.getenv('PORT', 5002)) - ANALYTICS_SERVICE_PORT = int(os.getenv('PORT', 5003)) - ADMIN_API_PORT = int(os.getenv('PORT', 5004)) - -class DevelopmentConfig(Config): - """Development configuration""" - DEBUG = True - TESTING = False - -class ProductionConfig(Config): - """Production configuration""" - DEBUG = False - TESTING = False - - # Override with production values - JWT_SECRET = os.environ['JWT_SECRET'] # Required in production - -class TestingConfig(Config): - """Testing configuration""" - DEBUG = True - TESTING = True - DATABASE_URL = 'postgresql://admin:adminpass@localhost:5432/v2_test' - -# Configuration dictionary -config = { - 'development': DevelopmentConfig, - 'production': ProductionConfig, - 'testing': TestingConfig, - 'default': DevelopmentConfig -} - -def get_config(): - """Get configuration based on environment""" - env = os.getenv('FLASK_ENV', 'development') - return config.get(env, config['default']) \ No newline at end of file diff --git a/lizenzserver/docker-compose.yaml b/lizenzserver/docker-compose.yaml deleted file mode 100644 index 8abf7d7..0000000 --- a/lizenzserver/docker-compose.yaml +++ /dev/null @@ -1,123 +0,0 @@ -version: '3.8' - -services: - license-auth: - build: ./services/auth - container_name: license-auth - environment: - - JWT_SECRET=${JWT_SECRET:-your-secret-key-change-in-production} - - DATABASE_URL=postgresql://admin:adminpass@postgres:5432/v2 - - REDIS_URL=redis://redis:6379 - - PORT=5001 - ports: - - "5001:5001" - depends_on: - - postgres - - redis - networks: - - v2_network - restart: unless-stopped - - license-api: - build: ./services/license_api - container_name: license-api - environment: - - DATABASE_URL=postgresql://admin:adminpass@postgres:5432/v2 - - REDIS_URL=redis://redis:6379 - - RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672 - - JWT_SECRET=${JWT_SECRET:-your-secret-key-change-in-production} - - PORT=5002 - ports: - - "5002:5002" - depends_on: - - postgres - - redis - - rabbitmq - networks: - - v2_network - restart: unless-stopped - - license-analytics: - build: ./services/analytics - container_name: license-analytics - environment: - - DATABASE_URL=postgresql://admin:adminpass@postgres:5432/v2 - - REDIS_URL=redis://redis:6379 - - RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672 - - PORT=5003 - ports: - - "5003:5003" - depends_on: - - postgres - - redis - - rabbitmq - networks: - - v2_network - restart: unless-stopped - - license-admin-api: - build: ./services/admin_api - container_name: license-admin-api - environment: - - DATABASE_URL=postgresql://admin:adminpass@postgres:5432/v2 - - REDIS_URL=redis://redis:6379 - - RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672 - - JWT_SECRET=${JWT_SECRET:-your-secret-key-change-in-production} - - PORT=5004 - ports: - - "5004:5004" - depends_on: - - postgres - - redis - - rabbitmq - networks: - - v2_network - restart: unless-stopped - - postgres: - image: postgres:15-alpine - container_name: license-postgres - environment: - - POSTGRES_DB=v2 - - POSTGRES_USER=admin - - POSTGRES_PASSWORD=adminpass - volumes: - - postgres_data:/var/lib/postgresql/data - - ./init.sql:/docker-entrypoint-initdb.d/init.sql - networks: - - v2_network - restart: unless-stopped - - redis: - image: redis:7-alpine - container_name: license-redis - command: redis-server --appendonly yes - volumes: - - redis_data:/data - networks: - - v2_network - restart: unless-stopped - - rabbitmq: - image: rabbitmq:3-management-alpine - container_name: license-rabbitmq - environment: - - RABBITMQ_DEFAULT_USER=guest - - RABBITMQ_DEFAULT_PASS=guest - ports: - - "5672:5672" - - "15672:15672" - volumes: - - rabbitmq_data:/var/lib/rabbitmq - networks: - - v2_network - restart: unless-stopped - -volumes: - postgres_data: - redis_data: - rabbitmq_data: - -networks: - v2_network: - external: true \ No newline at end of file diff --git a/lizenzserver/docker-compose.yml b/lizenzserver/docker-compose.yml deleted file mode 100644 index e774469..0000000 --- a/lizenzserver/docker-compose.yml +++ /dev/null @@ -1,191 +0,0 @@ -version: '3.8' - -services: - # PostgreSQL Database - postgres: - image: postgres:15-alpine - container_name: license_postgres - environment: - POSTGRES_DB: licenses - POSTGRES_USER: license_admin - POSTGRES_PASSWORD: ${DB_PASSWORD:-secure_password} - volumes: - - postgres_data:/var/lib/postgresql/data - - ./init.sql:/docker-entrypoint-initdb.d/init.sql - ports: - - "5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U license_admin -d licenses"] - interval: 10s - timeout: 5s - retries: 5 - - # Redis Cache - redis: - image: redis:7-alpine - container_name: license_redis - command: redis-server --requirepass ${REDIS_PASSWORD:-redis_password} - ports: - - "6379:6379" - volumes: - - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - # RabbitMQ Message Broker - rabbitmq: - image: rabbitmq:3-management-alpine - container_name: license_rabbitmq - environment: - RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin} - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-admin_password} - ports: - - "5672:5672" - - "15672:15672" # Management UI - volumes: - - rabbitmq_data:/var/lib/rabbitmq - healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - # Auth Service - auth_service: - build: - context: . - dockerfile: Dockerfile.auth - container_name: license_auth - environment: - DATABASE_URL: postgresql://license_admin:${DB_PASSWORD:-secure_password}@postgres:5432/licenses - REDIS_URL: redis://:${REDIS_PASSWORD:-redis_password}@redis:6379 - RABBITMQ_URL: amqp://${RABBITMQ_USER:-admin}:${RABBITMQ_PASS:-admin_password}@rabbitmq:5672 - JWT_SECRET: ${JWT_SECRET:-change_this_in_production} - ADMIN_SECRET: ${ADMIN_SECRET:-change_this_admin_secret} - FLASK_ENV: ${FLASK_ENV:-production} - PORT: 5001 - ports: - - "5001:5001" - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - rabbitmq: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5001/health"] - interval: 30s - timeout: 10s - retries: 3 - - # License API Service - license_api: - build: - context: . - dockerfile: Dockerfile.license - container_name: license_api - environment: - DATABASE_URL: postgresql://license_admin:${DB_PASSWORD:-secure_password}@postgres:5432/licenses - REDIS_URL: redis://:${REDIS_PASSWORD:-redis_password}@redis:6379 - RABBITMQ_URL: amqp://${RABBITMQ_USER:-admin}:${RABBITMQ_PASS:-admin_password}@rabbitmq:5672 - JWT_SECRET: ${JWT_SECRET:-change_this_in_production} - FLASK_ENV: ${FLASK_ENV:-production} - PORT: 5002 - ports: - - "5002:5002" - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - rabbitmq: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5002/health"] - interval: 30s - timeout: 10s - retries: 3 - - # Analytics Service - analytics_service: - build: - context: . - dockerfile: Dockerfile.analytics - container_name: license_analytics - environment: - DATABASE_URL: postgresql://license_admin:${DB_PASSWORD:-secure_password}@postgres:5432/licenses - REDIS_URL: redis://:${REDIS_PASSWORD:-redis_password}@redis:6379 - RABBITMQ_URL: amqp://${RABBITMQ_USER:-admin}:${RABBITMQ_PASS:-admin_password}@rabbitmq:5672 - FLASK_ENV: ${FLASK_ENV:-production} - PORT: 5003 - ports: - - "5003:5003" - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - rabbitmq: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5003/health"] - interval: 30s - timeout: 10s - retries: 3 - - # Admin API Service - admin_api: - build: - context: . - dockerfile: Dockerfile.admin - container_name: license_admin_api - environment: - DATABASE_URL: postgresql://license_admin:${DB_PASSWORD:-secure_password}@postgres:5432/licenses - REDIS_URL: redis://:${REDIS_PASSWORD:-redis_password}@redis:6379 - RABBITMQ_URL: amqp://${RABBITMQ_USER:-admin}:${RABBITMQ_PASS:-admin_password}@rabbitmq:5672 - ADMIN_API_KEY: ${ADMIN_API_KEY:-admin-key-change-in-production} - FLASK_ENV: ${FLASK_ENV:-production} - PORT: 5004 - ports: - - "5004:5004" - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - rabbitmq: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5004/health"] - interval: 30s - timeout: 10s - retries: 3 - - # Nginx Reverse Proxy - nginx: - image: nginx:alpine - container_name: license_nginx - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro - ports: - - "80:80" - - "443:443" - depends_on: - - auth_service - - license_api - - analytics_service - - admin_api - healthcheck: - test: ["CMD", "nginx", "-t"] - interval: 30s - timeout: 10s - retries: 3 - -volumes: - postgres_data: - redis_data: - rabbitmq_data: \ No newline at end of file diff --git a/lizenzserver/events/__init__.py b/lizenzserver/events/__init__.py deleted file mode 100644 index 001c7e9..0000000 --- a/lizenzserver/events/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Events Module \ No newline at end of file diff --git a/lizenzserver/events/event_bus.py b/lizenzserver/events/event_bus.py deleted file mode 100644 index c0f391b..0000000 --- a/lizenzserver/events/event_bus.py +++ /dev/null @@ -1,191 +0,0 @@ -import json -import logging -from typing import Dict, Any, Callable, List -from datetime import datetime -import pika -from pika.exceptions import AMQPConnectionError -import threading -from collections import defaultdict - -logger = logging.getLogger(__name__) - -class Event: - """Base event class""" - def __init__(self, event_type: str, data: Dict[str, Any], source: str = "unknown"): - self.id = self._generate_id() - self.type = event_type - self.data = data - self.source = source - self.timestamp = datetime.utcnow().isoformat() - - def _generate_id(self) -> str: - import uuid - return str(uuid.uuid4()) - - def to_dict(self) -> Dict[str, Any]: - return { - "id": self.id, - "type": self.type, - "data": self.data, - "source": self.source, - "timestamp": self.timestamp - } - - def to_json(self) -> str: - return json.dumps(self.to_dict()) - -class EventBus: - """Event bus for pub/sub pattern with RabbitMQ backend""" - - def __init__(self, rabbitmq_url: str): - self.rabbitmq_url = rabbitmq_url - self.connection = None - self.channel = None - self.exchange_name = "license_events" - self.local_handlers: Dict[str, List[Callable]] = defaultdict(list) - self._connect() - - def _connect(self): - """Establish connection to RabbitMQ""" - try: - parameters = pika.URLParameters(self.rabbitmq_url) - self.connection = pika.BlockingConnection(parameters) - self.channel = self.connection.channel() - - # Declare exchange - self.channel.exchange_declare( - exchange=self.exchange_name, - exchange_type='topic', - durable=True - ) - - logger.info("Connected to RabbitMQ") - except AMQPConnectionError as e: - logger.error(f"Failed to connect to RabbitMQ: {e}") - # Fallback to local-only event handling - self.connection = None - self.channel = None - - def publish(self, event: Event): - """Publish an event""" - try: - # Publish to RabbitMQ if connected - if self.channel and not self.channel.is_closed: - self.channel.basic_publish( - exchange=self.exchange_name, - routing_key=event.type, - body=event.to_json(), - properties=pika.BasicProperties( - delivery_mode=2, # Make message persistent - content_type='application/json' - ) - ) - logger.debug(f"Published event: {event.type}") - - # Also handle local subscribers - self._handle_local_event(event) - - except Exception as e: - logger.error(f"Error publishing event: {e}") - # Ensure local handlers still get called - self._handle_local_event(event) - - def subscribe(self, event_type: str, handler: Callable): - """Subscribe to an event type locally""" - self.local_handlers[event_type].append(handler) - logger.debug(f"Subscribed to {event_type}") - - def subscribe_queue(self, event_types: List[str], queue_name: str, handler: Callable): - """Subscribe to events via RabbitMQ queue""" - if not self.channel: - logger.warning("RabbitMQ not connected, falling back to local subscription") - for event_type in event_types: - self.subscribe(event_type, handler) - return - - try: - # Declare queue - self.channel.queue_declare(queue=queue_name, durable=True) - - # Bind queue to exchange for each event type - for event_type in event_types: - self.channel.queue_bind( - exchange=self.exchange_name, - queue=queue_name, - routing_key=event_type - ) - - # Set up consumer - def callback(ch, method, properties, body): - try: - event_data = json.loads(body) - event = Event( - event_type=event_data['type'], - data=event_data['data'], - source=event_data['source'] - ) - handler(event) - ch.basic_ack(delivery_tag=method.delivery_tag) - except Exception as e: - logger.error(f"Error handling event: {e}") - ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True) - - self.channel.basic_consume(queue=queue_name, on_message_callback=callback) - - # Start consuming in a separate thread - consumer_thread = threading.Thread(target=self.channel.start_consuming) - consumer_thread.daemon = True - consumer_thread.start() - - logger.info(f"Started consuming from queue: {queue_name}") - - except Exception as e: - logger.error(f"Error setting up queue subscription: {e}") - - def _handle_local_event(self, event: Event): - """Handle event with local subscribers""" - handlers = self.local_handlers.get(event.type, []) - for handler in handlers: - try: - handler(event) - except Exception as e: - logger.error(f"Error in event handler: {e}") - - def close(self): - """Close RabbitMQ connection""" - if self.connection and not self.connection.is_closed: - self.connection.close() - logger.info("Closed RabbitMQ connection") - -# Event types -class EventTypes: - """Centralized event type definitions""" - - # License events - LICENSE_VALIDATED = "license.validated" - LICENSE_VALIDATION_FAILED = "license.validation.failed" - LICENSE_ACTIVATED = "license.activated" - LICENSE_DEACTIVATED = "license.deactivated" - LICENSE_TRANSFERRED = "license.transferred" - LICENSE_EXPIRED = "license.expired" - LICENSE_CREATED = "license.created" - LICENSE_UPDATED = "license.updated" - - # Device events - DEVICE_ADDED = "device.added" - DEVICE_REMOVED = "device.removed" - DEVICE_BLOCKED = "device.blocked" - DEVICE_DEACTIVATED = "device.deactivated" - - # Anomaly events - ANOMALY_DETECTED = "anomaly.detected" - ANOMALY_RESOLVED = "anomaly.resolved" - - # Session events - SESSION_STARTED = "session.started" - SESSION_ENDED = "session.ended" - SESSION_EXPIRED = "session.expired" - - # System events - RATE_LIMIT_EXCEEDED = "system.rate_limit_exceeded" - API_ERROR = "system.api_error" \ No newline at end of file diff --git a/lizenzserver/init.sql b/lizenzserver/init.sql deleted file mode 100644 index 75ac4aa..0000000 --- a/lizenzserver/init.sql +++ /dev/null @@ -1,177 +0,0 @@ --- License Server Database Schema --- 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 UUID 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 UUID 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 license_heartbeats_2025_01 PARTITION OF license_heartbeats - FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); - -CREATE TABLE license_heartbeats_2025_02 PARTITION OF license_heartbeats - FOR VALUES FROM ('2025-02-01') TO ('2025-03-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 UUID 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 UUID 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 UUID[], - blacklist_license_ids UUID[], - 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 UUID 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; - --- Create a scheduled job to create partitions (requires pg_cron extension) --- This is a placeholder - actual scheduling depends on your PostgreSQL setup --- SELECT cron.schedule('create-partitions', '0 0 1 * *', 'SELECT create_monthly_partition();'); \ No newline at end of file diff --git a/lizenzserver/middleware/__init__.py b/lizenzserver/middleware/__init__.py deleted file mode 100644 index b62ab17..0000000 --- a/lizenzserver/middleware/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Middleware Module \ No newline at end of file diff --git a/lizenzserver/middleware/rate_limiter.py b/lizenzserver/middleware/rate_limiter.py deleted file mode 100644 index 59bdd9c..0000000 --- a/lizenzserver/middleware/rate_limiter.py +++ /dev/null @@ -1,158 +0,0 @@ -import time -from functools import wraps -from flask import request, jsonify -import redis -from typing import Optional, Tuple -import logging - -logger = logging.getLogger(__name__) - -class RateLimiter: - """Rate limiting middleware using Redis""" - - def __init__(self, redis_url: str): - self.redis_client = None - try: - self.redis_client = redis.from_url(redis_url, decode_responses=True) - self.redis_client.ping() - logger.info("Connected to Redis for rate limiting") - except Exception as e: - logger.warning(f"Redis not available for rate limiting: {e}") - - def limit(self, requests_per_minute: int = 60, requests_per_hour: int = 1000): - """Decorator for rate limiting endpoints""" - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if not self.redis_client: - # Redis not available, skip rate limiting - return f(*args, **kwargs) - - # Get client identifier (API key or IP) - client_id = self._get_client_id() - - # Check rate limits - is_allowed, retry_after = self._check_rate_limit( - client_id, - requests_per_minute, - requests_per_hour - ) - - if not is_allowed: - response = jsonify({ - "error": "Rate limit exceeded", - "retry_after": retry_after - }) - response.status_code = 429 - response.headers['Retry-After'] = str(retry_after) - response.headers['X-RateLimit-Limit'] = str(requests_per_minute) - return response - - # Add rate limit headers - response = f(*args, **kwargs) - if hasattr(response, 'headers'): - response.headers['X-RateLimit-Limit'] = str(requests_per_minute) - response.headers['X-RateLimit-Remaining'] = str( - self._get_remaining_requests(client_id, requests_per_minute) - ) - - return response - - return decorated_function - return decorator - - def _get_client_id(self) -> str: - """Get client identifier from request""" - # First try API key - api_key = request.headers.get('X-API-Key') - if api_key: - return f"api_key:{api_key}" - - # Then try auth token - auth_header = request.headers.get('Authorization') - if auth_header and auth_header.startswith('Bearer '): - return f"token:{auth_header[7:32]}" # Use first 32 chars of token - - # Fallback to IP - if request.headers.get('X-Forwarded-For'): - ip = request.headers.get('X-Forwarded-For').split(',')[0] - else: - ip = request.remote_addr - - return f"ip:{ip}" - - def _check_rate_limit(self, client_id: str, - requests_per_minute: int, - requests_per_hour: int) -> Tuple[bool, Optional[int]]: - """Check if request is within rate limits""" - now = int(time.time()) - - # Check minute limit - minute_key = f"rate_limit:minute:{client_id}:{now // 60}" - minute_count = self.redis_client.incr(minute_key) - self.redis_client.expire(minute_key, 60) - - if minute_count > requests_per_minute: - retry_after = 60 - (now % 60) - return False, retry_after - - # Check hour limit - hour_key = f"rate_limit:hour:{client_id}:{now // 3600}" - hour_count = self.redis_client.incr(hour_key) - self.redis_client.expire(hour_key, 3600) - - if hour_count > requests_per_hour: - retry_after = 3600 - (now % 3600) - return False, retry_after - - return True, None - - def _get_remaining_requests(self, client_id: str, limit: int) -> int: - """Get remaining requests in current minute""" - now = int(time.time()) - minute_key = f"rate_limit:minute:{client_id}:{now // 60}" - - try: - current_count = int(self.redis_client.get(minute_key) or 0) - return max(0, limit - current_count) - except: - return limit - -class APIKeyRateLimiter(RateLimiter): - """Extended rate limiter with API key specific limits""" - - def __init__(self, redis_url: str, db_repo): - super().__init__(redis_url) - self.db_repo = db_repo - - def limit_by_api_key(self): - """Rate limit based on API key configuration""" - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - api_key = request.headers.get('X-API-Key') - - if not api_key: - # Use default limits for non-API key requests - return self.limit()(f)(*args, **kwargs) - - # Get API key configuration from database - query = """ - SELECT rate_limit_per_minute, rate_limit_per_hour - FROM api_clients - WHERE api_key = %s AND is_active = true - """ - - client = self.db_repo.execute_one(query, (api_key,)) - - if not client: - return jsonify({"error": "Invalid API key"}), 401 - - # Use custom limits or defaults - rpm = client.get('rate_limit_per_minute', 60) - rph = client.get('rate_limit_per_hour', 1000) - - return self.limit(rpm, rph)(f)(*args, **kwargs) - - return decorated_function - return decorator \ No newline at end of file diff --git a/lizenzserver/models/__init__.py b/lizenzserver/models/__init__.py deleted file mode 100644 index e3541fe..0000000 --- a/lizenzserver/models/__init__.py +++ /dev/null @@ -1,127 +0,0 @@ -from datetime import datetime -from typing import Optional, List, Dict, Any -from dataclasses import dataclass, field -from enum import Enum - -class EventType(Enum): - """License event types""" - ACTIVATION = "activation" - DEACTIVATION = "deactivation" - REACTIVATION = "reactivation" - TRANSFER = "transfer" - -class AnomalyType(Enum): - """Anomaly detection types""" - MULTIPLE_IPS = "multiple_ips" - RAPID_HARDWARE_CHANGE = "rapid_hardware_change" - SUSPICIOUS_PATTERN = "suspicious_pattern" - CONCURRENT_USE = "concurrent_use" - GEO_ANOMALY = "geo_anomaly" - -class Severity(Enum): - """Anomaly severity levels""" - LOW = "low" - MEDIUM = "medium" - HIGH = "high" - CRITICAL = "critical" - -@dataclass -class License: - """License domain model""" - id: str - license_key: str - customer_id: str - max_devices: int - is_active: bool - is_test: bool - created_at: datetime - updated_at: datetime - expires_at: Optional[datetime] = None - features: List[str] = field(default_factory=list) - metadata: Dict[str, Any] = field(default_factory=dict) - -@dataclass -class LicenseToken: - """Offline validation token""" - id: str - license_id: str - token: str - hardware_id: str - valid_until: datetime - created_at: datetime - last_validated: Optional[datetime] = None - validation_count: int = 0 - -@dataclass -class Heartbeat: - """License heartbeat""" - id: int - license_id: str - hardware_id: str - ip_address: Optional[str] - user_agent: Optional[str] - app_version: Optional[str] - timestamp: datetime - session_data: Dict[str, Any] = field(default_factory=dict) - -@dataclass -class ActivationEvent: - """License activation event""" - id: str - license_id: str - event_type: EventType - hardware_id: Optional[str] - previous_hardware_id: Optional[str] - ip_address: Optional[str] - user_agent: Optional[str] - success: bool - error_message: Optional[str] - metadata: Dict[str, Any] = field(default_factory=dict) - created_at: datetime - -@dataclass -class AnomalyDetection: - """Detected anomaly""" - id: str - license_id: str - anomaly_type: AnomalyType - severity: Severity - details: Dict[str, Any] - detected_at: datetime - resolved: bool = False - resolved_at: Optional[datetime] = None - resolved_by: Optional[str] = None - action_taken: Optional[str] = None - -@dataclass -class Session: - """Active session""" - id: str - license_id: str - hardware_id: str - session_token: str - ip_address: Optional[str] - started_at: datetime - last_seen: datetime - expires_at: datetime - -@dataclass -class ValidationRequest: - """License validation request""" - license_key: str - hardware_id: str - app_version: Optional[str] = None - ip_address: Optional[str] = None - user_agent: Optional[str] = None - -@dataclass -class ValidationResponse: - """License validation response""" - valid: bool - license_id: Optional[str] = None - token: Optional[str] = None - expires_at: Optional[datetime] = None - features: List[str] = field(default_factory=list) - limits: Dict[str, Any] = field(default_factory=dict) - error: Optional[str] = None - error_code: Optional[str] = None \ No newline at end of file diff --git a/lizenzserver/nginx.conf b/lizenzserver/nginx.conf deleted file mode 100644 index 353ef0c..0000000 --- a/lizenzserver/nginx.conf +++ /dev/null @@ -1,167 +0,0 @@ -events { - worker_connections 1024; -} - -http { - # Basic settings - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - types_hash_max_size 2048; - - # Logging - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log; - - # Gzip compression - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss; - - # Rate limiting zones - limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; - limit_req_zone $http_x_api_key zone=key_limit:10m rate=100r/s; - - # Upstream services - upstream auth_service { - server auth_service:5001; - } - - upstream license_api { - server license_api:5002; - } - - upstream analytics_service { - server analytics_service:5003; - } - - upstream admin_api { - server admin_api:5004; - } - - # Main server block - server { - listen 80; - server_name localhost; - - # Security headers - add_header X-Content-Type-Options nosniff; - add_header X-Frame-Options DENY; - add_header X-XSS-Protection "1; mode=block"; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - - # API versioning and routing - location /api/v1/auth/ { - limit_req zone=api_limit burst=20 nodelay; - - proxy_pass http://auth_service/api/v1/auth/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # CORS headers - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-API-Key' always; - - if ($request_method = 'OPTIONS') { - return 204; - } - } - - location /api/v1/license/ { - limit_req zone=key_limit burst=50 nodelay; - - proxy_pass http://license_api/api/v1/license/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # CORS headers - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-API-Key' always; - - if ($request_method = 'OPTIONS') { - return 204; - } - } - - location /api/v1/analytics/ { - limit_req zone=key_limit burst=30 nodelay; - - proxy_pass http://analytics_service/api/v1/analytics/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # CORS headers - add_header 'Access-Control-Allow-Origin' '*' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-API-Key' always; - - if ($request_method = 'OPTIONS') { - return 204; - } - } - - location /api/v1/admin/ { - limit_req zone=key_limit burst=30 nodelay; - - # Additional security for admin endpoints - # In production, add IP whitelisting here - - proxy_pass http://admin_api/api/v1/admin/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # CORS headers (more restrictive for admin) - add_header 'Access-Control-Allow-Origin' '$http_origin' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Admin-API-Key' always; - add_header 'Access-Control-Allow-Credentials' 'true' always; - - if ($request_method = 'OPTIONS') { - return 204; - } - } - - # Health check endpoint - location /health { - access_log off; - return 200 "healthy\n"; - add_header Content-Type text/plain; - } - - # Root redirect - location / { - return 301 /api/v1/; - } - - # API documentation - location /api/v1/ { - return 200 '{"message": "License Server API v1", "documentation": "/api/v1/docs"}'; - add_header Content-Type application/json; - } - } - - # HTTPS server block (for production) - # server { - # listen 443 ssl http2; - # server_name your-domain.com; - # - # ssl_certificate /etc/nginx/ssl/cert.pem; - # ssl_certificate_key /etc/nginx/ssl/key.pem; - # ssl_protocols TLSv1.2 TLSv1.3; - # ssl_ciphers HIGH:!aNULL:!MD5; - # - # # Same location blocks as above - # } -} \ No newline at end of file diff --git a/lizenzserver/repositories/base.py b/lizenzserver/repositories/base.py deleted file mode 100644 index fce3c38..0000000 --- a/lizenzserver/repositories/base.py +++ /dev/null @@ -1,94 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Optional, List, Dict, Any -import psycopg2 -from psycopg2.extras import RealDictCursor -from contextlib import contextmanager -import logging - -logger = logging.getLogger(__name__) - -class BaseRepository(ABC): - """Base repository with common database operations""" - - def __init__(self, db_url: str): - self.db_url = db_url - - @contextmanager - def get_db_connection(self): - """Get database connection with automatic cleanup""" - conn = None - try: - conn = psycopg2.connect(self.db_url) - yield conn - except Exception as e: - if conn: - conn.rollback() - logger.error(f"Database error: {e}") - raise - finally: - if conn: - conn.close() - - @contextmanager - def get_db_cursor(self, conn): - """Get database cursor with dict results""" - cursor = None - try: - cursor = conn.cursor(cursor_factory=RealDictCursor) - yield cursor - finally: - if cursor: - cursor.close() - - def execute_query(self, query: str, params: tuple = None) -> List[Dict[str, Any]]: - """Execute SELECT query and return results""" - with self.get_db_connection() as conn: - with self.get_db_cursor(conn) as cursor: - cursor.execute(query, params) - return cursor.fetchall() - - def execute_one(self, query: str, params: tuple = None) -> Optional[Dict[str, Any]]: - """Execute query and return single result""" - with self.get_db_connection() as conn: - with self.get_db_cursor(conn) as cursor: - cursor.execute(query, params) - return cursor.fetchone() - - def execute_insert(self, query: str, params: tuple = None) -> Optional[str]: - """Execute INSERT query and return ID""" - with self.get_db_connection() as conn: - with self.get_db_cursor(conn) as cursor: - cursor.execute(query + " RETURNING id", params) - result = cursor.fetchone() - conn.commit() - return result['id'] if result else None - - def execute_update(self, query: str, params: tuple = None) -> int: - """Execute UPDATE query and return affected rows""" - with self.get_db_connection() as conn: - with self.get_db_cursor(conn) as cursor: - cursor.execute(query, params) - affected = cursor.rowcount - conn.commit() - return affected - - def execute_delete(self, query: str, params: tuple = None) -> int: - """Execute DELETE query and return affected rows""" - with self.get_db_connection() as conn: - with self.get_db_cursor(conn) as cursor: - cursor.execute(query, params) - affected = cursor.rowcount - conn.commit() - return affected - - def execute_batch(self, queries: List[tuple]) -> None: - """Execute multiple queries in a transaction""" - with self.get_db_connection() as conn: - with self.get_db_cursor(conn) as cursor: - try: - for query, params in queries: - cursor.execute(query, params) - conn.commit() - except Exception as e: - conn.rollback() - raise \ No newline at end of file diff --git a/lizenzserver/repositories/cache_repo.py b/lizenzserver/repositories/cache_repo.py deleted file mode 100644 index 83fc3f8..0000000 --- a/lizenzserver/repositories/cache_repo.py +++ /dev/null @@ -1,178 +0,0 @@ -import redis -import json -import logging -from typing import Optional, Any, Dict, List -from datetime import timedelta - -logger = logging.getLogger(__name__) - -class CacheRepository: - """Redis cache repository""" - - def __init__(self, redis_url: str): - self.redis_url = redis_url - self._connect() - - def _connect(self): - """Connect to Redis""" - try: - self.redis = redis.from_url(self.redis_url, decode_responses=True) - self.redis.ping() - logger.info("Connected to Redis") - except Exception as e: - logger.error(f"Failed to connect to Redis: {e}") - self.redis = None - - def _make_key(self, prefix: str, *args) -> str: - """Create cache key""" - parts = [prefix] + [str(arg) for arg in args] - return ":".join(parts) - - def get(self, key: str) -> Optional[Any]: - """Get value from cache""" - if not self.redis: - return None - - try: - value = self.redis.get(key) - if value: - return json.loads(value) - return None - except Exception as e: - logger.error(f"Cache get error: {e}") - return None - - def set(self, key: str, value: Any, ttl: int = 300) -> bool: - """Set value in cache with TTL in seconds""" - if not self.redis: - return False - - try: - json_value = json.dumps(value) - return self.redis.setex(key, ttl, json_value) - except Exception as e: - logger.error(f"Cache set error: {e}") - return False - - def delete(self, key: str) -> bool: - """Delete key from cache""" - if not self.redis: - return False - - try: - return bool(self.redis.delete(key)) - except Exception as e: - logger.error(f"Cache delete error: {e}") - return False - - def delete_pattern(self, pattern: str) -> int: - """Delete all keys matching pattern""" - if not self.redis: - return 0 - - try: - keys = self.redis.keys(pattern) - if keys: - return self.redis.delete(*keys) - return 0 - except Exception as e: - logger.error(f"Cache delete pattern error: {e}") - return 0 - - # License-specific cache methods - - def get_license_validation(self, license_key: str, hardware_id: str) -> Optional[Dict[str, Any]]: - """Get cached license validation result""" - key = self._make_key("license:validation", license_key, hardware_id) - return self.get(key) - - def set_license_validation(self, license_key: str, hardware_id: str, - result: Dict[str, Any], ttl: int = 300) -> bool: - """Cache license validation result""" - key = self._make_key("license:validation", license_key, hardware_id) - return self.set(key, result, ttl) - - def get_license_status(self, license_id: str) -> Optional[Dict[str, Any]]: - """Get cached license status""" - key = self._make_key("license:status", license_id) - return self.get(key) - - def set_license_status(self, license_id: str, status: Dict[str, Any], - ttl: int = 60) -> bool: - """Cache license status""" - key = self._make_key("license:status", license_id) - return self.set(key, status, ttl) - - def get_device_list(self, license_id: str) -> Optional[List[Dict[str, Any]]]: - """Get cached device list""" - key = self._make_key("license:devices", license_id) - return self.get(key) - - def set_device_list(self, license_id: str, devices: List[Dict[str, Any]], - ttl: int = 300) -> bool: - """Cache device list""" - key = self._make_key("license:devices", license_id) - return self.set(key, devices, ttl) - - def invalidate_license_cache(self, license_id: str) -> None: - """Invalidate all cache entries for a license""" - patterns = [ - f"license:validation:*:{license_id}", - f"license:status:{license_id}", - f"license:devices:{license_id}" - ] - - for pattern in patterns: - self.delete_pattern(pattern) - - # Rate limiting methods - - def check_rate_limit(self, key: str, limit: int, window: int) -> tuple[bool, int]: - """Check if rate limit is exceeded - Returns: (is_allowed, current_count) - """ - if not self.redis: - return True, 0 - - try: - pipe = self.redis.pipeline() - now = int(time.time()) - window_start = now - window - - # Remove old entries - pipe.zremrangebyscore(key, 0, window_start) - - # Count requests in current window - pipe.zcard(key) - - # Add current request - pipe.zadd(key, {str(now): now}) - - # Set expiry - pipe.expire(key, window + 1) - - results = pipe.execute() - current_count = results[1] - - return current_count < limit, current_count + 1 - - except Exception as e: - logger.error(f"Rate limit check error: {e}") - return True, 0 - - def increment_counter(self, key: str, window: int = 3600) -> int: - """Increment counter with expiry""" - if not self.redis: - return 0 - - try: - pipe = self.redis.pipeline() - pipe.incr(key) - pipe.expire(key, window) - results = pipe.execute() - return results[0] - except Exception as e: - logger.error(f"Counter increment error: {e}") - return 0 - -import time # Add this import at the top \ No newline at end of file diff --git a/lizenzserver/repositories/license_repo.py b/lizenzserver/repositories/license_repo.py deleted file mode 100644 index 938f262..0000000 --- a/lizenzserver/repositories/license_repo.py +++ /dev/null @@ -1,228 +0,0 @@ -from typing import Optional, List, Dict, Any -from datetime import datetime, timedelta -from .base import BaseRepository -from ..models import License, LicenseToken, ActivationEvent, EventType -import logging - -logger = logging.getLogger(__name__) - -class LicenseRepository(BaseRepository): - """Repository for license-related database operations""" - - def get_license_by_key(self, license_key: str) -> Optional[Dict[str, Any]]: - """Get license by key""" - query = """ - SELECT l.*, c.name as customer_name, c.email as customer_email - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.license_key = %s - """ - return self.execute_one(query, (license_key,)) - - def get_license_by_id(self, license_id: str) -> Optional[Dict[str, Any]]: - """Get license by ID""" - query = """ - SELECT l.*, c.name as customer_name, c.email as customer_email - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """ - return self.execute_one(query, (license_id,)) - - def get_active_devices(self, license_id: str) -> List[Dict[str, Any]]: - """Get active devices for a license""" - query = """ - SELECT DISTINCT ON (hardware_id) - hardware_id, - ip_address, - user_agent, - app_version, - timestamp as last_seen - FROM license_heartbeats - WHERE license_id = %s - AND timestamp > NOW() - INTERVAL '15 minutes' - ORDER BY hardware_id, timestamp DESC - """ - return self.execute_query(query, (license_id,)) - - def get_device_count(self, license_id: str) -> int: - """Get count of active devices""" - query = """ - SELECT COUNT(DISTINCT hardware_id) as device_count - FROM license_heartbeats - WHERE license_id = %s - AND timestamp > NOW() - INTERVAL '15 minutes' - """ - result = self.execute_one(query, (license_id,)) - return result['device_count'] if result else 0 - - def create_license_token(self, license_id: str, hardware_id: str, - valid_hours: int = 24) -> Optional[str]: - """Create offline validation token""" - import secrets - token = secrets.token_urlsafe(64) - valid_until = datetime.utcnow() + timedelta(hours=valid_hours) - - query = """ - INSERT INTO license_tokens (license_id, token, hardware_id, valid_until) - VALUES (%s, %s, %s, %s) - RETURNING id - """ - - result = self.execute_insert(query, (license_id, token, hardware_id, valid_until)) - return token if result else None - - def validate_token(self, token: str) -> Optional[Dict[str, Any]]: - """Validate offline token""" - query = """ - SELECT lt.*, l.license_key, l.is_active, l.expires_at - FROM license_tokens lt - JOIN licenses l ON lt.license_id = l.id - WHERE lt.token = %s - AND lt.valid_until > NOW() - AND l.is_active = true - """ - - result = self.execute_one(query, (token,)) - - if result: - # Update validation count and timestamp - update_query = """ - UPDATE license_tokens - SET validation_count = validation_count + 1, - last_validated = NOW() - WHERE token = %s - """ - self.execute_update(update_query, (token,)) - - return result - - def record_heartbeat(self, license_id: str, hardware_id: str, - ip_address: str = None, user_agent: str = None, - app_version: str = None, session_data: Dict = None) -> None: - """Record license heartbeat""" - query = """ - INSERT INTO license_heartbeats - (license_id, hardware_id, ip_address, user_agent, app_version, session_data) - VALUES (%s, %s, %s, %s, %s, %s) - """ - - import json - session_json = json.dumps(session_data) if session_data else None - - self.execute_insert(query, ( - license_id, hardware_id, ip_address, - user_agent, app_version, session_json - )) - - def record_activation_event(self, license_id: str, event_type: EventType, - hardware_id: str = None, previous_hardware_id: str = None, - ip_address: str = None, user_agent: str = None, - success: bool = True, error_message: str = None, - metadata: Dict = None) -> str: - """Record activation event""" - query = """ - INSERT INTO activation_events - (license_id, event_type, hardware_id, previous_hardware_id, - ip_address, user_agent, success, error_message, metadata) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """ - - import json - metadata_json = json.dumps(metadata) if metadata else None - - return self.execute_insert(query, ( - license_id, event_type.value, hardware_id, previous_hardware_id, - ip_address, user_agent, success, error_message, metadata_json - )) - - def get_recent_activations(self, license_id: str, hours: int = 24) -> List[Dict[str, Any]]: - """Get recent activation events""" - query = """ - SELECT * FROM activation_events - WHERE license_id = %s - AND created_at > NOW() - INTERVAL '%s hours' - ORDER BY created_at DESC - """ - return self.execute_query(query, (license_id, hours)) - - def check_hardware_id_exists(self, license_id: str, hardware_id: str) -> bool: - """Check if hardware ID is already registered""" - query = """ - SELECT 1 FROM activation_events - WHERE license_id = %s - AND hardware_id = %s - AND event_type IN ('activation', 'reactivation') - AND success = true - LIMIT 1 - """ - result = self.execute_one(query, (license_id, hardware_id)) - return result is not None - - def deactivate_device(self, license_id: str, hardware_id: str) -> bool: - """Deactivate a device""" - # Record deactivation event - self.record_activation_event( - license_id=license_id, - event_type=EventType.DEACTIVATION, - hardware_id=hardware_id, - success=True - ) - - # Remove any active tokens for this device - query = """ - DELETE FROM license_tokens - WHERE license_id = %s AND hardware_id = %s - """ - affected = self.execute_delete(query, (license_id, hardware_id)) - - return affected > 0 - - def transfer_license(self, license_id: str, from_hardware_id: str, - to_hardware_id: str, ip_address: str = None) -> bool: - """Transfer license from one device to another""" - try: - # Deactivate old device - self.deactivate_device(license_id, from_hardware_id) - - # Record transfer event - self.record_activation_event( - license_id=license_id, - event_type=EventType.TRANSFER, - hardware_id=to_hardware_id, - previous_hardware_id=from_hardware_id, - ip_address=ip_address, - success=True - ) - - return True - - except Exception as e: - logger.error(f"License transfer failed: {e}") - return False - - def get_license_usage_stats(self, license_id: str, days: int = 30) -> Dict[str, Any]: - """Get usage statistics for a license""" - query = """ - WITH daily_stats AS ( - SELECT - DATE(timestamp) as date, - COUNT(*) as validations, - COUNT(DISTINCT hardware_id) as unique_devices, - COUNT(DISTINCT ip_address) as unique_ips - FROM license_heartbeats - WHERE license_id = %s - AND timestamp > NOW() - INTERVAL '%s days' - GROUP BY DATE(timestamp) - ) - SELECT - COUNT(*) as total_days, - SUM(validations) as total_validations, - AVG(validations) as avg_daily_validations, - MAX(unique_devices) as max_devices, - MAX(unique_ips) as max_ips - FROM daily_stats - """ - - return self.execute_one(query, (license_id, days)) or {} \ No newline at end of file diff --git a/lizenzserver/requirements.txt b/lizenzserver/requirements.txt deleted file mode 100644 index 13136ad..0000000 --- a/lizenzserver/requirements.txt +++ /dev/null @@ -1,31 +0,0 @@ -# Flask and extensions -Flask==3.0.0 -Flask-CORS==4.0.0 -flask-limiter==3.5.0 - -# Database -psycopg2-binary==2.9.9 -SQLAlchemy==2.0.23 - -# Redis -redis==5.0.1 - -# RabbitMQ -pika==1.3.2 - -# JWT -PyJWT==2.8.0 - -# Validation -marshmallow==3.20.1 - -# Monitoring -prometheus-flask-exporter==0.23.0 - -# Utilities -python-dateutil==2.8.2 -pytz==2023.3 -requests==2.31.0 - -# Development -python-dotenv==1.0.0 \ No newline at end of file diff --git a/lizenzserver/services/admin_api/__init__.py b/lizenzserver/services/admin_api/__init__.py deleted file mode 100644 index 68928c2..0000000 --- a/lizenzserver/services/admin_api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Admin API Service \ No newline at end of file diff --git a/lizenzserver/services/admin_api/app.py b/lizenzserver/services/admin_api/app.py deleted file mode 100644 index f803fda..0000000 --- a/lizenzserver/services/admin_api/app.py +++ /dev/null @@ -1,666 +0,0 @@ -import os -import sys -from flask import Flask, request, jsonify -from flask_cors import CORS -import logging -from functools import wraps -from marshmallow import Schema, fields, ValidationError -from datetime import datetime, timedelta -import secrets - -# Add parent directory to path for imports -sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from config import get_config -from repositories.license_repo import LicenseRepository -from repositories.cache_repo import CacheRepository -from events.event_bus import EventBus, Event, EventTypes -from models import EventType, AnomalyType, Severity - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Initialize Flask app -app = Flask(__name__) -config = get_config() -app.config.from_object(config) -CORS(app) - -# Initialize dependencies -license_repo = LicenseRepository(config.DATABASE_URL) -cache_repo = CacheRepository(config.REDIS_URL) -event_bus = EventBus(config.RABBITMQ_URL) - -# Validation schemas -class CreateLicenseSchema(Schema): - customer_id = fields.Str(required=True) - max_devices = fields.Int(missing=1, validate=lambda x: x > 0) - expires_in_days = fields.Int(allow_none=True) - features = fields.List(fields.Str(), missing=[]) - is_test = fields.Bool(missing=False) - metadata = fields.Dict(missing={}) - -class UpdateLicenseSchema(Schema): - max_devices = fields.Int(validate=lambda x: x > 0) - is_active = fields.Bool() - expires_at = fields.DateTime() - features = fields.List(fields.Str()) - metadata = fields.Dict() - -class DeactivateDeviceSchema(Schema): - hardware_id = fields.Str(required=True) - reason = fields.Str() - -class TransferLicenseSchema(Schema): - from_hardware_id = fields.Str(required=True) - to_hardware_id = fields.Str(required=True) - -class SearchLicensesSchema(Schema): - customer_id = fields.Str() - is_active = fields.Bool() - is_test = fields.Bool() - created_after = fields.DateTime() - created_before = fields.DateTime() - expires_after = fields.DateTime() - expires_before = fields.DateTime() - page = fields.Int(missing=1, validate=lambda x: x > 0) - per_page = fields.Int(missing=50, validate=lambda x: 0 < x <= 100) - -def require_admin_auth(f): - """Decorator to require admin authentication""" - @wraps(f) - def decorated_function(*args, **kwargs): - # Check for admin API key - api_key = request.headers.get('X-Admin-API-Key') - - if not api_key: - return jsonify({"error": "Missing admin API key"}), 401 - - # In production, validate against database - # For now, check environment variable - if api_key != os.getenv('ADMIN_API_KEY', 'admin-key-change-in-production'): - return jsonify({"error": "Invalid admin API key"}), 401 - - return f(*args, **kwargs) - - return decorated_function - -@app.route('/health', methods=['GET']) -def health_check(): - """Health check endpoint""" - return jsonify({ - "status": "healthy", - "service": "admin-api", - "timestamp": datetime.utcnow().isoformat() - }) - -@app.route('/api/v1/admin/licenses', methods=['POST']) -@require_admin_auth -def create_license(): - """Create new license""" - schema = CreateLicenseSchema() - - try: - data = schema.load(request.get_json()) - except ValidationError as e: - return jsonify({"error": "Invalid request", "details": e.messages}), 400 - - # Generate license key - license_key = f"LIC-{secrets.token_urlsafe(16).upper()}" - - # Calculate expiration - expires_at = None - if data.get('expires_in_days'): - expires_at = datetime.utcnow() + timedelta(days=data['expires_in_days']) - - # Create license in database - query = """ - INSERT INTO licenses - (license_key, customer_id, max_devices, is_active, is_test, expires_at, features, metadata) - VALUES (%s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """ - - import json - license_id = license_repo.execute_insert(query, ( - license_key, - data['customer_id'], - data['max_devices'], - data['is_test'], - expires_at, - json.dumps(data['features']), - json.dumps(data['metadata']) - )) - - if not license_id: - return jsonify({"error": "Failed to create license"}), 500 - - # Publish event - event_bus.publish(Event( - EventTypes.LICENSE_CREATED, - { - "license_id": license_id, - "customer_id": data['customer_id'], - "license_key": license_key - }, - "admin-api" - )) - - return jsonify({ - "id": license_id, - "license_key": license_key, - "customer_id": data['customer_id'], - "max_devices": data['max_devices'], - "is_test": data['is_test'], - "expires_at": expires_at.isoformat() if expires_at else None, - "features": data['features'] - }), 201 - -@app.route('/api/v1/admin/licenses/', methods=['GET']) -@require_admin_auth -def get_license(license_id): - """Get license details""" - license = license_repo.get_license_by_id(license_id) - - if not license: - return jsonify({"error": "License not found"}), 404 - - # Get additional statistics - active_devices = license_repo.get_active_devices(license_id) - usage_stats = license_repo.get_license_usage_stats(license_id) - recent_events = license_repo.get_recent_activations(license_id) - - # Format response - license['active_devices'] = active_devices - license['usage_stats'] = usage_stats - license['recent_events'] = recent_events - - return jsonify(license) - -@app.route('/api/v1/admin/licenses/', methods=['PATCH']) -@require_admin_auth -def update_license(license_id): - """Update license""" - schema = UpdateLicenseSchema() - - try: - data = schema.load(request.get_json()) - except ValidationError as e: - return jsonify({"error": "Invalid request", "details": e.messages}), 400 - - # Build update query dynamically - updates = [] - params = [] - - if 'max_devices' in data: - updates.append("max_devices = %s") - params.append(data['max_devices']) - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - - if 'expires_at' in data: - updates.append("expires_at = %s") - params.append(data['expires_at']) - - if 'features' in data: - updates.append("features = %s") - params.append(json.dumps(data['features'])) - - if 'metadata' in data: - updates.append("metadata = %s") - params.append(json.dumps(data['metadata'])) - - if not updates: - return jsonify({"error": "No fields to update"}), 400 - - # Add updated_at - updates.append("updated_at = NOW()") - - # Add license_id to params - params.append(license_id) - - query = f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - RETURNING * - """ - - result = license_repo.execute_one(query, params) - - if not result: - return jsonify({"error": "License not found"}), 404 - - # Invalidate cache - cache_repo.invalidate_license_cache(license_id) - - # Publish event - event_bus.publish(Event( - EventTypes.LICENSE_UPDATED, - { - "license_id": license_id, - "changes": list(data.keys()) - }, - "admin-api" - )) - - return jsonify(result) - -@app.route('/api/v1/admin/licenses/', methods=['DELETE']) -@require_admin_auth -def delete_license(license_id): - """Soft delete license (deactivate)""" - query = """ - UPDATE licenses - SET is_active = false, updated_at = NOW() - WHERE id = %s - RETURNING id - """ - - result = license_repo.execute_one(query, (license_id,)) - - if not result: - return jsonify({"error": "License not found"}), 404 - - # Invalidate cache - cache_repo.invalidate_license_cache(license_id) - - # Publish event - event_bus.publish(Event( - EventTypes.LICENSE_DEACTIVATED, - {"license_id": license_id}, - "admin-api" - )) - - return jsonify({"success": True, "message": "License deactivated"}) - -@app.route('/api/v1/admin/licenses//devices', methods=['GET']) -@require_admin_auth -def get_license_devices(license_id): - """Get all devices for a license""" - # Get active devices - active_devices = license_repo.get_active_devices(license_id) - - # Get all registered devices from activation events - query = """ - SELECT DISTINCT ON (hardware_id) - hardware_id, - event_type, - ip_address, - user_agent, - created_at as registered_at, - metadata - FROM activation_events - WHERE license_id = %s - AND event_type IN ('activation', 'reactivation', 'transfer') - AND success = true - ORDER BY hardware_id, created_at DESC - """ - - all_devices = license_repo.execute_query(query, (license_id,)) - - # Mark active devices - active_hw_ids = {d['hardware_id'] for d in active_devices} - for device in all_devices: - device['is_active'] = device['hardware_id'] in active_hw_ids - if device['is_active']: - # Add last_seen from active_devices - active_device = next((d for d in active_devices if d['hardware_id'] == device['hardware_id']), None) - if active_device: - device['last_seen'] = active_device['last_seen'] - - return jsonify({ - "license_id": license_id, - "total_devices": len(all_devices), - "active_devices": len(active_devices), - "devices": all_devices - }) - -@app.route('/api/v1/admin/licenses//devices/deactivate', methods=['POST']) -@require_admin_auth -def deactivate_device(license_id): - """Deactivate a device""" - schema = DeactivateDeviceSchema() - - try: - data = schema.load(request.get_json()) - except ValidationError as e: - return jsonify({"error": "Invalid request", "details": e.messages}), 400 - - success = license_repo.deactivate_device(license_id, data['hardware_id']) - - if not success: - return jsonify({"error": "Failed to deactivate device"}), 500 - - # Invalidate cache - cache_repo.invalidate_license_cache(license_id) - - # Publish event - event_bus.publish(Event( - EventTypes.DEVICE_DEACTIVATED, - { - "license_id": license_id, - "hardware_id": data['hardware_id'], - "reason": data.get('reason', 'Admin action') - }, - "admin-api" - )) - - return jsonify({"success": True, "message": "Device deactivated"}) - -@app.route('/api/v1/admin/licenses//transfer', methods=['POST']) -@require_admin_auth -def transfer_license(license_id): - """Transfer license between devices""" - schema = TransferLicenseSchema() - - try: - data = schema.load(request.get_json()) - except ValidationError as e: - return jsonify({"error": "Invalid request", "details": e.messages}), 400 - - # Get client IP - ip_address = request.headers.get('X-Forwarded-For', request.remote_addr) - - success = license_repo.transfer_license( - license_id, - data['from_hardware_id'], - data['to_hardware_id'], - ip_address - ) - - if not success: - return jsonify({"error": "Failed to transfer license"}), 500 - - # Invalidate cache - cache_repo.invalidate_license_cache(license_id) - - # Publish event - event_bus.publish(Event( - EventTypes.LICENSE_TRANSFERRED, - { - "license_id": license_id, - "from_hardware_id": data['from_hardware_id'], - "to_hardware_id": data['to_hardware_id'] - }, - "admin-api" - )) - - return jsonify({"success": True, "message": "License transferred successfully"}) - -@app.route('/api/v1/admin/licenses', methods=['GET']) -@require_admin_auth -def search_licenses(): - """Search and list licenses""" - schema = SearchLicensesSchema() - - try: - filters = schema.load(request.args) - except ValidationError as e: - return jsonify({"error": "Invalid request", "details": e.messages}), 400 - - # Build query - where_clauses = [] - params = [] - - if filters.get('customer_id'): - where_clauses.append("customer_id = %s") - params.append(filters['customer_id']) - - if 'is_active' in filters: - where_clauses.append("is_active = %s") - params.append(filters['is_active']) - - if 'is_test' in filters: - where_clauses.append("is_test = %s") - params.append(filters['is_test']) - - if filters.get('created_after'): - where_clauses.append("created_at >= %s") - params.append(filters['created_after']) - - if filters.get('created_before'): - where_clauses.append("created_at <= %s") - params.append(filters['created_before']) - - if filters.get('expires_after'): - where_clauses.append("expires_at >= %s") - params.append(filters['expires_after']) - - if filters.get('expires_before'): - where_clauses.append("expires_at <= %s") - params.append(filters['expires_before']) - - where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" - - # Count total - count_query = f"SELECT COUNT(*) as total FROM licenses WHERE {where_sql}" - total_result = license_repo.execute_one(count_query, params) - total = total_result['total'] if total_result else 0 - - # Get paginated results - page = filters['page'] - per_page = filters['per_page'] - offset = (page - 1) * per_page - - query = f""" - SELECT l.*, c.name as customer_name, c.email as customer_email - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE {where_sql} - ORDER BY l.created_at DESC - LIMIT %s OFFSET %s - """ - - params.extend([per_page, offset]) - licenses = license_repo.execute_query(query, params) - - return jsonify({ - "licenses": licenses, - "pagination": { - "total": total, - "page": page, - "per_page": per_page, - "pages": (total + per_page - 1) // per_page - } - }) - -@app.route('/api/v1/admin/licenses//events', methods=['GET']) -@require_admin_auth -def get_license_events(license_id): - """Get all events for a license""" - hours = request.args.get('hours', 24, type=int) - - events = license_repo.get_recent_activations(license_id, hours) - - return jsonify({ - "license_id": license_id, - "hours": hours, - "total_events": len(events), - "events": events - }) - -@app.route('/api/v1/admin/licenses//usage', methods=['GET']) -@require_admin_auth -def get_license_usage(license_id): - """Get usage statistics for a license""" - days = request.args.get('days', 30, type=int) - - stats = license_repo.get_license_usage_stats(license_id, days) - - # Get daily breakdown - query = """ - SELECT - DATE(timestamp) as date, - COUNT(*) as validations, - COUNT(DISTINCT hardware_id) as unique_devices, - COUNT(DISTINCT ip_address) as unique_ips - FROM license_heartbeats - WHERE license_id = %s - AND timestamp > NOW() - INTERVAL '%s days' - GROUP BY DATE(timestamp) - ORDER BY date DESC - """ - - daily_stats = license_repo.execute_query(query, (license_id, days)) - - return jsonify({ - "license_id": license_id, - "days": days, - "summary": stats, - "daily": daily_stats - }) - -@app.route('/api/v1/admin/licenses//anomalies', methods=['GET']) -@require_admin_auth -def get_license_anomalies(license_id): - """Get detected anomalies for a license""" - query = """ - SELECT * FROM anomaly_detections - WHERE license_id = %s - ORDER BY detected_at DESC - LIMIT 100 - """ - - anomalies = license_repo.execute_query(query, (license_id,)) - - return jsonify({ - "license_id": license_id, - "total_anomalies": len(anomalies), - "anomalies": anomalies - }) - -@app.route('/api/v1/admin/licenses//anomalies//resolve', methods=['POST']) -@require_admin_auth -def resolve_anomaly(license_id, anomaly_id): - """Mark anomaly as resolved""" - data = request.get_json() or {} - action_taken = data.get('action_taken', 'Resolved by admin') - - query = """ - UPDATE anomaly_detections - SET resolved = true, - resolved_at = NOW(), - resolved_by = 'admin', - action_taken = %s - WHERE id = %s AND license_id = %s - RETURNING id - """ - - result = license_repo.execute_one(query, (action_taken, anomaly_id, license_id)) - - if not result: - return jsonify({"error": "Anomaly not found"}), 404 - - return jsonify({"success": True, "message": "Anomaly resolved"}) - -@app.route('/api/v1/admin/licenses/bulk-create', methods=['POST']) -@require_admin_auth -def bulk_create_licenses(): - """Create multiple licenses at once""" - data = request.get_json() - - if not data or 'licenses' not in data: - return jsonify({"error": "Missing licenses array"}), 400 - - schema = CreateLicenseSchema() - created_licenses = [] - errors = [] - - for idx, license_data in enumerate(data['licenses']): - try: - validated_data = schema.load(license_data) - - # Generate license key - license_key = f"LIC-{secrets.token_urlsafe(16).upper()}" - - # Calculate expiration - expires_at = None - if validated_data.get('expires_in_days'): - expires_at = datetime.utcnow() + timedelta(days=validated_data['expires_in_days']) - - # Create license - query = """ - INSERT INTO licenses - (license_key, customer_id, max_devices, is_active, is_test, expires_at, features, metadata) - VALUES (%s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """ - - import json - license_id = license_repo.execute_insert(query, ( - license_key, - validated_data['customer_id'], - validated_data['max_devices'], - validated_data['is_test'], - expires_at, - json.dumps(validated_data['features']), - json.dumps(validated_data['metadata']) - )) - - if license_id: - created_licenses.append({ - "id": license_id, - "license_key": license_key, - "customer_id": validated_data['customer_id'] - }) - except Exception as e: - errors.append({ - "index": idx, - "error": str(e) - }) - - return jsonify({ - "created": len(created_licenses), - "failed": len(errors), - "licenses": created_licenses, - "errors": errors - }), 201 if created_licenses else 400 - -@app.route('/api/v1/admin/statistics', methods=['GET']) -@require_admin_auth -def get_statistics(): - """Get overall license statistics""" - query = """ - WITH stats AS ( - SELECT - COUNT(*) as total_licenses, - COUNT(*) FILTER (WHERE is_active = true) as active_licenses, - COUNT(*) FILTER (WHERE is_test = true) as test_licenses, - COUNT(*) FILTER (WHERE expires_at < NOW()) as expired_licenses, - COUNT(DISTINCT customer_id) as total_customers - FROM licenses - ), - device_stats AS ( - SELECT COUNT(DISTINCT hardware_id) as total_devices - FROM license_heartbeats - WHERE timestamp > NOW() - INTERVAL '15 minutes' - ), - validation_stats AS ( - SELECT - COUNT(*) as validations_today, - COUNT(DISTINCT license_id) as licenses_used_today - FROM license_heartbeats - WHERE timestamp > CURRENT_DATE - ) - SELECT * FROM stats, device_stats, validation_stats - """ - - stats = license_repo.execute_one(query) - - return jsonify(stats or {}) - -@app.errorhandler(404) -def not_found(error): - return jsonify({"error": "Not found"}), 404 - -@app.errorhandler(500) -def internal_error(error): - logger.error(f"Internal error: {error}") - return jsonify({"error": "Internal server error"}), 500 - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5004, debug=True) \ No newline at end of file diff --git a/lizenzserver/services/analytics/__init__.py b/lizenzserver/services/analytics/__init__.py deleted file mode 100644 index 5c63479..0000000 --- a/lizenzserver/services/analytics/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Analytics Service \ No newline at end of file diff --git a/lizenzserver/services/analytics/app.py b/lizenzserver/services/analytics/app.py deleted file mode 100644 index f1d9561..0000000 --- a/lizenzserver/services/analytics/app.py +++ /dev/null @@ -1,478 +0,0 @@ -import os -import sys -from flask import Flask, request, jsonify -from flask_cors import CORS -import logging -from functools import wraps -from datetime import datetime, timedelta -import asyncio -from concurrent.futures import ThreadPoolExecutor - -# Add parent directory to path for imports -sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from config import get_config -from repositories.license_repo import LicenseRepository -from repositories.cache_repo import CacheRepository -from events.event_bus import EventBus, Event, EventTypes -from models import AnomalyType, Severity - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Initialize Flask app -app = Flask(__name__) -config = get_config() -app.config.from_object(config) -CORS(app) - -# Initialize dependencies -license_repo = LicenseRepository(config.DATABASE_URL) -cache_repo = CacheRepository(config.REDIS_URL) -event_bus = EventBus(config.RABBITMQ_URL) - -# Thread pool for async operations -executor = ThreadPoolExecutor(max_workers=10) - -def require_auth(f): - """Decorator to require authentication""" - @wraps(f) - def decorated_function(*args, **kwargs): - api_key = request.headers.get('X-API-Key') - - if not api_key: - return jsonify({"error": "Missing API key"}), 401 - - # Simple validation for now - if not api_key.startswith('sk_'): - return jsonify({"error": "Invalid API key"}), 401 - - return f(*args, **kwargs) - - return decorated_function - -@app.route('/health', methods=['GET']) -def health_check(): - """Health check endpoint""" - return jsonify({ - "status": "healthy", - "service": "analytics", - "timestamp": datetime.utcnow().isoformat() - }) - -@app.route('/api/v1/analytics/licenses//patterns', methods=['GET']) -@require_auth -def analyze_license_patterns(license_id): - """Analyze usage patterns for a license""" - days = request.args.get('days', 30, type=int) - - # Get usage data - query = """ - WITH hourly_usage AS ( - SELECT - DATE_TRUNC('hour', timestamp) as hour, - COUNT(*) as validations, - COUNT(DISTINCT hardware_id) as devices, - COUNT(DISTINCT ip_address) as ips - FROM license_heartbeats - WHERE license_id = %s - AND timestamp > NOW() - INTERVAL '%s days' - GROUP BY DATE_TRUNC('hour', timestamp) - ), - daily_patterns AS ( - SELECT - EXTRACT(DOW FROM hour) as day_of_week, - EXTRACT(HOUR FROM hour) as hour_of_day, - AVG(validations) as avg_validations, - MAX(devices) as max_devices - FROM hourly_usage - GROUP BY day_of_week, hour_of_day - ) - SELECT * FROM daily_patterns - ORDER BY day_of_week, hour_of_day - """ - - patterns = license_repo.execute_query(query, (license_id, days)) - - # Detect anomalies - anomalies = detect_usage_anomalies(license_id, patterns) - - return jsonify({ - "license_id": license_id, - "days_analyzed": days, - "patterns": patterns, - "anomalies": anomalies - }) - -@app.route('/api/v1/analytics/licenses//anomalies/detect', methods=['POST']) -@require_auth -def detect_anomalies(license_id): - """Manually trigger anomaly detection for a license""" - - # Run multiple anomaly detection checks - anomalies = [] - - # Check for multiple IPs - ip_anomalies = check_multiple_ips(license_id) - anomalies.extend(ip_anomalies) - - # Check for rapid hardware changes - hw_anomalies = check_rapid_hardware_changes(license_id) - anomalies.extend(hw_anomalies) - - # Check for concurrent usage - concurrent_anomalies = check_concurrent_usage(license_id) - anomalies.extend(concurrent_anomalies) - - # Check for geographic anomalies - geo_anomalies = check_geographic_anomalies(license_id) - anomalies.extend(geo_anomalies) - - # Store detected anomalies - for anomaly in anomalies: - store_anomaly(license_id, anomaly) - - return jsonify({ - "license_id": license_id, - "anomalies_detected": len(anomalies), - "anomalies": anomalies - }) - -@app.route('/api/v1/analytics/licenses//risk-score', methods=['GET']) -@require_auth -def get_risk_score(license_id): - """Calculate risk score for a license""" - - # Get recent anomalies - query = """ - SELECT anomaly_type, severity, detected_at - FROM anomaly_detections - WHERE license_id = %s - AND detected_at > NOW() - INTERVAL '30 days' - AND resolved = false - """ - - anomalies = license_repo.execute_query(query, (license_id,)) - - # Calculate risk score - risk_score = 0 - severity_weights = { - 'low': 10, - 'medium': 25, - 'high': 50, - 'critical': 100 - } - - for anomaly in anomalies: - weight = severity_weights.get(anomaly['severity'], 0) - # Recent anomalies have higher weight - days_old = (datetime.utcnow() - anomaly['detected_at']).days - recency_factor = max(0.5, 1 - (days_old / 30)) - risk_score += weight * recency_factor - - # Normalize to 0-100 - risk_score = min(100, risk_score) - - # Determine risk level - if risk_score < 20: - risk_level = "low" - elif risk_score < 50: - risk_level = "medium" - elif risk_score < 80: - risk_level = "high" - else: - risk_level = "critical" - - return jsonify({ - "license_id": license_id, - "risk_score": round(risk_score, 2), - "risk_level": risk_level, - "active_anomalies": len(anomalies), - "factors": anomalies - }) - -@app.route('/api/v1/analytics/reports/usage', methods=['GET']) -@require_auth -def generate_usage_report(): - """Generate usage report for all licenses""" - days = request.args.get('days', 30, type=int) - - query = """ - WITH license_stats AS ( - SELECT - l.id, - l.license_key, - l.customer_id, - c.name as customer_name, - l.max_devices, - l.is_test, - l.expires_at, - COUNT(DISTINCT lh.hardware_id) as active_devices, - COUNT(lh.*) as total_validations, - MAX(lh.timestamp) as last_validation - FROM licenses l - LEFT JOIN customers c ON l.customer_id = c.id - LEFT JOIN license_heartbeats lh ON l.id = lh.license_id - AND lh.timestamp > NOW() - INTERVAL '%s days' - WHERE l.is_active = true - GROUP BY l.id, l.license_key, l.customer_id, c.name, l.max_devices, l.is_test, l.expires_at - ) - SELECT - *, - CASE - WHEN total_validations = 0 THEN 'inactive' - WHEN active_devices > max_devices THEN 'over_limit' - WHEN expires_at < NOW() THEN 'expired' - ELSE 'active' - END as status, - ROUND((active_devices::numeric / NULLIF(max_devices, 0)) * 100, 2) as device_utilization - FROM license_stats - ORDER BY total_validations DESC - """ - - report = license_repo.execute_query(query, (days,)) - - # Summary statistics - summary = { - "total_licenses": len(report), - "active_licenses": len([r for r in report if r['status'] == 'active']), - "inactive_licenses": len([r for r in report if r['status'] == 'inactive']), - "over_limit_licenses": len([r for r in report if r['status'] == 'over_limit']), - "expired_licenses": len([r for r in report if r['status'] == 'expired']), - "total_validations": sum(r['total_validations'] for r in report), - "average_device_utilization": sum(r['device_utilization'] or 0 for r in report) / len(report) if report else 0 - } - - return jsonify({ - "period_days": days, - "generated_at": datetime.utcnow().isoformat(), - "summary": summary, - "licenses": report - }) - -@app.route('/api/v1/analytics/reports/revenue', methods=['GET']) -@require_auth -def generate_revenue_report(): - """Generate revenue analytics report""" - # This would need pricing information in the database - # For now, return a placeholder - return jsonify({ - "message": "Revenue reporting requires pricing data integration", - "placeholder": True - }) - -def detect_usage_anomalies(license_id, patterns): - """Detect anomalies in usage patterns""" - anomalies = [] - - if not patterns: - return anomalies - - # Calculate statistics - validations = [p['avg_validations'] for p in patterns] - if validations: - avg_validations = sum(validations) / len(validations) - max_validations = max(validations) - - # Detect spikes - for pattern in patterns: - if pattern['avg_validations'] > avg_validations * 3: - anomalies.append({ - "type": AnomalyType.SUSPICIOUS_PATTERN.value, - "severity": Severity.MEDIUM.value, - "details": { - "day": pattern['day_of_week'], - "hour": pattern['hour_of_day'], - "validations": pattern['avg_validations'], - "average": avg_validations - } - }) - - return anomalies - -def check_multiple_ips(license_id): - """Check for multiple IP addresses""" - query = """ - SELECT - COUNT(DISTINCT ip_address) as ip_count, - array_agg(DISTINCT ip_address) as ips - FROM license_heartbeats - WHERE license_id = %s - AND timestamp > NOW() - INTERVAL '1 hour' - """ - - result = license_repo.execute_one(query, (license_id,)) - anomalies = [] - - if result and result['ip_count'] > config.ANOMALY_MULTIPLE_IPS_THRESHOLD: - anomalies.append({ - "type": AnomalyType.MULTIPLE_IPS.value, - "severity": Severity.HIGH.value, - "details": { - "ip_count": result['ip_count'], - "ips": result['ips'][:10], # Limit to 10 IPs - "threshold": config.ANOMALY_MULTIPLE_IPS_THRESHOLD - } - }) - - return anomalies - -def check_rapid_hardware_changes(license_id): - """Check for rapid hardware ID changes""" - query = """ - SELECT - hardware_id, - created_at - FROM activation_events - WHERE license_id = %s - AND event_type IN ('activation', 'transfer') - AND created_at > NOW() - INTERVAL '1 hour' - AND success = true - ORDER BY created_at DESC - """ - - events = license_repo.execute_query(query, (license_id,)) - anomalies = [] - - if len(events) > 1: - # Check time between changes - for i in range(len(events) - 1): - time_diff = (events[i]['created_at'] - events[i+1]['created_at']).total_seconds() / 60 - if time_diff < config.ANOMALY_RAPID_HARDWARE_CHANGE_MINUTES: - anomalies.append({ - "type": AnomalyType.RAPID_HARDWARE_CHANGE.value, - "severity": Severity.HIGH.value, - "details": { - "hardware_ids": [events[i]['hardware_id'], events[i+1]['hardware_id']], - "time_difference_minutes": round(time_diff, 2), - "threshold_minutes": config.ANOMALY_RAPID_HARDWARE_CHANGE_MINUTES - } - }) - - return anomalies - -def check_concurrent_usage(license_id): - """Check for concurrent usage from different devices""" - query = """ - WITH concurrent_sessions AS ( - SELECT - h1.hardware_id as hw1, - h2.hardware_id as hw2, - h1.timestamp as time1, - h2.timestamp as time2 - FROM license_heartbeats h1 - JOIN license_heartbeats h2 ON h1.license_id = h2.license_id - WHERE h1.license_id = %s - AND h2.license_id = %s - AND h1.hardware_id != h2.hardware_id - AND h1.timestamp > NOW() - INTERVAL '15 minutes' - AND h2.timestamp > NOW() - INTERVAL '15 minutes' - AND ABS(EXTRACT(EPOCH FROM h1.timestamp - h2.timestamp)) < 300 - ) - SELECT COUNT(*) as concurrent_count - FROM concurrent_sessions - """ - - result = license_repo.execute_one(query, (license_id, license_id)) - anomalies = [] - - if result and result['concurrent_count'] > 0: - anomalies.append({ - "type": AnomalyType.CONCURRENT_USE.value, - "severity": Severity.CRITICAL.value, - "details": { - "concurrent_sessions": result['concurrent_count'], - "timeframe_minutes": 5 - } - }) - - return anomalies - -def check_geographic_anomalies(license_id): - """Check for geographic anomalies (requires IP geolocation)""" - # This would require IP geolocation service integration - # For now, return empty list - return [] - -def store_anomaly(license_id, anomaly): - """Store detected anomaly in database""" - query = """ - INSERT INTO anomaly_detections - (license_id, anomaly_type, severity, details) - VALUES (%s, %s, %s, %s) - ON CONFLICT (license_id, anomaly_type, details) DO NOTHING - """ - - import json - license_repo.execute_insert(query, ( - license_id, - anomaly['type'], - anomaly['severity'], - json.dumps(anomaly['details']) - )) - - # Publish event - event_bus.publish(Event( - EventTypes.ANOMALY_DETECTED, - { - "license_id": license_id, - "anomaly": anomaly - }, - "analytics" - )) - -@app.route('/api/v1/analytics/dashboard', methods=['GET']) -@require_auth -def get_dashboard_data(): - """Get analytics dashboard data""" - query = """ - WITH current_stats AS ( - SELECT - COUNT(DISTINCT license_id) as active_licenses, - COUNT(DISTINCT hardware_id) as active_devices, - COUNT(*) as validations_today - FROM license_heartbeats - WHERE timestamp > CURRENT_DATE - ), - anomaly_stats AS ( - SELECT - COUNT(*) as total_anomalies, - COUNT(*) FILTER (WHERE severity = 'critical') as critical_anomalies, - COUNT(*) FILTER (WHERE resolved = false) as unresolved_anomalies - FROM anomaly_detections - WHERE detected_at > CURRENT_DATE - INTERVAL '7 days' - ), - trend_data AS ( - SELECT - DATE(timestamp) as date, - COUNT(*) as validations, - COUNT(DISTINCT license_id) as licenses, - COUNT(DISTINCT hardware_id) as devices - FROM license_heartbeats - WHERE timestamp > CURRENT_DATE - INTERVAL '7 days' - GROUP BY DATE(timestamp) - ORDER BY date - ) - SELECT - cs.*, - ans.*, - (SELECT json_agg(td.*) FROM trend_data td) as trends - FROM current_stats cs, anomaly_stats ans - """ - - dashboard_data = license_repo.execute_one(query) - - return jsonify(dashboard_data or {}) - -@app.errorhandler(404) -def not_found(error): - return jsonify({"error": "Not found"}), 404 - -@app.errorhandler(500) -def internal_error(error): - logger.error(f"Internal error: {error}") - return jsonify({"error": "Internal server error"}), 500 - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5003, debug=True) \ No newline at end of file diff --git a/lizenzserver/services/auth/Dockerfile b/lizenzserver/services/auth/Dockerfile deleted file mode 100644 index 6390647..0000000 --- a/lizenzserver/services/auth/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . . - -# Create non-root user -RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app -USER appuser - -# Expose port -EXPOSE 5001 - -# Run with gunicorn -CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "4", "--timeout", "120", "app:app"] \ No newline at end of file diff --git a/lizenzserver/services/auth/app.py b/lizenzserver/services/auth/app.py deleted file mode 100644 index b560c05..0000000 --- a/lizenzserver/services/auth/app.py +++ /dev/null @@ -1,279 +0,0 @@ -import os -import sys -from flask import Flask, request, jsonify -from flask_cors import CORS -import jwt -from datetime import datetime, timedelta -import logging -from functools import wraps -from prometheus_flask_exporter import PrometheusMetrics - -# Add parent directory to path for imports -sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from config import get_config -from repositories.base import BaseRepository - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Initialize Flask app -app = Flask(__name__) -config = get_config() -app.config.from_object(config) -CORS(app) - -# Initialize Prometheus metrics -metrics = PrometheusMetrics(app) -metrics.info('auth_service_info', 'Auth Service Information', version='1.0.0') - -# Initialize repository -db_repo = BaseRepository(config.DATABASE_URL) - -def create_token(payload: dict, expires_delta: timedelta) -> str: - """Create JWT token""" - to_encode = payload.copy() - expire = datetime.utcnow() + expires_delta - to_encode.update({"exp": expire, "iat": datetime.utcnow()}) - - return jwt.encode( - to_encode, - config.JWT_SECRET, - algorithm=config.JWT_ALGORITHM - ) - -def decode_token(token: str) -> dict: - """Decode and validate JWT token""" - try: - payload = jwt.decode( - token, - config.JWT_SECRET, - algorithms=[config.JWT_ALGORITHM] - ) - return payload - except jwt.ExpiredSignatureError: - raise ValueError("Token has expired") - except jwt.InvalidTokenError: - raise ValueError("Invalid token") - -def require_api_key(f): - """Decorator to require API key""" - @wraps(f) - def decorated_function(*args, **kwargs): - api_key = request.headers.get('X-API-Key') - - if not api_key: - return jsonify({"error": "Missing API key"}), 401 - - # Validate API key - query = """ - SELECT id, client_name, allowed_endpoints - FROM api_clients - WHERE api_key = %s AND is_active = true - """ - client = db_repo.execute_one(query, (api_key,)) - - if not client: - return jsonify({"error": "Invalid API key"}), 401 - - # Check if endpoint is allowed - endpoint = request.endpoint - allowed = client.get('allowed_endpoints', []) - if allowed and endpoint not in allowed: - return jsonify({"error": "Endpoint not allowed"}), 403 - - # Add client info to request - request.api_client = client - - return f(*args, **kwargs) - - return decorated_function - -@app.route('/health', methods=['GET']) -def health_check(): - """Health check endpoint""" - return jsonify({ - "status": "healthy", - "service": "auth", - "timestamp": datetime.utcnow().isoformat() - }) - -@app.route('/api/v1/auth/token', methods=['POST']) -@require_api_key -def create_access_token(): - """Create access token for license validation""" - data = request.get_json() - - if not data or 'license_id' not in data: - return jsonify({"error": "Missing license_id"}), 400 - - license_id = data['license_id'] - hardware_id = data.get('hardware_id') - - # Verify license exists and is active - query = """ - SELECT id, is_active, max_devices - FROM licenses - WHERE id = %s - """ - license = db_repo.execute_one(query, (license_id,)) - - if not license: - return jsonify({"error": "License not found"}), 404 - - if not license['is_active']: - return jsonify({"error": "License is not active"}), 403 - - # Create token payload - payload = { - "sub": license_id, - "hwid": hardware_id, - "client_id": request.api_client['id'], - "type": "access" - } - - # Add features and limits based on license - payload["features"] = data.get('features', []) - payload["limits"] = { - "api_calls": config.DEFAULT_RATE_LIMIT_PER_HOUR, - "concurrent_sessions": config.MAX_CONCURRENT_SESSIONS - } - - # Create tokens - access_token = create_token(payload, config.JWT_ACCESS_TOKEN_EXPIRES) - - # Create refresh token - refresh_payload = { - "sub": license_id, - "client_id": request.api_client['id'], - "type": "refresh" - } - refresh_token = create_token(refresh_payload, config.JWT_REFRESH_TOKEN_EXPIRES) - - return jsonify({ - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": "Bearer", - "expires_in": int(config.JWT_ACCESS_TOKEN_EXPIRES.total_seconds()) - }) - -@app.route('/api/v1/auth/refresh', methods=['POST']) -def refresh_access_token(): - """Refresh access token""" - data = request.get_json() - - if not data or 'refresh_token' not in data: - return jsonify({"error": "Missing refresh_token"}), 400 - - try: - # Decode refresh token - payload = decode_token(data['refresh_token']) - - if payload.get('type') != 'refresh': - return jsonify({"error": "Invalid token type"}), 400 - - license_id = payload['sub'] - - # Verify license still active - query = "SELECT is_active FROM licenses WHERE id = %s" - license = db_repo.execute_one(query, (license_id,)) - - if not license or not license['is_active']: - return jsonify({"error": "License is not active"}), 403 - - # Create new access token - access_payload = { - "sub": license_id, - "client_id": payload['client_id'], - "type": "access" - } - - access_token = create_token(access_payload, config.JWT_ACCESS_TOKEN_EXPIRES) - - return jsonify({ - "access_token": access_token, - "token_type": "Bearer", - "expires_in": int(config.JWT_ACCESS_TOKEN_EXPIRES.total_seconds()) - }) - - except ValueError as e: - return jsonify({"error": str(e)}), 401 - -@app.route('/api/v1/auth/verify', methods=['POST']) -def verify_token(): - """Verify token validity""" - auth_header = request.headers.get('Authorization') - - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({"error": "Missing or invalid authorization header"}), 401 - - token = auth_header.split(' ')[1] - - try: - payload = decode_token(token) - - return jsonify({ - "valid": True, - "license_id": payload['sub'], - "expires_at": datetime.fromtimestamp(payload['exp']).isoformat() - }) - - except ValueError as e: - return jsonify({ - "valid": False, - "error": str(e) - }), 401 - -@app.route('/api/v1/auth/api-key', methods=['POST']) -def create_api_key(): - """Create new API key (admin only)""" - # This endpoint should be protected by admin authentication - # For now, we'll use a simple secret header - admin_secret = request.headers.get('X-Admin-Secret') - - if admin_secret != os.getenv('ADMIN_SECRET', 'change-this-admin-secret'): - return jsonify({"error": "Unauthorized"}), 401 - - data = request.get_json() - - if not data or 'client_name' not in data: - return jsonify({"error": "Missing client_name"}), 400 - - import secrets - api_key = f"sk_{secrets.token_urlsafe(32)}" - secret_key = secrets.token_urlsafe(64) - - query = """ - INSERT INTO api_clients (client_name, api_key, secret_key, allowed_endpoints) - VALUES (%s, %s, %s, %s) - RETURNING id - """ - - allowed_endpoints = data.get('allowed_endpoints', []) - client_id = db_repo.execute_insert( - query, - (data['client_name'], api_key, secret_key, allowed_endpoints) - ) - - if not client_id: - return jsonify({"error": "Failed to create API key"}), 500 - - return jsonify({ - "client_id": client_id, - "api_key": api_key, - "secret_key": secret_key, - "client_name": data['client_name'] - }), 201 - -@app.errorhandler(404) -def not_found(error): - return jsonify({"error": "Not found"}), 404 - -@app.errorhandler(500) -def internal_error(error): - logger.error(f"Internal error: {error}") - return jsonify({"error": "Internal server error"}), 500 - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5001, debug=True) \ No newline at end of file diff --git a/lizenzserver/services/auth/config.py b/lizenzserver/services/auth/config.py deleted file mode 100644 index 60da21f..0000000 --- a/lizenzserver/services/auth/config.py +++ /dev/null @@ -1,15 +0,0 @@ -import os -from datetime import timedelta - -def get_config(): - """Get configuration from environment variables""" - return { - 'DATABASE_URL': os.getenv('DATABASE_URL', 'postgresql://postgres:password@postgres:5432/v2_adminpanel'), - 'REDIS_URL': os.getenv('REDIS_URL', 'redis://redis:6379/1'), - 'JWT_SECRET': os.getenv('JWT_SECRET', 'dev-secret-key'), - 'JWT_ALGORITHM': 'HS256', - 'ACCESS_TOKEN_EXPIRE_MINUTES': 30, - 'REFRESH_TOKEN_EXPIRE_DAYS': 7, - 'FLASK_ENV': os.getenv('FLASK_ENV', 'production'), - 'LOG_LEVEL': os.getenv('LOG_LEVEL', 'INFO'), - } \ No newline at end of file diff --git a/lizenzserver/services/auth/requirements.txt b/lizenzserver/services/auth/requirements.txt deleted file mode 100644 index 1c13f39..0000000 --- a/lizenzserver/services/auth/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -flask==3.0.0 -flask-cors==4.0.0 -pyjwt==2.8.0 -psycopg2-binary==2.9.9 -redis==5.0.1 -python-dotenv==1.0.0 -gunicorn==21.2.0 -marshmallow==3.20.1 -prometheus-flask-exporter==0.23.0 \ No newline at end of file diff --git a/lizenzserver/services/license_api/Dockerfile b/lizenzserver/services/license_api/Dockerfile deleted file mode 100644 index d8e624d..0000000 --- a/lizenzserver/services/license_api/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . . - -# Create non-root user -RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app -USER appuser - -# Expose port -EXPOSE 5002 - -# Run with gunicorn -CMD ["gunicorn", "--bind", "0.0.0.0:5002", "--workers", "4", "--timeout", "120", "app:app"] \ No newline at end of file diff --git a/lizenzserver/services/license_api/app.py b/lizenzserver/services/license_api/app.py deleted file mode 100644 index 14d55a5..0000000 --- a/lizenzserver/services/license_api/app.py +++ /dev/null @@ -1,409 +0,0 @@ -import os -import sys -from flask import Flask, request, jsonify -from flask_cors import CORS -import jwt -from datetime import datetime, timedelta -import logging -from functools import wraps -from marshmallow import Schema, fields, ValidationError - -# Add parent directory to path for imports -sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from config import get_config -from repositories.license_repo import LicenseRepository -from repositories.cache_repo import CacheRepository -from events.event_bus import EventBus, Event, EventTypes -from models import EventType, ValidationRequest, ValidationResponse - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Initialize Flask app -app = Flask(__name__) -config = get_config() -app.config.from_object(config) -CORS(app) - -# Initialize dependencies -license_repo = LicenseRepository(config.DATABASE_URL) -cache_repo = CacheRepository(config.REDIS_URL) -event_bus = EventBus(config.RABBITMQ_URL) - -# Validation schemas -class ValidateSchema(Schema): - license_key = fields.Str(required=True) - hardware_id = fields.Str(required=True) - app_version = fields.Str() - -class ActivateSchema(Schema): - license_key = fields.Str(required=True) - hardware_id = fields.Str(required=True) - device_name = fields.Str() - os_info = fields.Dict() - -class HeartbeatSchema(Schema): - session_data = fields.Dict() - -class OfflineTokenSchema(Schema): - duration_hours = fields.Int(missing=24, validate=lambda x: 0 < x <= 72) - -def require_api_key(f): - """Decorator to require API key""" - @wraps(f) - def decorated_function(*args, **kwargs): - api_key = request.headers.get('X-API-Key') - - if not api_key: - return jsonify({"error": "Missing API key"}), 401 - - # For now, accept any API key starting with 'sk_' - # In production, validate against database - if not api_key.startswith('sk_'): - return jsonify({"error": "Invalid API key"}), 401 - - return f(*args, **kwargs) - - return decorated_function - -def require_auth_token(f): - """Decorator to require JWT token""" - @wraps(f) - def decorated_function(*args, **kwargs): - auth_header = request.headers.get('Authorization') - - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({"error": "Missing or invalid authorization header"}), 401 - - token = auth_header.split(' ')[1] - - try: - payload = jwt.decode( - token, - config.JWT_SECRET, - algorithms=[config.JWT_ALGORITHM] - ) - request.token_payload = payload - return f(*args, **kwargs) - except jwt.ExpiredSignatureError: - return jsonify({"error": "Token has expired"}), 401 - except jwt.InvalidTokenError: - return jsonify({"error": "Invalid token"}), 401 - - return decorated_function - -def get_client_ip(): - """Get client IP address""" - if request.headers.get('X-Forwarded-For'): - return request.headers.get('X-Forwarded-For').split(',')[0] - return request.remote_addr - -@app.route('/health', methods=['GET']) -def health_check(): - """Health check endpoint""" - return jsonify({ - "status": "healthy", - "service": "license-api", - "timestamp": datetime.utcnow().isoformat() - }) - -@app.route('/api/v1/license/validate', methods=['POST']) -@require_api_key -def validate_license(): - """Validate license key with hardware ID""" - schema = ValidateSchema() - - try: - data = schema.load(request.get_json()) - except ValidationError as e: - return jsonify({"error": "Invalid request", "details": e.messages}), 400 - - license_key = data['license_key'] - hardware_id = data['hardware_id'] - app_version = data.get('app_version') - - # Check cache first - cached_result = cache_repo.get_license_validation(license_key, hardware_id) - if cached_result: - logger.info(f"Cache hit for license validation: {license_key[:8]}...") - return jsonify(cached_result) - - # Get license from database - license = license_repo.get_license_by_key(license_key) - - if not license: - event_bus.publish(Event( - EventTypes.LICENSE_VALIDATION_FAILED, - {"license_key": license_key, "reason": "not_found"}, - "license-api" - )) - return jsonify({ - "valid": False, - "error": "License not found", - "error_code": "LICENSE_NOT_FOUND" - }), 404 - - # Check if license is active - if not license['is_active']: - event_bus.publish(Event( - EventTypes.LICENSE_VALIDATION_FAILED, - {"license_id": license['id'], "reason": "inactive"}, - "license-api" - )) - return jsonify({ - "valid": False, - "error": "License is not active", - "error_code": "LICENSE_INACTIVE" - }), 403 - - # Check expiration - if license['expires_at'] and datetime.utcnow() > license['expires_at']: - event_bus.publish(Event( - EventTypes.LICENSE_EXPIRED, - {"license_id": license['id']}, - "license-api" - )) - return jsonify({ - "valid": False, - "error": "License has expired", - "error_code": "LICENSE_EXPIRED" - }), 403 - - # Check device limit - device_count = license_repo.get_device_count(license['id']) - if device_count >= license['max_devices']: - # Check if this device is already registered - if not license_repo.check_hardware_id_exists(license['id'], hardware_id): - return jsonify({ - "valid": False, - "error": "Device limit exceeded", - "error_code": "DEVICE_LIMIT_EXCEEDED", - "current_devices": device_count, - "max_devices": license['max_devices'] - }), 403 - - # Record heartbeat - license_repo.record_heartbeat( - license_id=license['id'], - hardware_id=hardware_id, - ip_address=get_client_ip(), - user_agent=request.headers.get('User-Agent'), - app_version=app_version - ) - - # Create response - response = { - "valid": True, - "license_id": license['id'], - "expires_at": license['expires_at'].isoformat() if license['expires_at'] else None, - "features": license.get('features', []), - "limits": { - "max_devices": license['max_devices'], - "current_devices": device_count - } - } - - # Cache the result - cache_repo.set_license_validation( - license_key, - hardware_id, - response, - config.CACHE_TTL_VALIDATION - ) - - # Publish success event - event_bus.publish(Event( - EventTypes.LICENSE_VALIDATED, - { - "license_id": license['id'], - "hardware_id": hardware_id, - "ip_address": get_client_ip() - }, - "license-api" - )) - - return jsonify(response) - -@app.route('/api/v1/license/activate', methods=['POST']) -@require_api_key -def activate_license(): - """Activate license on a new device""" - schema = ActivateSchema() - - try: - data = schema.load(request.get_json()) - except ValidationError as e: - return jsonify({"error": "Invalid request", "details": e.messages}), 400 - - license_key = data['license_key'] - hardware_id = data['hardware_id'] - device_name = data.get('device_name') - os_info = data.get('os_info', {}) - - # Get license - license = license_repo.get_license_by_key(license_key) - - if not license: - return jsonify({ - "error": "License not found", - "error_code": "LICENSE_NOT_FOUND" - }), 404 - - if not license['is_active']: - return jsonify({ - "error": "License is not active", - "error_code": "LICENSE_INACTIVE" - }), 403 - - # Check if already activated on this device - if license_repo.check_hardware_id_exists(license['id'], hardware_id): - return jsonify({ - "error": "License already activated on this device", - "error_code": "ALREADY_ACTIVATED" - }), 400 - - # Check device limit - device_count = license_repo.get_device_count(license['id']) - if device_count >= license['max_devices']: - return jsonify({ - "error": "Device limit exceeded", - "error_code": "DEVICE_LIMIT_EXCEEDED", - "current_devices": device_count, - "max_devices": license['max_devices'] - }), 403 - - # Record activation - license_repo.record_activation_event( - license_id=license['id'], - event_type=EventType.ACTIVATION, - hardware_id=hardware_id, - ip_address=get_client_ip(), - user_agent=request.headers.get('User-Agent'), - success=True, - metadata={ - "device_name": device_name, - "os_info": os_info - } - ) - - # Invalidate cache - cache_repo.invalidate_license_cache(license['id']) - - # Publish event - event_bus.publish(Event( - EventTypes.LICENSE_ACTIVATED, - { - "license_id": license['id'], - "hardware_id": hardware_id, - "device_name": device_name - }, - "license-api" - )) - - return jsonify({ - "success": True, - "license_id": license['id'], - "message": "License activated successfully" - }), 201 - -@app.route('/api/v1/license/heartbeat', methods=['POST']) -@require_auth_token -def heartbeat(): - """Record license heartbeat""" - schema = HeartbeatSchema() - - try: - data = schema.load(request.get_json() or {}) - except ValidationError as e: - return jsonify({"error": "Invalid request", "details": e.messages}), 400 - - license_id = request.token_payload['sub'] - hardware_id = request.token_payload.get('hwid') - - # Record heartbeat - license_repo.record_heartbeat( - license_id=license_id, - hardware_id=hardware_id, - ip_address=get_client_ip(), - user_agent=request.headers.get('User-Agent'), - session_data=data.get('session_data', {}) - ) - - return jsonify({ - "success": True, - "timestamp": datetime.utcnow().isoformat() - }) - -@app.route('/api/v1/license/offline-token', methods=['POST']) -@require_auth_token -def create_offline_token(): - """Create offline validation token""" - schema = OfflineTokenSchema() - - try: - data = schema.load(request.get_json() or {}) - except ValidationError as e: - return jsonify({"error": "Invalid request", "details": e.messages}), 400 - - license_id = request.token_payload['sub'] - hardware_id = request.token_payload.get('hwid') - duration_hours = data['duration_hours'] - - if not hardware_id: - return jsonify({"error": "Hardware ID required"}), 400 - - # Create offline token - token = license_repo.create_license_token( - license_id=license_id, - hardware_id=hardware_id, - valid_hours=duration_hours - ) - - if not token: - return jsonify({"error": "Failed to create token"}), 500 - - valid_until = datetime.utcnow() + timedelta(hours=duration_hours) - - return jsonify({ - "token": token, - "valid_until": valid_until.isoformat(), - "duration_hours": duration_hours - }) - -@app.route('/api/v1/license/validate-offline', methods=['POST']) -def validate_offline_token(): - """Validate offline token""" - data = request.get_json() - - if not data or 'token' not in data: - return jsonify({"error": "Missing token"}), 400 - - # Validate token - result = license_repo.validate_token(data['token']) - - if not result: - return jsonify({ - "valid": False, - "error": "Invalid or expired token" - }), 401 - - return jsonify({ - "valid": True, - "license_id": result['license_id'], - "hardware_id": result['hardware_id'], - "expires_at": result['valid_until'].isoformat() - }) - -@app.errorhandler(404) -def not_found(error): - return jsonify({"error": "Not found"}), 404 - -@app.errorhandler(500) -def internal_error(error): - logger.error(f"Internal error: {error}") - return jsonify({"error": "Internal server error"}), 500 - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5002, debug=True) \ No newline at end of file diff --git a/lizenzserver/services/license_api/requirements.txt b/lizenzserver/services/license_api/requirements.txt deleted file mode 100644 index 7f36e3a..0000000 --- a/lizenzserver/services/license_api/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -flask==3.0.0 -flask-cors==4.0.0 -pyjwt==2.8.0 -psycopg2-binary==2.9.9 -redis==5.0.1 -pika==1.3.2 -python-dotenv==1.0.0 -gunicorn==21.2.0 -marshmallow==3.20.1 -requests==2.31.0 \ No newline at end of file diff --git a/scripts/reset-to-dhcp.ps1 b/scripts/reset-to-dhcp.ps1 deleted file mode 100644 index 4b67ddc..0000000 --- a/scripts/reset-to-dhcp.ps1 +++ /dev/null @@ -1,38 +0,0 @@ -# PowerShell Script zum Zurücksetzen auf DHCP -# MUSS ALS ADMINISTRATOR AUSGEFÜHRT WERDEN! - -Write-Host "=== Zurücksetzen auf DHCP (automatische IP) ===" -ForegroundColor Green - -# Aktive WLAN-Adapter finden -$adapters = Get-NetAdapter | Where-Object {$_.Status -eq 'Up' -and ($_.Name -like '*WLAN*' -or $_.Name -like '*Wi-Fi*')} - -if ($adapters.Count -eq 0) { - Write-Host "Kein aktiver WLAN-Adapter gefunden!" -ForegroundColor Red - exit -} - -# Den ersten aktiven WLAN-Adapter nehmen -$adapter = $adapters[0] -Write-Host "`nSetze Adapter zurück auf DHCP: $($adapter.Name)" -ForegroundColor Cyan - -# Statische IP entfernen -Write-Host "`nEntferne statische IP-Konfiguration..." -ForegroundColor Yellow -Remove-NetIPAddress -InterfaceIndex $adapter.InterfaceIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue -Remove-NetRoute -InterfaceIndex $adapter.InterfaceIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue - -# DHCP aktivieren -Write-Host "`nAktiviere DHCP..." -ForegroundColor Green -Set-NetIPInterface -InterfaceIndex $adapter.InterfaceIndex -Dhcp Enabled - -# DNS auf automatisch setzen -Write-Host "`nSetze DNS auf automatisch..." -ForegroundColor Green -Set-DnsClientServerAddress -InterfaceIndex $adapter.InterfaceIndex -ResetServerAddresses - -Write-Host "`n✅ Fertig! Der Adapter nutzt jetzt wieder DHCP (automatische IP-Vergabe)" -ForegroundColor Green - -# Kurz warten -Start-Sleep -Seconds 3 - -# Neue IP anzeigen -Write-Host "`nNeue IP-Adresse:" -ForegroundColor Yellow -Get-NetIPAddress -InterfaceIndex $adapter.InterfaceIndex -AddressFamily IPv4 | Format-Table IPAddress, PrefixLength \ No newline at end of file diff --git a/scripts/set-static-ip.ps1 b/scripts/set-static-ip.ps1 deleted file mode 100644 index a8c2758..0000000 --- a/scripts/set-static-ip.ps1 +++ /dev/null @@ -1,54 +0,0 @@ -# PowerShell Script für statische IP-Konfiguration -# MUSS ALS ADMINISTRATOR AUSGEFÜHRT WERDEN! - -Write-Host "=== Statische IP 192.168.178.88 einrichten ===" -ForegroundColor Green - -# Aktive WLAN-Adapter finden -$adapters = Get-NetAdapter | Where-Object {$_.Status -eq 'Up' -and ($_.Name -like '*WLAN*' -or $_.Name -like '*Wi-Fi*')} - -if ($adapters.Count -eq 0) { - Write-Host "Kein aktiver WLAN-Adapter gefunden!" -ForegroundColor Red - exit -} - -Write-Host "`nGefundene WLAN-Adapter:" -ForegroundColor Yellow -$adapters | Format-Table Name, Status, InterfaceIndex - -# Den ersten aktiven WLAN-Adapter nehmen -$adapter = $adapters[0] -Write-Host "`nKonfiguriere Adapter: $($adapter.Name)" -ForegroundColor Cyan - -# Aktuelle Konfiguration anzeigen -Write-Host "`nAktuelle IP-Konfiguration:" -ForegroundColor Yellow -Get-NetIPAddress -InterfaceIndex $adapter.InterfaceIndex -AddressFamily IPv4 | Format-Table IPAddress, PrefixLength - -# Alte IP-Konfiguration entfernen -Write-Host "`nEntferne alte IP-Konfiguration..." -ForegroundColor Yellow -Remove-NetIPAddress -InterfaceIndex $adapter.InterfaceIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue -Remove-NetRoute -InterfaceIndex $adapter.InterfaceIndex -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue - -# Neue statische IP setzen -Write-Host "`nSetze neue statische IP: 192.168.178.88" -ForegroundColor Green -New-NetIPAddress -InterfaceIndex $adapter.InterfaceIndex -IPAddress "192.168.178.88" -PrefixLength 24 -DefaultGateway "192.168.178.1" -AddressFamily IPv4 - -# DNS-Server setzen (FRITZ!Box und Google) -Write-Host "`nSetze DNS-Server..." -ForegroundColor Green -Set-DnsClientServerAddress -InterfaceIndex $adapter.InterfaceIndex -ServerAddresses "192.168.178.1", "8.8.8.8" - -# Neue Konfiguration anzeigen -Write-Host "`nNeue IP-Konfiguration:" -ForegroundColor Green -Get-NetIPAddress -InterfaceIndex $adapter.InterfaceIndex -AddressFamily IPv4 | Format-Table IPAddress, PrefixLength -Get-NetRoute -InterfaceIndex $adapter.InterfaceIndex -DestinationPrefix "0.0.0.0/0" | Format-Table DestinationPrefix, NextHop - -Write-Host "`n✅ Fertig! Ihre IP ist jetzt: 192.168.178.88" -ForegroundColor Green -Write-Host "Die FRITZ!Box Port-Weiterleitung sollte jetzt funktionieren!" -ForegroundColor Green - -# Test -Write-Host "`nTeste Internetverbindung..." -ForegroundColor Yellow -Test-NetConnection google.com -Port 80 -InformationLevel Quiet - -if ($?) { - Write-Host "✅ Internetverbindung funktioniert!" -ForegroundColor Green -} else { - Write-Host "❌ Keine Internetverbindung - prüfen Sie die Einstellungen!" -ForegroundColor Red -} \ No newline at end of file diff --git a/scripts/setup-firewall.ps1 b/scripts/setup-firewall.ps1 deleted file mode 100644 index 1b0e9f6..0000000 --- a/scripts/setup-firewall.ps1 +++ /dev/null @@ -1,16 +0,0 @@ -# PowerShell-Script für Windows Firewall Konfiguration -# Als Administrator ausführen! - -Write-Host "Konfiguriere Windows Firewall für Docker..." -ForegroundColor Green - -# Firewall-Regeln für HTTP und HTTPS -New-NetFirewallRule -DisplayName "Docker HTTP" -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow -ErrorAction SilentlyContinue -New-NetFirewallRule -DisplayName "Docker HTTPS" -Direction Inbound -Protocol TCP -LocalPort 443 -Action Allow -ErrorAction SilentlyContinue - -Write-Host "Firewall-Regeln erstellt." -ForegroundColor Green - -# Docker-Service neustarten (optional) -Write-Host "Starte Docker-Service neu..." -ForegroundColor Yellow -Restart-Service docker - -Write-Host "Fertig! Ports 80 und 443 sind jetzt offen." -ForegroundColor Green \ No newline at end of file diff --git a/v2/.env b/v2/.env deleted file mode 100644 index 616e37c..0000000 --- a/v2/.env +++ /dev/null @@ -1,70 +0,0 @@ -# PostgreSQL-Datenbank -POSTGRES_DB=meinedatenbank -POSTGRES_USER=adminuser -POSTGRES_PASSWORD=supergeheimespasswort - -# Admin-Panel Zugangsdaten -ADMIN1_USERNAME=rac00n -ADMIN1_PASSWORD=1248163264 -ADMIN2_USERNAME=w@rh@mm3r -ADMIN2_PASSWORD=Warhammer123! - -# Lizenzserver API Key für Authentifizierung - - -# Domains (können von der App ausgewertet werden, z. B. für Links oder CORS) -API_DOMAIN=api-software-undso.intelsight.de -ADMIN_PANEL_DOMAIN=admin-panel-undso.intelsight.de - -# ===================== OPTIONALE VARIABLEN ===================== - -# JWT für API-Auth (WICHTIG: Für sichere Token-Verschlüsselung!) -JWT_SECRET=xY9ZmK2pL7nQ4wF6jH8vB3tG5aZ1dE7fR9hT2kM4nP6qS8uW0xC3yA5bD7eF9gH2jK4 - -# E-Mail Konfiguration (z. B. bei Ablaufwarnungen) -# MAIL_SERVER=smtp.meinedomain.de -# MAIL_PORT=587 -# MAIL_USERNAME=deinemail -# MAIL_PASSWORD=geheim -# MAIL_FROM=no-reply@meinedomain.de - -# Logging -# LOG_LEVEL=info - -# Erlaubte CORS-Domains (für Web-Frontend) -# ALLOWED_ORIGINS=https://admin.meinedomain.de - -# ===================== VERSION ===================== - -# Serverseitig gepflegte aktuelle Software-Version -# Diese wird vom Lizenzserver genutzt, um die Kundenversion zu vergleichen -LATEST_CLIENT_VERSION=1.0.0 - -# ===================== BACKUP KONFIGURATION ===================== - -# E-Mail für Backup-Benachrichtigungen -EMAIL_ENABLED=false - -# Backup-Verschlüsselung (optional, wird automatisch generiert wenn leer) -# BACKUP_ENCRYPTION_KEY= - -# ===================== CAPTCHA KONFIGURATION ===================== - -# Google reCAPTCHA v2 Keys (https://www.google.com/recaptcha/admin) -# Für PoC-Phase auskommentiert - CAPTCHA wird übersprungen wenn Keys fehlen -# RECAPTCHA_SITE_KEY=your-site-key-here -# RECAPTCHA_SECRET_KEY=your-secret-key-here - -# ===================== MONITORING KONFIGURATION ===================== - -# Grafana Admin Credentials -GRAFANA_USER=admin -GRAFANA_PASSWORD=admin - -# SMTP Settings for Alertmanager (optional) -# SMTP_USERNAME=your-email@gmail.com -# SMTP_PASSWORD=your-app-password - -# Webhook URLs for critical alerts (optional) -# WEBHOOK_CRITICAL=https://your-webhook-url/critical -# WEBHOOK_SECURITY=https://your-webhook-url/security diff --git a/v2/.env.production.template b/v2/.env.production.template deleted file mode 100644 index d8f0056..0000000 --- a/v2/.env.production.template +++ /dev/null @@ -1,56 +0,0 @@ -# PostgreSQL-Datenbank -POSTGRES_DB=meinedatenbank -POSTGRES_USER=adminuser -# IMPORTANT: Generate a strong password using generate-secrets.py -POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD - -# Admin-Panel Zugangsdaten -ADMIN1_USERNAME=rac00n -ADMIN1_PASSWORD=1248163264 -ADMIN2_USERNAME=w@rh@mm3r -ADMIN2_PASSWORD=Warhammer123! - -# Domains -API_DOMAIN=api-software-undso.intelsight.de -ADMIN_PANEL_DOMAIN=admin-panel-undso.intelsight.de - -# JWT für API-Auth (WICHTIG: Für sichere Token-Verschlüsselung!) -# IMPORTANT: Generate using generate-secrets.py -JWT_SECRET=CHANGE_THIS_GENERATE_SECURE_SECRET - -# E-Mail Konfiguration (optional) -# MAIL_SERVER=smtp.meinedomain.de -# MAIL_PORT=587 -# MAIL_USERNAME=deinemail -# MAIL_PASSWORD=geheim -# MAIL_FROM=no-reply@intelsight.de - -# Logging -LOG_LEVEL=info - -# Erlaubte CORS-Domains (für Web-Frontend) -ALLOWED_ORIGINS=https://admin-panel-undso.intelsight.de - -# VERSION -LATEST_CLIENT_VERSION=1.0.0 - -# BACKUP KONFIGURATION -EMAIL_ENABLED=false - -# CAPTCHA KONFIGURATION (optional für PoC) -# RECAPTCHA_SITE_KEY=your-site-key-here -# RECAPTCHA_SECRET_KEY=your-secret-key-here - -# MONITORING KONFIGURATION -GRAFANA_USER=admin -# IMPORTANT: Generate a strong password using generate-secrets.py -GRAFANA_PASSWORD=CHANGE_THIS_STRONG_PASSWORD - -# SMTP Settings for Alertmanager (optional) -# SMTP_USERNAME=your-email@gmail.com -# SMTP_PASSWORD=your-app-password - -# Webhook URLs for critical alerts (optional) -# WEBHOOK_CRITICAL=https://your-webhook-url/critical -# WEBHOOK_SECURITY=https://your-webhook-url/security - diff --git a/v2/backup_before_timezone_change.sql b/v2/backup_before_timezone_change.sql deleted file mode 100644 index a1ddb79..0000000 --- a/v2/backup_before_timezone_change.sql +++ /dev/null @@ -1,624 +0,0 @@ --- --- PostgreSQL database dump --- - --- Dumped from database version 14.18 (Debian 14.18-1.pgdg120+1) --- Dumped by pg_dump version 14.18 (Debian 14.18-1.pgdg120+1) - -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''; - -SET default_table_access_method = heap; - --- --- Name: audit_log; Type: TABLE; Schema: public; Owner: adminuser --- - -CREATE TABLE public.audit_log ( - id integer NOT NULL, - "timestamp" timestamp without 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 -); - - -ALTER TABLE public.audit_log OWNER TO adminuser; - --- --- Name: audit_log_id_seq; Type: SEQUENCE; Schema: public; Owner: adminuser --- - -CREATE SEQUENCE public.audit_log_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER TABLE public.audit_log_id_seq OWNER TO adminuser; - --- --- Name: audit_log_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: adminuser --- - -ALTER SEQUENCE public.audit_log_id_seq OWNED BY public.audit_log.id; - - --- --- Name: backup_history; Type: TABLE; Schema: public; Owner: adminuser --- - -CREATE TABLE public.backup_history ( - id integer NOT NULL, - filename text NOT NULL, - filepath text NOT NULL, - filesize bigint, - backup_type text NOT NULL, - status text NOT NULL, - error_message text, - created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, - created_by text NOT NULL, - tables_count integer, - records_count integer, - duration_seconds numeric, - is_encrypted boolean DEFAULT true -); - - -ALTER TABLE public.backup_history OWNER TO adminuser; - --- --- Name: backup_history_id_seq; Type: SEQUENCE; Schema: public; Owner: adminuser --- - -CREATE SEQUENCE public.backup_history_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER TABLE public.backup_history_id_seq OWNER TO adminuser; - --- --- Name: backup_history_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: adminuser --- - -ALTER SEQUENCE public.backup_history_id_seq OWNED BY public.backup_history.id; - - --- --- Name: customers; Type: TABLE; Schema: public; Owner: adminuser --- - -CREATE TABLE public.customers ( - id integer NOT NULL, - name text NOT NULL, - email text, - created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP -); - - -ALTER TABLE public.customers OWNER TO adminuser; - --- --- Name: customers_id_seq; Type: SEQUENCE; Schema: public; Owner: adminuser --- - -CREATE SEQUENCE public.customers_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER TABLE public.customers_id_seq OWNER TO adminuser; - --- --- Name: customers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: adminuser --- - -ALTER SEQUENCE public.customers_id_seq OWNED BY public.customers.id; - - --- --- Name: licenses; Type: TABLE; Schema: public; Owner: adminuser --- - -CREATE TABLE public.licenses ( - id integer NOT NULL, - license_key text NOT NULL, - customer_id integer, - license_type text NOT NULL, - valid_from date NOT NULL, - valid_until date NOT NULL, - is_active boolean DEFAULT true -); - - -ALTER TABLE public.licenses OWNER TO adminuser; - --- --- Name: licenses_id_seq; Type: SEQUENCE; Schema: public; Owner: adminuser --- - -CREATE SEQUENCE public.licenses_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER TABLE public.licenses_id_seq OWNER TO adminuser; - --- --- Name: licenses_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: adminuser --- - -ALTER SEQUENCE public.licenses_id_seq OWNED BY public.licenses.id; - - --- --- Name: login_attempts; Type: TABLE; Schema: public; Owner: adminuser --- - -CREATE TABLE public.login_attempts ( - ip_address character varying(45) NOT NULL, - attempt_count integer DEFAULT 0, - first_attempt timestamp without time zone DEFAULT CURRENT_TIMESTAMP, - last_attempt timestamp without time zone DEFAULT CURRENT_TIMESTAMP, - blocked_until timestamp without time zone, - last_username_tried text, - last_error_message text -); - - -ALTER TABLE public.login_attempts OWNER TO adminuser; - --- --- Name: sessions; Type: TABLE; Schema: public; Owner: adminuser --- - -CREATE TABLE public.sessions ( - id integer NOT NULL, - license_id integer, - session_id text NOT NULL, - ip_address text, - user_agent text, - started_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, - last_heartbeat timestamp without time zone DEFAULT CURRENT_TIMESTAMP, - ended_at timestamp without time zone, - is_active boolean DEFAULT true -); - - -ALTER TABLE public.sessions OWNER TO adminuser; - --- --- Name: sessions_id_seq; Type: SEQUENCE; Schema: public; Owner: adminuser --- - -CREATE SEQUENCE public.sessions_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - -ALTER TABLE public.sessions_id_seq OWNER TO adminuser; - --- --- Name: sessions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: adminuser --- - -ALTER SEQUENCE public.sessions_id_seq OWNED BY public.sessions.id; - - --- --- Name: audit_log id; Type: DEFAULT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.audit_log ALTER COLUMN id SET DEFAULT nextval('public.audit_log_id_seq'::regclass); - - --- --- Name: backup_history id; Type: DEFAULT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.backup_history ALTER COLUMN id SET DEFAULT nextval('public.backup_history_id_seq'::regclass); - - --- --- Name: customers id; Type: DEFAULT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.customers ALTER COLUMN id SET DEFAULT nextval('public.customers_id_seq'::regclass); - - --- --- Name: licenses id; Type: DEFAULT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.licenses ALTER COLUMN id SET DEFAULT nextval('public.licenses_id_seq'::regclass); - - --- --- Name: sessions id; Type: DEFAULT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.sessions ALTER COLUMN id SET DEFAULT nextval('public.sessions_id_seq'::regclass); - - --- --- Data for Name: audit_log; Type: TABLE DATA; Schema: public; Owner: adminuser --- - -COPY public.audit_log (id, "timestamp", username, action, entity_type, entity_id, old_values, new_values, ip_address, user_agent, additional_info) FROM stdin; -1 2025-06-07 14:40:45.833151 rac00n LOGIN user \N \N \N 172.19.0.1 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung -2 2025-06-07 14:41:11.190149 rac00n LOGOUT user \N \N \N 172.19.0.1 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Abmeldung -3 2025-06-07 14:41:17.105596 rac00n LOGIN user \N \N \N 172.19.0.1 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung -4 2025-06-07 15:15:06.256545 rac00n LOGIN user \N \N \N 172.19.0.1 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung -5 2025-06-07 15:15:33.972469 rac00n LOGIN user \N \N \N 172.19.0.1 curl/8.5.0 Erfolgreiche Anmeldung -6 2025-06-07 15:15:34.109986 rac00n BACKUP database 1 \N \N 172.19.0.1 curl/8.5.0 Backup erstellt: backup_v2docker_20250607_151534_encrypted.sql.gz.enc (3640 bytes) -7 2025-06-07 15:15:43.824357 rac00n LOGIN user \N \N \N 172.19.0.1 curl/8.5.0 Erfolgreiche Anmeldung -8 2025-06-07 15:15:43.912466 rac00n BACKUP database 2 \N \N 172.19.0.1 curl/8.5.0 Backup erstellt: backup_v2docker_20250607_151543_encrypted.sql.gz.enc (3768 bytes) -9 2025-06-07 15:16:23.724322 rac00n DOWNLOAD backup 2 \N \N 172.19.0.1 curl/8.5.0 Backup heruntergeladen: backup_v2docker_20250607_151543_encrypted.sql.gz.enc -76 2025-06-08 07:55:31.892854 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -78 2025-06-08 07:59:05.01361 rac00n DOWNLOAD backup 5 \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Backup heruntergeladen: backup_v2docker_20250608_075834_encrypted.sql.gz.enc -80 2025-06-08 08:06:18.479631 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/28.0 Chrome/130.0.0.0 Mobile Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -82 2025-06-08 14:36:01.730846 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -84 2025-06-08 16:13:31.837749 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -86 2025-06-08 17:47:45.942574 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 python-requests/2.31.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -10 2025-06-07 17:01:30.363152 w@rh@mm3r LOGOUT user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Abmeldung -11 2025-06-07 17:11:08.302021 w@rh@mm3r LOGIN user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung -12 2025-06-07 17:11:10.058681 w@rh@mm3r LOGOUT user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Abmeldung -13 2025-06-07 17:11:17.726581 rac00n LOGIN user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung -14 2025-06-07 17:37:01.770454 rac00n LOGIN user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung -15 2025-06-07 17:45:53.454599 rac00n LOGIN user \N \N \N 172.19.0.5 curl/8.5.0 Erfolgreiche Anmeldung -16 2025-06-07 17:46:45.804984 rac00n BACKUP database 3 \N \N 172.19.0.5 curl/8.5.0 Backup erstellt: backup_v2docker_20250607_174645_encrypted.sql.gz.enc (4108 bytes) -17 2025-06-07 17:54:25.805772 rac00n LOGIN user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung -18 2025-06-07 17:54:30.18286 rac00n LOGOUT user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Abmeldung -19 2025-06-07 18:02:18.1851 rac00n LOGIN user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung -20 2025-06-07 18:55:11.584957 system LOGIN_FAILED user \N \N \N 172.19.0.5 curl/8.5.0 IP: 172.19.0.1, User: testuser, Message: YOU FAILED -21 2025-06-07 18:55:13.112779 system LOGIN_FAILED user \N \N \N 172.19.0.5 curl/8.5.0 IP: 172.19.0.1, User: testuser, Message: WRONG! 🚫 -22 2025-06-07 19:00:59.418824 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -23 2025-06-07 19:01:54.566099 rac00n LOGOUT user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Abmeldung -24 2025-06-07 19:03:45.046523 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -25 2025-06-07 19:31:13.237177 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -26 2025-06-07 19:32:20.003266 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -27 2025-06-07 19:33:26.379968 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -28 2025-06-07 19:34:09.072085 rac00n LOGOUT user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Abmeldung -29 2025-06-07 19:36:17.489882 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 curl/8.5.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -30 2025-06-07 19:44:27.078563 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -31 2025-06-07 19:51:17.993707 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 curl/8.5.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -32 2025-06-07 19:58:17.448169 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -33 2025-06-07 19:58:52.291492 rac00n LOGOUT user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Abmeldung -34 2025-06-07 20:03:15.20645 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -35 2025-06-07 20:05:19.654401 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 curl/8.5.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -36 2025-06-07 20:11:25.331298 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 curl/8.5.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -37 2025-06-07 20:49:37.631513 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 curl/8.5.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -38 2025-06-07 20:52:27.354576 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 curl/8.5.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -39 2025-06-07 20:52:27.38643 rac00n EXPORT license \N \N \N 172.19.0.5 curl/8.5.0 Export aller Lizenzen als CSV -40 2025-06-07 20:54:04.783715 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -41 2025-06-07 20:56:50.950356 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -42 2025-06-07 21:06:51.653154 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 curl/8.5.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -43 2025-06-07 21:12:07.627636 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -44 2025-06-07 21:45:19.164825 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -45 2025-06-07 21:56:57.616336 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -46 2025-06-07 21:58:35.15561 rac00n CREATE license 1 \N {"valid_from": "2025-06-07", "license_key": "AF-202506T-75R4-9M9H-DFEY", "valid_until": "2025-06-13", "license_type": "test", "customer_name": "Demo Firma GmbH", "customer_email": "demo@firma.de"} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -47 2025-06-07 22:01:01.548875 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -48 2025-06-07 22:01:28.294051 rac00n CREATE license 2 \N {"valid_from": "2025-06-07", "license_key": "AF-202506T-3DF2-ELJN-RQDR", "valid_until": "2025-06-20", "license_type": "test", "customer_name": "Test Kunde", "customer_email": "test@example.com"} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -49 2025-06-07 22:19:17.155373 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -50 2025-06-07 22:19:29.307415 rac00n CREATE license 3 \N {"valid_from": "2025-06-07", "license_key": "AF-202506F-CRS3-PL6W-9CCP", "valid_until": "2026-06-06", "license_type": "full", "customer_name": "Test vor Restore", "customer_email": "test@restore.de"} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -51 2025-06-07 22:19:36.9697 rac00n CREATE license 4 \N {"valid_from": "2025-06-07", "license_key": "AF-202506F-GGKA-CJKV-P2PF", "valid_until": "2026-06-06", "license_type": "full", "customer_name": "Test vor Restore", "customer_email": "test@restore.de"} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -52 2025-06-07 22:19:51.747722 rac00n CREATE_BATCH license \N \N {"type": "full", "customer": "Müller GmbH & Co. KG", "quantity": 10} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Batch-Generierung von 10 Lizenzen -53 2025-06-07 22:23:56.496775 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -54 2025-06-07 22:24:29.52332 rac00n UPDATE customer 4 {"name": "Demo Firma GmbH", "email": "demo@firma.de"} {"name": "Demo Firma GmbH oder so ähnlich", "email": "demo@firma.de"} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -55 2025-06-07 22:24:53.387461 rac00n UPDATE license 14 {"is_active": true, "valid_from": "2025-06-07", "license_key": "AF-202506F-Z6KT-QY28-CTZ9", "valid_until": "2026-06-06", "license_type": "full"} {"is_active": false, "valid_from": "2025-06-07", "license_key": "AF-202506F-Z6KT-QY28-CTZ9", "valid_until": "2026-06-06", "license_type": "full"} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -56 2025-06-07 22:25:42.636503 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -57 2025-06-07 22:26:04.176345 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -58 2025-06-07 22:32:36.918402 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -59 2025-06-07 22:33:04.289738 rac00n UPDATE license 12 {"is_active": true, "valid_from": "2025-06-07", "license_key": "AF-202506F-F6E8-2KXL-7GVB", "valid_until": "2026-06-06", "license_type": "full"} {"is_active": false, "valid_from": "2025-06-07", "license_key": "AF-202506F-F6E8-2KXL-7GVB", "valid_until": "2026-06-06", "license_type": "full"} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -60 2025-06-07 22:37:11.24327 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -61 2025-06-07 22:40:11.528994 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -62 2025-06-07 23:02:03.858286 system LOGIN_FAILED user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 IP: 172.19.0.1, User: rac00n, Message: YOU FAILED -63 2025-06-07 23:02:13.590411 system LOGIN_FAILED user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 IP: 172.19.0.1, User: rac00n, Message: COMPUTER SAYS NO -64 2025-06-07 23:18:04.017897 system LOGIN_FAILED user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 IP: 172.19.0.1, User: rac00n, Message: NOPE! -65 2025-06-07 23:18:45.316866 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -66 2025-06-07 23:24:10.758514 rac00n CREATE customer 6 \N {"name": "Bli Bla Blub GmbH", "email": "test@bliblablu.info"} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -67 2025-06-07 23:24:10.763695 rac00n CREATE license 15 \N {"valid_from": "2025-06-08", "license_key": "AF-202506T-R5TC-9KT3-ZPRD", "valid_until": "2025-07-07", "license_type": "full", "customer_name": "Bli Bla Blub GmbH", "customer_email": "test@bliblablu.info"} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -68 2025-06-07 23:25:25.589172 rac00n UPDATE license 14 {"is_active": false, "valid_from": "2025-06-07", "license_key": "AF-202506F-Z6KT-QY28-CTZ9", "valid_until": "2026-06-06", "license_type": "full"} {"is_active": false, "valid_from": "2025-06-07", "license_key": "AF-202506F-Z6KT-QY28-CTZ9", "valid_until": "2026-06-06", "license_type": "full"} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -69 2025-06-07 23:25:33.134625 rac00n UPDATE license 11 {"is_active": true, "valid_from": "2025-06-07", "license_key": "AF-202506F-A6BV-6KAX-KU4J", "valid_until": "2026-06-06", "license_type": "full"} {"is_active": false, "valid_from": "2025-06-07", "license_key": "AF-202506F-A6BV-6KAX-KU4J", "valid_until": "2026-06-06", "license_type": "full"} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -70 2025-06-07 23:25:52.026955 rac00n DELETE license 14 {"license_key": "AF-202506F-Z6KT-QY28-CTZ9", "license_type": "full", "customer_name": "Müller GmbH & Co. KG"} \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -71 2025-06-07 23:26:34.32667 rac00n DELETE customer 5 {"name": "Max Mustermann", "email": "max@mustermann.de"} \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 \N -72 2025-06-07 23:27:11.170417 rac00n CREATE_BATCH license \N \N {"type": "full", "customer": "Bli Bla Blub GmbH", "quantity": 5} 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Batch-Generierung von 5 Lizenzen -73 2025-06-07 23:28:45.926809 rac00n BACKUP database 4 \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Backup erstellt: backup_v2docker_20250607_232845_encrypted.sql.gz.enc (7116 bytes) -74 2025-06-07 23:29:10.885674 rac00n DOWNLOAD backup 4 \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Backup heruntergeladen: backup_v2docker_20250607_232845_encrypted.sql.gz.enc -75 2025-06-08 07:55:19.354464 system LOGIN_FAILED user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 IP: 172.19.0.1, User: rac00n, Message: NOPE! -77 2025-06-08 07:58:34.270119 rac00n BACKUP database 5 \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Backup erstellt: backup_v2docker_20250608_075834_encrypted.sql.gz.enc (7308 bytes) -79 2025-06-08 07:59:21.636378 rac00n LOGOUT user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Abmeldung -81 2025-06-08 08:51:01.683507 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/28.0 Chrome/130.0.0.0 Mobile Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -83 2025-06-08 14:36:24.980042 rac00n LOGOUT user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Abmeldung -85 2025-06-08 17:21:27.353725 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -87 2025-06-08 17:49:18.527739 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 python-requests/2.31.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -88 2025-06-08 17:49:31.018007 rac00n BACKUP database 6 \N \N 172.19.0.5 python-requests/2.31.0 Backup erstellt: backup_v2docker_20250608_174930_encrypted.sql.gz.enc (7692 bytes) -89 2025-06-08 17:50:12.342288 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 python-requests/2.31.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -90 2025-06-08 18:01:50.515914 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -91 2025-06-08 18:02:24.836845 rac00n BACKUP database 7 \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Backup erstellt: backup_v2docker_20250608_200224_encrypted.sql.gz.enc (7864 bytes) -92 2025-06-08 18:02:35.914193 rac00n LOGOUT user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Abmeldung -93 2025-06-08 18:04:57.805724 system LOGOUT user \N \N \N 172.19.0.5 python-requests/2.31.0 Abmeldung -94 2025-06-08 18:04:59.88908 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 python-requests/2.31.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -95 2025-06-08 18:06:51.462396 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 python-requests/2.31.0 Erfolgreiche Anmeldung von IP: 172.19.0.1 -96 2025-06-08 18:10:13.065749 rac00n LOGIN_SUCCESS user \N \N \N 172.19.0.5 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Erfolgreiche Anmeldung von IP: 172.19.0.1 -\. - - --- --- Data for Name: backup_history; Type: TABLE DATA; Schema: public; Owner: adminuser --- - -COPY public.backup_history (id, filename, filepath, filesize, backup_type, status, error_message, created_at, created_by, tables_count, records_count, duration_seconds, is_encrypted) FROM stdin; -1 backup_v2docker_20250607_151534_encrypted.sql.gz.enc /app/backups/backup_v2docker_20250607_151534_encrypted.sql.gz.enc 3640 manual success \N 2025-06-07 15:15:34.003903 rac00n 5 6 0.1055765151977539 t -2 backup_v2docker_20250607_151543_encrypted.sql.gz.enc /app/backups/backup_v2docker_20250607_151543_encrypted.sql.gz.enc 3768 manual success \N 2025-06-07 15:15:43.853153 rac00n 5 9 0.058580875396728516 t -3 backup_v2docker_20250607_174645_encrypted.sql.gz.enc /app/backups/backup_v2docker_20250607_174645_encrypted.sql.gz.enc 4108 manual success \N 2025-06-07 17:46:45.701839 rac00n 5 19 0.10155487060546875 t -4 backup_v2docker_20250607_232845_encrypted.sql.gz.enc /app/backups/backup_v2docker_20250607_232845_encrypted.sql.gz.enc 7116 manual success \N 2025-06-07 23:28:45.823584 rac00n 6 102 0.10189580917358398 t -5 backup_v2docker_20250608_075834_encrypted.sql.gz.enc /app/backups/backup_v2docker_20250608_075834_encrypted.sql.gz.enc 7308 manual success \N 2025-06-08 07:58:34.205649 rac00n 6 107 0.06303167343139648 t -6 backup_v2docker_20250608_174930_encrypted.sql.gz.enc /app/backups/backup_v2docker_20250608_174930_encrypted.sql.gz.enc 7692 manual success \N 2025-06-08 17:49:30.953077 rac00n 6 119 0.06305289268493652 t -7 backup_v2docker_20250608_200224_encrypted.sql.gz.enc /app/backups/backup_v2docker_20250608_200224_encrypted.sql.gz.enc 7864 manual success \N 2025-06-08 18:02:24.771159 rac00n 6 123 0.06486988067626953 t -\. - - --- --- Data for Name: customers; Type: TABLE DATA; Schema: public; Owner: adminuser --- - -COPY public.customers (id, name, email, created_at) FROM stdin; -1 Müller GmbH & Co. KG kontakt@müller-köln.de 2025-06-07 14:39:21.554354 -2 Test vor Restore test@restore.de 2025-06-07 15:16:51.26188 -3 Test Kunde test@example.com 2025-06-07 21:43:48.308699 -4 Demo Firma GmbH oder so ähnlich demo@firma.de 2025-06-07 21:43:48.308699 -6 Bli Bla Blub GmbH test@bliblablu.info 2025-06-07 23:24:10.753449 -\. - - --- --- Data for Name: licenses; Type: TABLE DATA; Schema: public; Owner: adminuser --- - -COPY public.licenses (id, license_key, customer_id, license_type, valid_from, valid_until, is_active) FROM stdin; -1 AF-202506T-75R4-9M9H-DFEY 4 test 2025-06-07 2025-06-13 t -2 AF-202506T-3DF2-ELJN-RQDR 3 test 2025-06-07 2025-06-20 t -3 AF-202506F-CRS3-PL6W-9CCP 2 full 2025-06-07 2026-06-06 t -4 AF-202506F-GGKA-CJKV-P2PF 2 full 2025-06-07 2026-06-06 t -5 AF-202506F-FDZQ-MH3T-6EWP 1 full 2025-06-07 2026-06-06 t -6 AF-202506F-QYM9-58NT-SRCV 1 full 2025-06-07 2026-06-06 t -7 AF-202506F-9AVT-SY3C-KZ6H 1 full 2025-06-07 2026-06-06 t -8 AF-202506F-TS34-U93G-X74Y 1 full 2025-06-07 2026-06-06 t -9 AF-202506F-PC2K-SPEC-9MDS 1 full 2025-06-07 2026-06-06 t -10 AF-202506F-R2VL-J7YF-6XLA 1 full 2025-06-07 2026-06-06 t -13 AF-202506F-JU47-AS8Y-8VFP 1 full 2025-06-07 2026-06-06 t -12 AF-202506F-F6E8-2KXL-7GVB 1 full 2025-06-07 2026-06-06 f -15 AF-202506T-R5TC-9KT3-ZPRD 6 full 2025-06-08 2025-07-07 t -11 AF-202506F-A6BV-6KAX-KU4J 1 full 2025-06-07 2026-06-06 f -16 AF-202506F-F4WM-RUBG-RR7A 6 full 2025-06-08 2025-07-07 t -17 AF-202506F-ZFYY-D2ZG-9D2B 6 full 2025-06-08 2025-07-07 t -18 AF-202506F-KS7T-Z676-7V5L 6 full 2025-06-08 2025-07-07 t -19 AF-202506F-3NJ4-X5ET-RWY2 6 full 2025-06-08 2025-07-07 t -20 AF-202506F-B984-PHN6-BUDX 6 full 2025-06-08 2025-07-07 t -\. - - --- --- Data for Name: login_attempts; Type: TABLE DATA; Schema: public; Owner: adminuser --- - -COPY public.login_attempts (ip_address, attempt_count, first_attempt, last_attempt, blocked_until, last_username_tried, last_error_message) FROM stdin; -192.168.1.50 3 2025-06-07 18:56:54.538998 2025-06-07 18:56:54.538998 2025-06-07 20:56:54.538998 attacker1 NOPE\\! -10.0.0.25 5 2025-06-07 18:56:54.538998 2025-06-07 18:56:54.538998 2025-06-08 14:56:54.538998 scanner ACCESS DENIED, TRY HARDER -192.168.100.200 7 2025-06-07 18:56:54.538998 2025-06-07 18:56:54.538998 2025-06-08 17:56:54.538998 brute_forcer YOU FAILED -\. - - --- --- Data for Name: sessions; Type: TABLE DATA; Schema: public; Owner: adminuser --- - -COPY public.sessions (id, license_id, session_id, ip_address, user_agent, started_at, last_heartbeat, ended_at, is_active) FROM stdin; -\. - - --- --- Name: audit_log_id_seq; Type: SEQUENCE SET; Schema: public; Owner: adminuser --- - -SELECT pg_catalog.setval('public.audit_log_id_seq', 96, true); - - --- --- Name: backup_history_id_seq; Type: SEQUENCE SET; Schema: public; Owner: adminuser --- - -SELECT pg_catalog.setval('public.backup_history_id_seq', 7, true); - - --- --- Name: customers_id_seq; Type: SEQUENCE SET; Schema: public; Owner: adminuser --- - -SELECT pg_catalog.setval('public.customers_id_seq', 6, true); - - --- --- Name: licenses_id_seq; Type: SEQUENCE SET; Schema: public; Owner: adminuser --- - -SELECT pg_catalog.setval('public.licenses_id_seq', 20, true); - - --- --- Name: sessions_id_seq; Type: SEQUENCE SET; Schema: public; Owner: adminuser --- - -SELECT pg_catalog.setval('public.sessions_id_seq', 1, false); - - --- --- Name: audit_log audit_log_pkey; Type: CONSTRAINT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.audit_log - ADD CONSTRAINT audit_log_pkey PRIMARY KEY (id); - - --- --- Name: backup_history backup_history_pkey; Type: CONSTRAINT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.backup_history - ADD CONSTRAINT backup_history_pkey PRIMARY KEY (id); - - --- --- Name: customers customers_pkey; Type: CONSTRAINT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.customers - ADD CONSTRAINT customers_pkey PRIMARY KEY (id); - - --- --- Name: licenses licenses_license_key_key; Type: CONSTRAINT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.licenses - ADD CONSTRAINT licenses_license_key_key UNIQUE (license_key); - - --- --- Name: licenses licenses_pkey; Type: CONSTRAINT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.licenses - ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); - - --- --- Name: login_attempts login_attempts_pkey; Type: CONSTRAINT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.login_attempts - ADD CONSTRAINT login_attempts_pkey PRIMARY KEY (ip_address); - - --- --- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.sessions - ADD CONSTRAINT sessions_pkey PRIMARY KEY (id); - - --- --- Name: sessions sessions_session_id_key; Type: CONSTRAINT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.sessions - ADD CONSTRAINT sessions_session_id_key UNIQUE (session_id); - - --- --- Name: idx_audit_log_entity; Type: INDEX; Schema: public; Owner: adminuser --- - -CREATE INDEX idx_audit_log_entity ON public.audit_log USING btree (entity_type, entity_id); - - --- --- Name: idx_audit_log_timestamp; Type: INDEX; Schema: public; Owner: adminuser --- - -CREATE INDEX idx_audit_log_timestamp ON public.audit_log USING btree ("timestamp" DESC); - - --- --- Name: idx_audit_log_username; Type: INDEX; Schema: public; Owner: adminuser --- - -CREATE INDEX idx_audit_log_username ON public.audit_log USING btree (username); - - --- --- Name: idx_backup_history_created_at; Type: INDEX; Schema: public; Owner: adminuser --- - -CREATE INDEX idx_backup_history_created_at ON public.backup_history USING btree (created_at DESC); - - --- --- Name: idx_backup_history_status; Type: INDEX; Schema: public; Owner: adminuser --- - -CREATE INDEX idx_backup_history_status ON public.backup_history USING btree (status); - - --- --- Name: idx_login_attempts_blocked_until; Type: INDEX; Schema: public; Owner: adminuser --- - -CREATE INDEX idx_login_attempts_blocked_until ON public.login_attempts USING btree (blocked_until); - - --- --- Name: idx_login_attempts_last_attempt; Type: INDEX; Schema: public; Owner: adminuser --- - -CREATE INDEX idx_login_attempts_last_attempt ON public.login_attempts USING btree (last_attempt DESC); - - --- --- Name: licenses licenses_customer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.licenses - ADD CONSTRAINT licenses_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.customers(id); - - --- --- Name: sessions sessions_license_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: adminuser --- - -ALTER TABLE ONLY public.sessions - ADD CONSTRAINT sessions_license_id_fkey FOREIGN KEY (license_id) REFERENCES public.licenses(id); - - --- --- PostgreSQL database dump complete --- - diff --git a/v2/cookies.txt b/v2/cookies.txt deleted file mode 100644 index 32e94c9..0000000 --- a/v2/cookies.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com FALSE / FALSE 1750193106 admin_session AwO_9xkBcSaqhYwpkjUTL1bNPOMWUZ5qMXGUAwdTpNM diff --git a/v2/docker-compose.yaml b/v2/docker-compose.yaml deleted file mode 100644 index c8ad7b6..0000000 --- a/v2/docker-compose.yaml +++ /dev/null @@ -1,164 +0,0 @@ -services: - postgres: - build: - context: ../v2_postgres - container_name: db - restart: always - env_file: .env - environment: - POSTGRES_HOST: postgres - POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=de_DE.UTF-8' - POSTGRES_COLLATE: 'de_DE.UTF-8' - POSTGRES_CTYPE: 'de_DE.UTF-8' - TZ: Europe/Berlin - PGTZ: Europe/Berlin - volumes: - # Persistente Speicherung der Datenbank auf dem Windows-Host - - postgres_data:/var/lib/postgresql/data - # Init-Skript für Tabellen - - ../v2_adminpanel/init.sql:/docker-entrypoint-initdb.d/init.sql - networks: - - internal_net - deploy: - resources: - limits: - cpus: '2' - memory: 4g - - license-server: - build: - context: ../v2_lizenzserver - container_name: license-server - restart: always - # Port-Mapping entfernt - nur noch über Nginx erreichbar - env_file: .env - environment: - TZ: Europe/Berlin - depends_on: - - postgres - networks: - - internal_net - deploy: - resources: - limits: - cpus: '2' - memory: 4g - - # auth-service: - # build: - # context: ../lizenzserver/services/auth - # container_name: auth-service - # restart: always - # # Port 5001 - nur intern erreichbar - # env_file: .env - # environment: - # TZ: Europe/Berlin - # DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/v2_adminpanel - # REDIS_URL: redis://redis:6379/1 - # JWT_SECRET: ${JWT_SECRET} - # FLASK_ENV: production - # depends_on: - # - postgres - # - redis - # networks: - # - internal_net - # deploy: - # resources: - # limits: - # cpus: '1' - # memory: 1g - - # analytics-service: - # build: - # context: ../lizenzserver/services/analytics - # container_name: analytics-service - # restart: always - # # Port 5003 - nur intern erreichbar - # env_file: .env - # environment: - # TZ: Europe/Berlin - # DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/v2_adminpanel - # REDIS_URL: redis://redis:6379/2 - # JWT_SECRET: ${JWT_SECRET} - # FLASK_ENV: production - # depends_on: - # - postgres - # - redis - # - rabbitmq - # networks: - # - internal_net - # deploy: - # resources: - # limits: - # cpus: '1' - # memory: 2g - - # admin-api-service: - # build: - # context: ../lizenzserver/services/admin_api - # container_name: admin-api-service - # restart: always - # # Port 5004 - nur intern erreichbar - # env_file: .env - # environment: - # TZ: Europe/Berlin - # DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/v2_adminpanel - # REDIS_URL: redis://redis:6379/3 - # JWT_SECRET: ${JWT_SECRET} - # FLASK_ENV: production - # depends_on: - # - postgres - # - redis - # - rabbitmq - # networks: - # - internal_net - # deploy: - # resources: - # limits: - # cpus: '1' - # memory: 2g - - admin-panel: - build: - context: ../v2_adminpanel - container_name: admin-panel - restart: always - # Port-Mapping entfernt - nur über nginx erreichbar - env_file: .env - environment: - TZ: Europe/Berlin - depends_on: - - postgres - networks: - - internal_net - volumes: - # Backup-Verzeichnis - - ../backups:/app/backups - deploy: - resources: - limits: - cpus: '2' - memory: 4g - - nginx: - build: - context: ../v2_nginx - container_name: nginx-proxy - restart: always - ports: - - "80:80" - - "443:443" - environment: - TZ: Europe/Berlin - depends_on: - - admin-panel - - license-server - networks: - - internal_net - -networks: - internal_net: - driver: bridge - -volumes: - postgres_data: diff --git a/v2_adminpanel/Dockerfile b/v2_adminpanel/Dockerfile deleted file mode 100644 index cee53bf..0000000 --- a/v2_adminpanel/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -FROM python:3.11-slim - -# Locale für deutsche Sprache und UTF-8 setzen -ENV LANG=de_DE.UTF-8 -ENV LC_ALL=de_DE.UTF-8 -ENV PYTHONIOENCODING=utf-8 - -# Zeitzone auf Europe/Berlin setzen -ENV TZ=Europe/Berlin - -WORKDIR /app - -# System-Dependencies inkl. PostgreSQL-Tools installieren -RUN apt-get update && apt-get install -y \ - locales \ - postgresql-client \ - tzdata \ - && sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \ - && locale-gen \ - && update-locale LANG=de_DE.UTF-8 \ - && ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \ - && echo "Europe/Berlin" > /etc/timezone \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 5000 - -CMD ["python", "app.py"] diff --git a/v2_adminpanel/ERROR_HANDLING_GUIDE.md b/v2_adminpanel/ERROR_HANDLING_GUIDE.md deleted file mode 100644 index a93a05d..0000000 --- a/v2_adminpanel/ERROR_HANDLING_GUIDE.md +++ /dev/null @@ -1,456 +0,0 @@ -# Error Handling Guide - -## Overview - -This guide describes the error handling system implemented in the v2_adminpanel application. The system provides: - -- Centralized error handling with custom exception hierarchy -- Input validation framework -- Structured logging -- Monitoring and alerting -- Consistent error responses - -## Architecture - -### 1. Custom Exception Hierarchy - -``` -BaseApplicationException -├── ValidationException -│ ├── InputValidationError -│ ├── BusinessRuleViolation -│ └── DataIntegrityError -├── AuthenticationException -│ ├── InvalidCredentialsError -│ ├── SessionExpiredError -│ └── InsufficientPermissionsError -├── DatabaseException -│ ├── ConnectionError -│ ├── QueryError -│ └── TransactionError -├── ExternalServiceException -│ ├── APIError -│ └── TimeoutError -└── ResourceException - ├── ResourceNotFoundError - ├── ResourceConflictError - └── ResourceLimitExceeded -``` - -### 2. Core Components - -- **core/exceptions.py**: Custom exception classes -- **core/error_handlers.py**: Global error handlers and decorators -- **core/validators.py**: Input validation framework -- **core/logging_config.py**: Structured logging setup -- **core/monitoring.py**: Error metrics and alerting -- **middleware/error_middleware.py**: Request-level error handling - -## Usage Examples - -### 1. Raising Custom Exceptions - -```python -from core.exceptions import ( - InputValidationError, - ResourceNotFoundError, - BusinessRuleViolation -) - -# Validation error -if not email_is_valid: - raise InputValidationError( - field='email', - message='Invalid email format', - value=email_value, - expected_type='email' - ) - -# Resource not found -user = db.get_user(user_id) -if not user: - raise ResourceNotFoundError( - resource_type='User', - resource_id=user_id - ) - -# Business rule violation -if active_licenses >= license_limit: - raise BusinessRuleViolation( - rule='license_limit', - message='License limit exceeded', - context={ - 'current': active_licenses, - 'limit': license_limit - } - ) -``` - -### 2. Using Error Decorators - -```python -from core.error_handlers import handle_errors, validate_request -from core.validators import validate - -@handle_errors( - catch=(psycopg2.Error,), - message='Database operation failed', - user_message='Datenbankfehler aufgetreten', - redirect_to='admin.dashboard' -) -def update_customer(customer_id): - # Database operations - pass - -@validate_request( - required_fields={ - 'email': str, - 'age': int, - 'active': bool - } -) -def create_user(): - # Request data is validated - pass - -@validate({ - 'email': { - 'type': 'email', - 'required': True - }, - 'password': { - 'type': 'password', - 'required': True - }, - 'age': { - 'type': 'integer', - 'required': True, - 'min_value': 18, - 'max_value': 120 - } -}) -def register_user(): - # Access validated data - data = request.validated_data - # Use data safely -``` - -### 3. Input Validation - -```python -from core.validators import Validators - -# Email validation -email = Validators.email(user_input, field_name='email') - -# Phone validation -phone = Validators.phone(user_input, field_name='phone') - -# License key validation -license_key = Validators.license_key(user_input) - -# Integer with constraints -age = Validators.integer( - user_input, - field_name='age', - min_value=0, - max_value=150 -) - -# String with constraints -username = Validators.string( - user_input, - field_name='username', - min_length=3, - max_length=50, - safe_only=True -) - -# Password validation -password = Validators.password(user_input) - -# Custom enum validation -status = Validators.enum( - user_input, - field_name='status', - allowed_values=['active', 'inactive', 'pending'] -) -``` - -### 4. Error Context Manager - -```python -from core.error_handlers import ErrorContext - -with ErrorContext( - operation='create_license', - resource_type='License', - resource_id=license_key -): - # Operations that might fail - db.insert_license(license_data) - # Errors are automatically logged with context -``` - -### 5. Logging - -```python -from core.logging_config import get_logger, log_error, log_security_event - -logger = get_logger(__name__) - -# Standard logging -logger.info('User created', extra={ - 'user_id': user.id, - 'email': user.email -}) - -# Error logging -try: - risky_operation() -except Exception as e: - log_error( - logger, - 'Risky operation failed', - error=e, - user_id=user.id, - operation='risky_operation' - ) - -# Security event logging -log_security_event( - 'INVALID_LOGIN_ATTEMPT', - 'Multiple failed login attempts', - username=username, - ip_address=request.remote_addr, - attempt_count=5 -) -``` - -## Error Response Format - -### JSON Responses (API) - -```json -{ - "error": { - "code": "VALIDATION_ERROR", - "message": "Invalid input provided", - "timestamp": "2024-01-15T10:30:00Z", - "request_id": "550e8400-e29b-41d4-a716-446655440000", - "details": { - "field": "email", - "value": "invalid-email", - "expected_type": "email" - } - } -} -``` - -### HTML Responses - -- User-friendly error messages -- Error code and request ID for support -- Helpful suggestions for resolution -- Navigation options (back, dashboard, retry) - -## Monitoring and Alerts - -### Metrics Exposed - -- `app_errors_total`: Total error count by code, status, endpoint -- `app_error_rate`: Errors per minute by error code -- `app_validation_errors_total`: Validation errors by field -- `app_auth_failures_total`: Authentication failures -- `app_database_errors_total`: Database errors -- `app_request_duration_seconds`: Request duration histogram - -### Alert Thresholds - -- Error rate > 10/min: Critical alert -- Auth failure rate > 5/min: Security alert -- DB error rate > 3/min: Infrastructure alert -- Response time 95th percentile > 2s: Performance alert - -### Accessing Metrics - -- Prometheus metrics: `/metrics` -- Active alerts: `/api/alerts` - -## Best Practices - -### 1. Always Use Specific Exceptions - -```python -# Bad -raise Exception("User not found") - -# Good -raise ResourceNotFoundError('User', user_id) -``` - -### 2. Provide Context - -```python -# Bad -raise ValidationException("Invalid data") - -# Good -raise InputValidationError( - field='email', - message='Email domain not allowed', - value=email, - expected_type='corporate_email' -) -``` - -### 3. Handle Database Errors - -```python -# Bad -result = db.execute(query) - -# Good -try: - result = db.execute(query) -except psycopg2.IntegrityError as e: - if e.pgcode == '23505': - raise DataIntegrityError( - entity='User', - constraint='unique_email', - message='Email already exists' - ) - raise -``` - -### 4. Validate Early - -```python -# Bad -def process_order(data): - # Process without validation - total = data['quantity'] * data['price'] - -# Good -@validate({ - 'quantity': {'type': 'integer', 'min_value': 1}, - 'price': {'type': 'float', 'min_value': 0} -}) -def process_order(): - data = request.validated_data - total = data['quantity'] * data['price'] -``` - -### 5. Log Security Events - -```python -# Failed login attempts -log_security_event( - 'LOGIN_FAILURE', - f'Failed login for user {username}', - username=username, - ip_address=request.remote_addr -) - -# Suspicious activity -log_security_event( - 'SUSPICIOUS_ACTIVITY', - 'Rapid API requests detected', - ip_address=request.remote_addr, - request_count=count, - time_window=60 -) -``` - -## Migration Guide - -### Converting Existing Error Handling - -1. **Replace generic exceptions**: -```python -# Old -except Exception as e: - flash(f"Error: {str(e)}", "error") - return redirect(url_for('admin.dashboard')) - -# New -except DatabaseException as e: - # Already handled by global handler - raise -``` - -2. **Update validation**: -```python -# Old -email = request.form.get('email') -if not email or '@' not in email: - flash("Invalid email", "error") - return redirect(request.url) - -# New -from core.validators import Validators -try: - email = Validators.email(request.form.get('email')) -except InputValidationError as e: - # Handled automatically - raise -``` - -3. **Use decorators**: -```python -# Old -@app.route('/api/user', methods=['POST']) -def create_user(): - try: - # validation code - # database operations - except Exception as e: - return jsonify({'error': str(e)}), 500 - -# New -@app.route('/api/user', methods=['POST']) -@validate({ - 'email': {'type': 'email', 'required': True}, - 'name': {'type': 'string', 'required': True, 'min_length': 2} -}) -def create_user(): - data = request.validated_data - # Use validated data directly -``` - -## Testing - -Run the test suite: - -```bash -pytest v2_adminpanel/tests/test_error_handling.py -v -``` - -Test coverage includes: -- Exception creation and properties -- Error handler responses (JSON/HTML) -- Validation functions -- Decorators -- Monitoring and metrics -- Alert generation - -## Troubleshooting - -### Common Issues - -1. **Import errors**: Ensure you import from `core` package -2. **Validation not working**: Check decorator order (validate must be closest to function) -3. **Logs not appearing**: Verify LOG_LEVEL environment variable -4. **Metrics missing**: Ensure prometheus_client is installed - -### Debug Mode - -In development, set: -```python -app.config['DEBUG'] = True -``` - -This will: -- Include detailed error information -- Show stack traces -- Log to console with readable format \ No newline at end of file diff --git a/v2_adminpanel/__pycache__/app.cpython-312.pyc b/v2_adminpanel/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 37a2c18..0000000 Binary files a/v2_adminpanel/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/v2_adminpanel/app.py b/v2_adminpanel/app.py deleted file mode 100644 index e84b8d0..0000000 --- a/v2_adminpanel/app.py +++ /dev/null @@ -1,168 +0,0 @@ -import os -import sys -import logging -from datetime import datetime - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, session -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -from prometheus_flask_exporter import PrometheusMetrics - -# Import our configuration and utilities -import config -from utils.backup import create_backup - -# Import error handling system -from core.error_handlers import init_error_handlers -from core.logging_config import setup_logging -from core.monitoring import init_monitoring -from middleware.error_middleware import ErrorHandlingMiddleware - -app = Flask(__name__) - -# Initialize Prometheus metrics -metrics = PrometheusMetrics(app) -metrics.info('admin_panel_info', 'Admin Panel Information', version='1.0.0') -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Initialize error handling system -setup_logging(app) -init_error_handlers(app) -init_monitoring(app) -ErrorHandlingMiddleware(app) - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Initialize scheduler from scheduler module -from scheduler import init_scheduler -scheduler = init_scheduler() - -# Import and register blueprints -try: - from routes.auth_routes import auth_bp - from routes.admin_routes import admin_bp - from routes.api_routes import api_bp - from routes.batch_routes import batch_bp - from routes.customer_routes import customer_bp - from routes.export_routes import export_bp - from routes.license_routes import license_bp - from routes.resource_routes import resource_bp - from routes.session_routes import session_bp - from routes.monitoring_routes import monitoring_bp - from leads import leads_bp - print("All blueprints imported successfully!") -except Exception as e: - print(f"Blueprint import error: {str(e)}") - import traceback - traceback.print_exc() - -# Register all blueprints -app.register_blueprint(auth_bp) -app.register_blueprint(admin_bp) -app.register_blueprint(api_bp) -app.register_blueprint(batch_bp) -app.register_blueprint(customer_bp) -app.register_blueprint(export_bp) -app.register_blueprint(license_bp) -app.register_blueprint(resource_bp) -app.register_blueprint(session_bp) -app.register_blueprint(monitoring_bp) -app.register_blueprint(leads_bp, url_prefix='/leads') - -# Template filters -@app.template_filter('nl2br') -def nl2br_filter(s): - """Convert newlines to
tags""" - return s.replace('\n', '
\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 "
".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)}
{traceback.format_exc()}
" - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) \ No newline at end of file diff --git a/v2_adminpanel/apply_lead_migration.py b/v2_adminpanel/apply_lead_migration.py deleted file mode 100644 index 3030a05..0000000 --- a/v2_adminpanel/apply_lead_migration.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -""" -Apply Lead Management Tables Migration -""" -import psycopg2 -import os -from db import get_db_connection - -def apply_migration(): - """Apply the lead tables migration""" - try: - # Read migration SQL - migration_file = os.path.join(os.path.dirname(__file__), - 'migrations', 'create_lead_tables.sql') - - with open(migration_file, 'r') as f: - migration_sql = f.read() - - # Connect and execute - with get_db_connection() as conn: - cur = conn.cursor() - - print("Applying lead management tables migration...") - cur.execute(migration_sql) - - # Verify tables were created - cur.execute(""" - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name LIKE 'lead_%' - ORDER BY table_name - """) - - tables = cur.fetchall() - print(f"\nCreated {len(tables)} tables:") - for table in tables: - print(f" - {table[0]}") - - cur.close() - - print("\n✅ Migration completed successfully!") - - except FileNotFoundError: - print(f"❌ Migration file not found: {migration_file}") - except psycopg2.Error as e: - print(f"❌ Database error: {e}") - except Exception as e: - print(f"❌ Unexpected error: {e}") - -if __name__ == "__main__": - apply_migration() \ No newline at end of file diff --git a/v2_adminpanel/apply_license_heartbeats_migration.py b/v2_adminpanel/apply_license_heartbeats_migration.py deleted file mode 100644 index 843627d..0000000 --- a/v2_adminpanel/apply_license_heartbeats_migration.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -""" -Apply the license_heartbeats table migration -""" - -import os -import psycopg2 -import logging -from datetime import datetime - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -def get_db_connection(): - """Get database connection""" - return psycopg2.connect( - host=os.environ.get('POSTGRES_HOST', 'postgres'), - database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'), - user=os.environ.get('POSTGRES_USER', 'postgres'), - password=os.environ.get('POSTGRES_PASSWORD', 'postgres') - ) - -def apply_migration(): - """Apply the license_heartbeats migration""" - conn = None - try: - logger.info("Connecting to database...") - conn = get_db_connection() - cur = conn.cursor() - - # Read migration file - migration_file = os.path.join(os.path.dirname(__file__), 'migrations', 'create_license_heartbeats_table.sql') - logger.info(f"Reading migration file: {migration_file}") - - with open(migration_file, 'r') as f: - migration_sql = f.read() - - # Execute migration - logger.info("Executing migration...") - cur.execute(migration_sql) - - # Verify table was created - cur.execute(""" - SELECT EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_name = 'license_heartbeats' - ) - """) - - if cur.fetchone()[0]: - logger.info("✓ license_heartbeats table created successfully!") - - # Check partitions - cur.execute(""" - SELECT tablename - FROM pg_tables - WHERE tablename LIKE 'license_heartbeats_%' - ORDER BY tablename - """) - - partitions = cur.fetchall() - logger.info(f"✓ Created {len(partitions)} partitions:") - for partition in partitions: - logger.info(f" - {partition[0]}") - else: - logger.error("✗ Failed to create license_heartbeats table") - return False - - conn.commit() - logger.info("✓ Migration completed successfully!") - return True - - except Exception as e: - logger.error(f"✗ Migration failed: {str(e)}") - if conn: - conn.rollback() - return False - finally: - if conn: - cur.close() - conn.close() - -if __name__ == "__main__": - logger.info("=== Applying license_heartbeats migration ===") - logger.info(f"Timestamp: {datetime.now()}") - - if apply_migration(): - logger.info("=== Migration successful! ===") - else: - logger.error("=== Migration failed! ===") - exit(1) \ No newline at end of file diff --git a/v2_adminpanel/apply_partition_migration.py b/v2_adminpanel/apply_partition_migration.py deleted file mode 100644 index c4f1c5d..0000000 --- a/v2_adminpanel/apply_partition_migration.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -""" -Apply partition migration for license_heartbeats table. -This script creates missing partitions for the current and future months. -""" - -import psycopg2 -import os -import sys -from datetime import datetime, timedelta -from dateutil.relativedelta import relativedelta - -def get_db_connection(): - """Get database connection""" - return psycopg2.connect( - host=os.environ.get('POSTGRES_HOST', 'postgres'), - database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'), - user=os.environ.get('POSTGRES_USER', 'postgres'), - password=os.environ.get('POSTGRES_PASSWORD', 'postgres') - ) - -def create_partition(cursor, year, month): - """Create a partition for the given year and month""" - partition_name = f"license_heartbeats_{year}_{month:02d}" - start_date = f"{year}-{month:02d}-01" - - # Calculate end date (first day of next month) - if month == 12: - end_date = f"{year + 1}-01-01" - else: - end_date = f"{year}-{month + 1:02d}-01" - - # Check if partition already exists - cursor.execute(""" - SELECT EXISTS ( - SELECT 1 - FROM pg_tables - WHERE tablename = %s - ) - """, (partition_name,)) - - exists = cursor.fetchone()[0] - - if not exists: - try: - cursor.execute(f""" - CREATE TABLE {partition_name} PARTITION OF license_heartbeats - FOR VALUES FROM ('{start_date}') TO ('{end_date}') - """) - print(f"✓ Created partition {partition_name}") - return True - except Exception as e: - print(f"✗ Error creating partition {partition_name}: {e}") - return False - else: - print(f"- Partition {partition_name} already exists") - return False - -def main(): - """Main function""" - print("Applying license_heartbeats partition migration...") - print("-" * 50) - - try: - # Connect to database - conn = get_db_connection() - cursor = conn.cursor() - - # Check if license_heartbeats table exists - cursor.execute(""" - SELECT EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_name = 'license_heartbeats' - ) - """) - - if not cursor.fetchone()[0]: - print("✗ Error: license_heartbeats table does not exist!") - print(" Please run the init.sql script first.") - return 1 - - # Get current date - current_date = datetime.now() - partitions_created = 0 - - # Create partitions for the next 6 months (including current month) - for i in range(7): - target_date = current_date + relativedelta(months=i) - if create_partition(cursor, target_date.year, target_date.month): - partitions_created += 1 - - # Commit changes - conn.commit() - - print("-" * 50) - print(f"✓ Migration complete. Created {partitions_created} new partitions.") - - # List all partitions - cursor.execute(""" - SELECT tablename - FROM pg_tables - WHERE tablename LIKE 'license_heartbeats_%' - ORDER BY tablename - """) - - partitions = cursor.fetchall() - print(f"\nTotal partitions: {len(partitions)}") - for partition in partitions: - print(f" - {partition[0]}") - - cursor.close() - conn.close() - - return 0 - - except Exception as e: - print(f"✗ Error: {e}") - return 1 - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file diff --git a/v2_adminpanel/auth/__init__.py b/v2_adminpanel/auth/__init__.py deleted file mode 100644 index 8ca1225..0000000 --- a/v2_adminpanel/auth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Auth module initialization \ No newline at end of file diff --git a/v2_adminpanel/auth/decorators.py b/v2_adminpanel/auth/decorators.py deleted file mode 100644 index 9e0b004..0000000 --- a/v2_adminpanel/auth/decorators.py +++ /dev/null @@ -1,44 +0,0 @@ -from functools import wraps -from flask import session, redirect, url_for, flash, request -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -import logging -from utils.audit import log_audit - -logger = logging.getLogger(__name__) - - -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'logged_in' not in session: - return redirect(url_for('auth.login')) - - # Check if session has expired - if 'last_activity' in session: - last_activity = datetime.fromisoformat(session['last_activity']) - time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity - - # Debug logging - logger.info(f"Session check for {session.get('username', 'unknown')}: " - f"Last activity: {last_activity}, " - f"Time since: {time_since_activity.total_seconds()} seconds") - - if time_since_activity > timedelta(minutes=5): - # Session expired - Logout - username = session.get('username', 'unbekannt') - logger.info(f"Session timeout for user {username} - auto logout") - # Audit log for automatic logout (before session.clear()!) - try: - log_audit('AUTO_LOGOUT', 'session', - additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) - except: - pass - session.clear() - flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') - return redirect(url_for('auth.login')) - - # Activity is NOT automatically updated - # Only on explicit user actions (done by heartbeat) - return f(*args, **kwargs) - return decorated_function \ No newline at end of file diff --git a/v2_adminpanel/auth/password.py b/v2_adminpanel/auth/password.py deleted file mode 100644 index 785466f..0000000 --- a/v2_adminpanel/auth/password.py +++ /dev/null @@ -1,11 +0,0 @@ -import bcrypt - - -def hash_password(password): - """Hash a password using bcrypt""" - return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - - -def verify_password(password, hashed): - """Verify a password against its hash""" - return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) \ No newline at end of file diff --git a/v2_adminpanel/auth/rate_limiting.py b/v2_adminpanel/auth/rate_limiting.py deleted file mode 100644 index 8aca82b..0000000 --- a/v2_adminpanel/auth/rate_limiting.py +++ /dev/null @@ -1,124 +0,0 @@ -import random -import logging -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from flask import request -from db import execute_query, get_db_connection, get_db_cursor -from config import FAIL_MESSAGES, MAX_LOGIN_ATTEMPTS, BLOCK_DURATION_HOURS, EMAIL_ENABLED -from utils.audit import log_audit -from utils.network import get_client_ip - -logger = logging.getLogger(__name__) - - -def check_ip_blocked(ip_address): - """Check if an IP address is blocked""" - result = execute_query( - """ - SELECT blocked_until FROM login_attempts - WHERE ip_address = %s AND blocked_until IS NOT NULL - """, - (ip_address,), - fetch_one=True - ) - - if result and result[0]: - if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): - return True, result[0] - return False, None - - -def record_failed_attempt(ip_address, username): - """Record a failed login attempt""" - # Random error message - error_message = random.choice(FAIL_MESSAGES) - - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - try: - # Check if IP already exists - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - - if result: - # Update existing entry - new_count = result[0] + 1 - blocked_until = None - - if new_count >= MAX_LOGIN_ATTEMPTS: - blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) - # Email notification (if enabled) - if EMAIL_ENABLED: - send_security_alert_email(ip_address, username, new_count) - - cur.execute(""" - UPDATE login_attempts - SET attempt_count = %s, - last_attempt = CURRENT_TIMESTAMP, - blocked_until = %s, - last_username_tried = %s, - last_error_message = %s - WHERE ip_address = %s - """, (new_count, blocked_until, username, error_message, ip_address)) - else: - # Create new entry - cur.execute(""" - INSERT INTO login_attempts - (ip_address, attempt_count, last_username_tried, last_error_message) - VALUES (%s, 1, %s, %s) - """, (ip_address, username, error_message)) - - conn.commit() - - # Audit log - log_audit('LOGIN_FAILED', 'user', - additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") - - except Exception as e: - logger.error(f"Rate limiting error: {e}") - conn.rollback() - - return error_message - - -def reset_login_attempts(ip_address): - """Reset login attempts for an IP""" - execute_query( - "DELETE FROM login_attempts WHERE ip_address = %s", - (ip_address,) - ) - - -def get_login_attempts(ip_address): - """Get the number of login attempts for an IP""" - result = execute_query( - "SELECT attempt_count FROM login_attempts WHERE ip_address = %s", - (ip_address,), - fetch_one=True - ) - return result[0] if result else 0 - - -def send_security_alert_email(ip_address, username, attempt_count): - """Send a security alert email""" - subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" - body = f""" - WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! - - IP-Adresse: {ip_address} - Versuchter Benutzername: {username} - Anzahl Versuche: {attempt_count} - Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} - - Die IP-Adresse wurde für 24 Stunden gesperrt. - - Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. - """ - - # TODO: Email sending implementation when SMTP is configured - logger.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") - print(f"E-Mail würde gesendet: {subject}") \ No newline at end of file diff --git a/v2_adminpanel/auth/two_factor.py b/v2_adminpanel/auth/two_factor.py deleted file mode 100644 index 474555d..0000000 --- a/v2_adminpanel/auth/two_factor.py +++ /dev/null @@ -1,57 +0,0 @@ -import pyotp -import qrcode -import random -import string -import hashlib -from io import BytesIO -import base64 - - -def generate_totp_secret(): - """Generate a new TOTP secret""" - return pyotp.random_base32() - - -def generate_qr_code(username, totp_secret): - """Generate QR code for TOTP setup""" - totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( - name=username, - issuer_name='V2 Admin Panel' - ) - - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(totp_uri) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - buf = BytesIO() - img.save(buf, format='PNG') - buf.seek(0) - - return base64.b64encode(buf.getvalue()).decode() - - -def verify_totp(totp_secret, token): - """Verify a TOTP token""" - totp = pyotp.TOTP(totp_secret) - return totp.verify(token, valid_window=1) - - -def generate_backup_codes(count=8): - """Generate backup codes for 2FA recovery""" - codes = [] - for _ in range(count): - code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) - codes.append(code) - return codes - - -def hash_backup_code(code): - """Hash a backup code for storage""" - return hashlib.sha256(code.encode()).hexdigest() - - -def verify_backup_code(code, hashed_codes): - """Verify a backup code against stored hashes""" - code_hash = hashlib.sha256(code.encode()).hexdigest() - return code_hash in hashed_codes \ No newline at end of file diff --git a/v2_adminpanel/config.py b/v2_adminpanel/config.py deleted file mode 100644 index de4c1df..0000000 --- a/v2_adminpanel/config.py +++ /dev/null @@ -1,70 +0,0 @@ -import os -from datetime import timedelta -from pathlib import Path -from dotenv import load_dotenv - -load_dotenv() - -# Flask Configuration -SECRET_KEY = os.urandom(24) -SESSION_TYPE = 'filesystem' -JSON_AS_ASCII = False -JSONIFY_MIMETYPE = 'application/json; charset=utf-8' -PERMANENT_SESSION_LIFETIME = timedelta(minutes=5) -SESSION_COOKIE_HTTPONLY = True -SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "true").lower() == "true" # Default True for HTTPS -SESSION_COOKIE_SAMESITE = 'Lax' -SESSION_COOKIE_NAME = 'admin_session' -SESSION_REFRESH_EACH_REQUEST = False - -# Database Configuration -DATABASE_CONFIG = { - 'host': os.getenv("POSTGRES_HOST", "postgres"), - 'port': os.getenv("POSTGRES_PORT", "5432"), - 'dbname': os.getenv("POSTGRES_DB"), - 'user': os.getenv("POSTGRES_USER"), - 'password': os.getenv("POSTGRES_PASSWORD"), - 'options': '-c client_encoding=UTF8' -} - -# Backup Configuration -BACKUP_DIR = Path("/app/backups") -BACKUP_DIR.mkdir(exist_ok=True) -BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY") - -# Rate Limiting Configuration -FAIL_MESSAGES = [ - "NOPE!", - "ACCESS DENIED, TRY HARDER", - "WRONG! 🚫", - "COMPUTER SAYS NO", - "YOU FAILED" -] -MAX_LOGIN_ATTEMPTS = 5 -BLOCK_DURATION_HOURS = 24 -CAPTCHA_AFTER_ATTEMPTS = 2 - -# reCAPTCHA Configuration -RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY') -RECAPTCHA_SECRET_KEY = os.getenv('RECAPTCHA_SECRET_KEY') - -# Email Configuration -EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "false").lower() == "true" - -# Admin Users (for backward compatibility) -ADMIN_USERS = { - os.getenv("ADMIN1_USERNAME"): os.getenv("ADMIN1_PASSWORD"), - os.getenv("ADMIN2_USERNAME"): os.getenv("ADMIN2_PASSWORD") -} - -# Scheduler Configuration -SCHEDULER_CONFIG = { - 'backup_hour': 3, - 'backup_minute': 0 -} - -# Logging Configuration -LOGGING_CONFIG = { - 'level': 'INFO', - 'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s' -} \ No newline at end of file diff --git a/v2_adminpanel/core/__init__.py b/v2_adminpanel/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/v2_adminpanel/core/error_handlers.py b/v2_adminpanel/core/error_handlers.py deleted file mode 100644 index ec3708a..0000000 --- a/v2_adminpanel/core/error_handlers.py +++ /dev/null @@ -1,273 +0,0 @@ -import logging -import traceback -from functools import wraps -from typing import Optional, Dict, Any, Callable, Union -from flask import ( - Flask, request, jsonify, render_template, flash, redirect, - url_for, current_app, g -) -from werkzeug.exceptions import HTTPException -import psycopg2 - -from .exceptions import ( - BaseApplicationException, DatabaseException, ValidationException, - AuthenticationException, ResourceException, QueryError, - ConnectionError, TransactionError -) - - -logger = logging.getLogger(__name__) - - -def init_error_handlers(app: Flask) -> None: - @app.before_request - def before_request(): - g.request_id = request.headers.get('X-Request-ID', - BaseApplicationException('', '', 0).request_id) - - @app.errorhandler(BaseApplicationException) - def handle_application_error(error: BaseApplicationException): - return _handle_error(error) - - @app.errorhandler(HTTPException) - def handle_http_error(error: HTTPException): - return _handle_error(error) - - @app.errorhandler(psycopg2.Error) - def handle_database_error(error: psycopg2.Error): - db_exception = _convert_psycopg2_error(error) - return _handle_error(db_exception) - - @app.errorhandler(Exception) - def handle_unexpected_error(error: Exception): - logger.error( - f"Unexpected error: {str(error)}", - exc_info=True, - extra={'request_id': getattr(g, 'request_id', 'unknown')} - ) - - if current_app.debug: - raise - - generic_error = BaseApplicationException( - message="An unexpected error occurred", - code="INTERNAL_ERROR", - status_code=500, - user_message="Ein unerwarteter Fehler ist aufgetreten" - ) - return _handle_error(generic_error) - - -def _handle_error(error: Union[BaseApplicationException, HTTPException, Exception]) -> tuple: - if isinstance(error, HTTPException): - status_code = error.code - error_dict = { - 'error': { - 'code': error.name.upper().replace(' ', '_'), - 'message': error.description or str(error), - 'request_id': getattr(g, 'request_id', 'unknown') - } - } - user_message = error.description or str(error) - elif isinstance(error, BaseApplicationException): - status_code = error.status_code - error_dict = error.to_dict(include_details=current_app.debug) - error_dict['error']['request_id'] = getattr(g, 'request_id', error.request_id) - user_message = error.user_message - - logger.error( - f"{error.__class__.__name__}: {error.message}", - extra={ - 'error_code': error.code, - 'details': error.details, - 'request_id': error_dict['error']['request_id'] - } - ) - else: - status_code = 500 - error_dict = { - 'error': { - 'code': 'INTERNAL_ERROR', - 'message': 'An internal error occurred', - 'request_id': getattr(g, 'request_id', 'unknown') - } - } - user_message = "Ein interner Fehler ist aufgetreten" - - if _is_json_request(): - return jsonify(error_dict), status_code - else: - if status_code == 404: - return render_template('404.html'), 404 - elif status_code >= 500: - return render_template('500.html', error=user_message), status_code - else: - flash(user_message, 'error') - return render_template('error.html', - error=user_message, - error_code=error_dict['error']['code'], - request_id=error_dict['error']['request_id']), status_code - - -def _convert_psycopg2_error(error: psycopg2.Error) -> DatabaseException: - error_code = getattr(error, 'pgcode', None) - error_message = str(error).split('\n')[0] - - if isinstance(error, psycopg2.OperationalError): - return ConnectionError( - message=f"Database connection failed: {error_message}", - host=None - ) - elif isinstance(error, psycopg2.IntegrityError): - if error_code == '23505': - return ValidationException( - message="Duplicate entry violation", - details={'constraint': error_message}, - user_message="Dieser Eintrag existiert bereits" - ) - elif error_code == '23503': - return ValidationException( - message="Foreign key violation", - details={'constraint': error_message}, - user_message="Referenzierte Daten existieren nicht" - ) - else: - return ValidationException( - message="Data integrity violation", - details={'error_code': error_code}, - user_message="Datenintegritätsfehler" - ) - elif isinstance(error, psycopg2.DataError): - return ValidationException( - message="Invalid data format", - details={'error': error_message}, - user_message="Ungültiges Datenformat" - ) - else: - return QueryError( - message=error_message, - query="[query hidden for security]", - error_code=error_code - ) - - -def _is_json_request() -> bool: - return (request.is_json or - request.path.startswith('/api/') or - request.accept_mimetypes.best == 'application/json') - - -def handle_errors( - catch: tuple = (Exception,), - message: str = "Operation failed", - user_message: Optional[str] = None, - redirect_to: Optional[str] = None -) -> Callable: - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except catch as e: - if isinstance(e, BaseApplicationException): - raise - - logger.error( - f"Error in {func.__name__}: {str(e)}", - exc_info=True, - extra={'request_id': getattr(g, 'request_id', 'unknown')} - ) - - if _is_json_request(): - return jsonify({ - 'error': { - 'code': 'OPERATION_FAILED', - 'message': user_message or message, - 'request_id': getattr(g, 'request_id', 'unknown') - } - }), 500 - else: - flash(user_message or message, 'error') - if redirect_to: - return redirect(url_for(redirect_to)) - return redirect(request.referrer or url_for('admin.dashboard')) - - return wrapper - return decorator - - -def validate_request( - required_fields: Optional[Dict[str, type]] = None, - optional_fields: Optional[Dict[str, type]] = None -) -> Callable: - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(*args, **kwargs): - data = request.get_json() if request.is_json else request.form - - if required_fields: - for field, expected_type in required_fields.items(): - if field not in data: - raise ValidationException( - message=f"Missing required field: {field}", - field=field, - user_message=f"Pflichtfeld fehlt: {field}" - ) - - try: - if expected_type != str: - if expected_type == int: - int(data[field]) - elif expected_type == float: - float(data[field]) - elif expected_type == bool: - if isinstance(data[field], str): - if data[field].lower() not in ['true', 'false', '1', '0']: - raise ValueError - except (ValueError, TypeError): - raise ValidationException( - message=f"Invalid type for field {field}", - field=field, - value=data[field], - details={'expected_type': expected_type.__name__}, - user_message=f"Ungültiger Typ für Feld {field}" - ) - - return func(*args, **kwargs) - return wrapper - return decorator - - -class ErrorContext: - def __init__( - self, - operation: str, - resource_type: Optional[str] = None, - resource_id: Optional[Any] = None - ): - self.operation = operation - self.resource_type = resource_type - self.resource_id = resource_id - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if exc_val is None: - return False - - if isinstance(exc_val, BaseApplicationException): - return False - - logger.error( - f"Error during {self.operation}", - exc_info=True, - extra={ - 'operation': self.operation, - 'resource_type': self.resource_type, - 'resource_id': self.resource_id, - 'request_id': getattr(g, 'request_id', 'unknown') - } - ) - - return False \ No newline at end of file diff --git a/v2_adminpanel/core/exceptions.py b/v2_adminpanel/core/exceptions.py deleted file mode 100644 index 275818c..0000000 --- a/v2_adminpanel/core/exceptions.py +++ /dev/null @@ -1,356 +0,0 @@ -import uuid -from typing import Optional, Dict, Any -from datetime import datetime - - -class BaseApplicationException(Exception): - def __init__( - self, - message: str, - code: str, - status_code: int = 500, - details: Optional[Dict[str, Any]] = None, - user_message: Optional[str] = None - ): - super().__init__(message) - self.message = message - self.code = code - self.status_code = status_code - self.details = details or {} - self.user_message = user_message or message - self.timestamp = datetime.utcnow() - self.request_id = str(uuid.uuid4()) - - def to_dict(self, include_details: bool = False) -> Dict[str, Any]: - result = { - 'error': { - 'code': self.code, - 'message': self.user_message, - 'timestamp': self.timestamp.isoformat(), - 'request_id': self.request_id - } - } - - if include_details and self.details: - result['error']['details'] = self.details - - return result - - -class ValidationException(BaseApplicationException): - def __init__( - self, - message: str, - field: Optional[str] = None, - value: Any = None, - details: Optional[Dict[str, Any]] = None, - user_message: Optional[str] = None - ): - details = details or {} - if field: - details['field'] = field - if value is not None: - details['value'] = str(value) - - super().__init__( - message=message, - code='VALIDATION_ERROR', - status_code=400, - details=details, - user_message=user_message or "Ungültige Eingabe" - ) - - -class InputValidationError(ValidationException): - def __init__( - self, - field: str, - message: str, - value: Any = None, - expected_type: Optional[str] = None - ): - details = {'expected_type': expected_type} if expected_type else None - super().__init__( - message=f"Invalid input for field '{field}': {message}", - field=field, - value=value, - details=details, - user_message=f"Ungültiger Wert für Feld '{field}'" - ) - - -class BusinessRuleViolation(ValidationException): - def __init__( - self, - rule: str, - message: str, - context: Optional[Dict[str, Any]] = None - ): - super().__init__( - message=message, - details={'rule': rule, 'context': context or {}}, - user_message="Geschäftsregel verletzt" - ) - - -class DataIntegrityError(ValidationException): - def __init__( - self, - entity: str, - constraint: str, - message: str, - details: Optional[Dict[str, Any]] = None - ): - details = details or {} - details.update({'entity': entity, 'constraint': constraint}) - super().__init__( - message=message, - details=details, - user_message="Datenintegritätsfehler" - ) - - -class AuthenticationException(BaseApplicationException): - def __init__( - self, - message: str, - details: Optional[Dict[str, Any]] = None, - user_message: Optional[str] = None - ): - super().__init__( - message=message, - code='AUTHENTICATION_ERROR', - status_code=401, - details=details, - user_message=user_message or "Authentifizierung fehlgeschlagen" - ) - - -class InvalidCredentialsError(AuthenticationException): - def __init__(self, username: Optional[str] = None): - details = {'username': username} if username else None - super().__init__( - message="Invalid username or password", - details=details, - user_message="Ungültiger Benutzername oder Passwort" - ) - - -class SessionExpiredError(AuthenticationException): - def __init__(self, session_id: Optional[str] = None): - details = {'session_id': session_id} if session_id else None - super().__init__( - message="Session has expired", - details=details, - user_message="Ihre Sitzung ist abgelaufen" - ) - - -class InsufficientPermissionsError(AuthenticationException): - def __init__( - self, - required_permission: str, - user_permissions: Optional[list] = None - ): - super().__init__( - message=f"User lacks required permission: {required_permission}", - details={ - 'required': required_permission, - 'user_permissions': user_permissions or [] - }, - user_message="Unzureichende Berechtigungen für diese Aktion" - ) - self.status_code = 403 - - -class DatabaseException(BaseApplicationException): - def __init__( - self, - message: str, - query: Optional[str] = None, - details: Optional[Dict[str, Any]] = None, - user_message: Optional[str] = None - ): - details = details or {} - if query: - details['query_hash'] = str(hash(query)) - - super().__init__( - message=message, - code='DATABASE_ERROR', - status_code=500, - details=details, - user_message=user_message or "Datenbankfehler aufgetreten" - ) - - -class ConnectionError(DatabaseException): - def __init__(self, message: str, host: Optional[str] = None): - details = {'host': host} if host else None - super().__init__( - message=message, - details=details, - user_message="Datenbankverbindung fehlgeschlagen" - ) - - -class QueryError(DatabaseException): - def __init__( - self, - message: str, - query: str, - error_code: Optional[str] = None - ): - super().__init__( - message=message, - query=query, - details={'error_code': error_code} if error_code else None, - user_message="Datenbankabfrage fehlgeschlagen" - ) - - -class TransactionError(DatabaseException): - def __init__(self, message: str, operation: str): - super().__init__( - message=message, - details={'operation': operation}, - user_message="Transaktion fehlgeschlagen" - ) - - -class ExternalServiceException(BaseApplicationException): - def __init__( - self, - service_name: str, - message: str, - details: Optional[Dict[str, Any]] = None, - user_message: Optional[str] = None - ): - details = details or {} - details['service'] = service_name - - super().__init__( - message=message, - code='EXTERNAL_SERVICE_ERROR', - status_code=502, - details=details, - user_message=user_message or f"Fehler beim Zugriff auf {service_name}" - ) - - -class APIError(ExternalServiceException): - def __init__( - self, - service_name: str, - endpoint: str, - status_code: int, - message: str - ): - super().__init__( - service_name=service_name, - message=message, - details={ - 'endpoint': endpoint, - 'response_status': status_code - }, - user_message=f"API-Fehler bei {service_name}" - ) - - -class TimeoutError(ExternalServiceException): - def __init__( - self, - service_name: str, - timeout_seconds: int, - operation: str - ): - super().__init__( - service_name=service_name, - message=f"Timeout after {timeout_seconds}s while {operation}", - details={ - 'timeout_seconds': timeout_seconds, - 'operation': operation - }, - user_message=f"Zeitüberschreitung bei {service_name}" - ) - - -class ResourceException(BaseApplicationException): - def __init__( - self, - message: str, - resource_type: str, - resource_id: Any = None, - details: Optional[Dict[str, Any]] = None, - user_message: Optional[str] = None - ): - details = details or {} - details.update({ - 'resource_type': resource_type, - 'resource_id': str(resource_id) if resource_id else None - }) - - super().__init__( - message=message, - code='RESOURCE_ERROR', - status_code=404, - details=details, - user_message=user_message or "Ressourcenfehler" - ) - - -class ResourceNotFoundError(ResourceException): - def __init__( - self, - resource_type: str, - resource_id: Any = None, - search_criteria: Optional[Dict[str, Any]] = None - ): - details = {'search_criteria': search_criteria} if search_criteria else None - super().__init__( - message=f"{resource_type} not found", - resource_type=resource_type, - resource_id=resource_id, - details=details, - user_message=f"{resource_type} nicht gefunden" - ) - self.status_code = 404 - - -class ResourceConflictError(ResourceException): - def __init__( - self, - resource_type: str, - resource_id: Any, - conflict_reason: str - ): - super().__init__( - message=f"Conflict with {resource_type}: {conflict_reason}", - resource_type=resource_type, - resource_id=resource_id, - details={'conflict_reason': conflict_reason}, - user_message=f"Konflikt mit {resource_type}" - ) - self.status_code = 409 - - -class ResourceLimitExceeded(ResourceException): - def __init__( - self, - resource_type: str, - limit: int, - current: int, - requested: Optional[int] = None - ): - details = { - 'limit': limit, - 'current': current, - 'requested': requested - } - super().__init__( - message=f"{resource_type} limit exceeded: {current}/{limit}", - resource_type=resource_type, - details=details, - user_message=f"Limit für {resource_type} überschritten" - ) - self.status_code = 429 \ No newline at end of file diff --git a/v2_adminpanel/core/logging_config.py b/v2_adminpanel/core/logging_config.py deleted file mode 100644 index 56df1dd..0000000 --- a/v2_adminpanel/core/logging_config.py +++ /dev/null @@ -1,190 +0,0 @@ -import logging -import logging.handlers -import json -import sys -import os -from datetime import datetime -from typing import Dict, Any -from flask import g, request, has_request_context - - -class StructuredFormatter(logging.Formatter): - def format(self, record): - log_data = { - 'timestamp': datetime.utcnow().isoformat(), - 'level': record.levelname, - 'logger': record.name, - 'message': record.getMessage(), - 'module': record.module, - 'function': record.funcName, - 'line': record.lineno - } - - if has_request_context(): - log_data['request'] = { - 'method': request.method, - 'path': request.path, - 'remote_addr': request.remote_addr, - 'user_agent': request.user_agent.string, - 'request_id': getattr(g, 'request_id', 'unknown') - } - - if hasattr(record, 'request_id'): - log_data['request_id'] = record.request_id - - if hasattr(record, 'error_code'): - log_data['error_code'] = record.error_code - - if hasattr(record, 'details') and record.details: - log_data['details'] = self._sanitize_details(record.details) - - if record.exc_info: - log_data['exception'] = { - 'type': record.exc_info[0].__name__, - 'message': str(record.exc_info[1]), - 'traceback': self.formatException(record.exc_info) - } - - return json.dumps(log_data, ensure_ascii=False) - - def _sanitize_details(self, details: Dict[str, Any]) -> Dict[str, Any]: - sensitive_fields = { - 'password', 'secret', 'token', 'api_key', 'authorization', - 'credit_card', 'ssn', 'pin' - } - - sanitized = {} - for key, value in details.items(): - if any(field in key.lower() for field in sensitive_fields): - sanitized[key] = '[REDACTED]' - elif isinstance(value, dict): - sanitized[key] = self._sanitize_details(value) - else: - sanitized[key] = value - - return sanitized - - -class ErrorLevelFilter(logging.Filter): - def __init__(self, min_level=logging.ERROR): - self.min_level = min_level - - def filter(self, record): - return record.levelno >= self.min_level - - -def setup_logging(app): - log_level = os.getenv('LOG_LEVEL', 'INFO').upper() - log_dir = os.getenv('LOG_DIR', 'logs') - - os.makedirs(log_dir, exist_ok=True) - - root_logger = logging.getLogger() - root_logger.setLevel(getattr(logging, log_level)) - - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(logging.INFO) - - if app.debug: - console_formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - ) - else: - console_formatter = StructuredFormatter() - - console_handler.setFormatter(console_formatter) - root_logger.addHandler(console_handler) - - app_log_handler = logging.handlers.RotatingFileHandler( - os.path.join(log_dir, 'app.log'), - maxBytes=10 * 1024 * 1024, - backupCount=10 - ) - app_log_handler.setLevel(logging.DEBUG) - app_log_handler.setFormatter(StructuredFormatter()) - root_logger.addHandler(app_log_handler) - - error_log_handler = logging.handlers.RotatingFileHandler( - os.path.join(log_dir, 'errors.log'), - maxBytes=10 * 1024 * 1024, - backupCount=10 - ) - error_log_handler.setLevel(logging.ERROR) - error_log_handler.setFormatter(StructuredFormatter()) - error_log_handler.addFilter(ErrorLevelFilter()) - root_logger.addHandler(error_log_handler) - - security_logger = logging.getLogger('security') - security_handler = logging.handlers.RotatingFileHandler( - os.path.join(log_dir, 'security.log'), - maxBytes=10 * 1024 * 1024, - backupCount=20 - ) - security_handler.setFormatter(StructuredFormatter()) - security_logger.addHandler(security_handler) - security_logger.setLevel(logging.INFO) - - werkzeug_logger = logging.getLogger('werkzeug') - werkzeug_logger.setLevel(logging.WARNING) - - @app.before_request - def log_request_info(): - logger = logging.getLogger('request') - logger.info( - 'Request started', - extra={ - 'request_id': getattr(g, 'request_id', 'unknown'), - 'details': { - 'method': request.method, - 'path': request.path, - 'query_params': dict(request.args), - 'content_length': request.content_length - } - } - ) - - @app.after_request - def log_response_info(response): - logger = logging.getLogger('request') - logger.info( - 'Request completed', - extra={ - 'request_id': getattr(g, 'request_id', 'unknown'), - 'details': { - 'status_code': response.status_code, - 'content_length': response.content_length or 0 - } - } - ) - return response - - -def get_logger(name: str) -> logging.Logger: - return logging.getLogger(name) - - -def log_error(logger: logging.Logger, message: str, error: Exception = None, **kwargs): - extra = kwargs.copy() - - if error: - extra['error_type'] = type(error).__name__ - extra['error_message'] = str(error) - - if hasattr(error, 'code'): - extra['error_code'] = error.code - if hasattr(error, 'details'): - extra['details'] = error.details - - logger.error(message, exc_info=error, extra=extra) - - -def log_security_event(event_type: str, message: str, **details): - logger = logging.getLogger('security') - logger.warning( - f"Security Event: {event_type} - {message}", - extra={ - 'security_event': event_type, - 'details': details, - 'request_id': getattr(g, 'request_id', 'unknown') if has_request_context() else None - } - ) \ No newline at end of file diff --git a/v2_adminpanel/core/monitoring.py b/v2_adminpanel/core/monitoring.py deleted file mode 100644 index 700aab9..0000000 --- a/v2_adminpanel/core/monitoring.py +++ /dev/null @@ -1,246 +0,0 @@ -import time -import functools -from typing import Dict, Any, Optional, List -from collections import defaultdict, deque -from datetime import datetime, timedelta -from threading import Lock -import logging - -from prometheus_client import Counter, Histogram, Gauge, generate_latest -from flask import g, request, Response - -from .exceptions import BaseApplicationException -from .logging_config import log_security_event - - -logger = logging.getLogger(__name__) - - -class ErrorMetrics: - def __init__(self): - self.error_counter = Counter( - 'app_errors_total', - 'Total number of errors', - ['error_code', 'status_code', 'endpoint'] - ) - - self.error_rate = Gauge( - 'app_error_rate', - 'Error rate per minute', - ['error_code'] - ) - - self.request_duration = Histogram( - 'app_request_duration_seconds', - 'Request duration in seconds', - ['method', 'endpoint', 'status_code'] - ) - - self.validation_errors = Counter( - 'app_validation_errors_total', - 'Total validation errors', - ['field', 'endpoint'] - ) - - self.auth_failures = Counter( - 'app_auth_failures_total', - 'Total authentication failures', - ['reason', 'endpoint'] - ) - - self.db_errors = Counter( - 'app_database_errors_total', - 'Total database errors', - ['error_type', 'operation'] - ) - - self._error_history = defaultdict(lambda: deque(maxlen=60)) - self._lock = Lock() - - def record_error(self, error: BaseApplicationException, endpoint: str = None): - endpoint = endpoint or request.endpoint or 'unknown' - - self.error_counter.labels( - error_code=error.code, - status_code=error.status_code, - endpoint=endpoint - ).inc() - - with self._lock: - self._error_history[error.code].append(datetime.utcnow()) - self._update_error_rates() - - if error.code == 'VALIDATION_ERROR' and 'field' in error.details: - self.validation_errors.labels( - field=error.details['field'], - endpoint=endpoint - ).inc() - elif error.code == 'AUTHENTICATION_ERROR': - reason = error.__class__.__name__ - self.auth_failures.labels( - reason=reason, - endpoint=endpoint - ).inc() - elif error.code == 'DATABASE_ERROR': - error_type = error.__class__.__name__ - operation = error.details.get('operation', 'unknown') - self.db_errors.labels( - error_type=error_type, - operation=operation - ).inc() - - def _update_error_rates(self): - now = datetime.utcnow() - one_minute_ago = now - timedelta(minutes=1) - - for error_code, timestamps in self._error_history.items(): - recent_count = sum(1 for ts in timestamps if ts >= one_minute_ago) - self.error_rate.labels(error_code=error_code).set(recent_count) - - -class AlertManager: - def __init__(self): - self.alerts = [] - self.alert_thresholds = { - 'error_rate': 10, - 'auth_failure_rate': 5, - 'db_error_rate': 3, - 'response_time_95th': 2.0 - } - self._lock = Lock() - - def check_alerts(self, metrics: ErrorMetrics): - new_alerts = [] - - for error_code, rate in self._get_current_error_rates(metrics).items(): - if rate > self.alert_thresholds['error_rate']: - new_alerts.append({ - 'type': 'high_error_rate', - 'severity': 'critical', - 'error_code': error_code, - 'rate': rate, - 'threshold': self.alert_thresholds['error_rate'], - 'message': f'High error rate for {error_code}: {rate}/min', - 'timestamp': datetime.utcnow() - }) - - auth_failure_rate = self._get_auth_failure_rate(metrics) - if auth_failure_rate > self.alert_thresholds['auth_failure_rate']: - new_alerts.append({ - 'type': 'auth_failures', - 'severity': 'warning', - 'rate': auth_failure_rate, - 'threshold': self.alert_thresholds['auth_failure_rate'], - 'message': f'High authentication failure rate: {auth_failure_rate}/min', - 'timestamp': datetime.utcnow() - }) - - log_security_event( - 'HIGH_AUTH_FAILURE_RATE', - f'Authentication failure rate exceeded threshold', - rate=auth_failure_rate, - threshold=self.alert_thresholds['auth_failure_rate'] - ) - - with self._lock: - self.alerts.extend(new_alerts) - self.alerts = [a for a in self.alerts - if a['timestamp'] > datetime.utcnow() - timedelta(hours=24)] - - return new_alerts - - def _get_current_error_rates(self, metrics: ErrorMetrics) -> Dict[str, float]: - rates = {} - with metrics._lock: - now = datetime.utcnow() - one_minute_ago = now - timedelta(minutes=1) - - for error_code, timestamps in metrics._error_history.items(): - rates[error_code] = sum(1 for ts in timestamps if ts >= one_minute_ago) - - return rates - - def _get_auth_failure_rate(self, metrics: ErrorMetrics) -> float: - return sum( - sample.value - for sample in metrics.auth_failures._child_samples() - ) / 60.0 - - def get_active_alerts(self) -> List[Dict[str, Any]]: - with self._lock: - return list(self.alerts) - - -error_metrics = ErrorMetrics() -alert_manager = AlertManager() - - -def init_monitoring(app): - @app.before_request - def before_request(): - g.start_time = time.time() - - @app.after_request - def after_request(response): - if hasattr(g, 'start_time'): - duration = time.time() - g.start_time - error_metrics.request_duration.labels( - method=request.method, - endpoint=request.endpoint or 'unknown', - status_code=response.status_code - ).observe(duration) - - return response - - @app.route('/metrics') - def metrics(): - alert_manager.check_alerts(error_metrics) - return Response(generate_latest(), mimetype='text/plain') - - @app.route('/api/alerts') - def get_alerts(): - alerts = alert_manager.get_active_alerts() - return { - 'alerts': alerts, - 'total': len(alerts), - 'critical': len([a for a in alerts if a['severity'] == 'critical']), - 'warning': len([a for a in alerts if a['severity'] == 'warning']) - } - - -def monitor_performance(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - start_time = time.time() - - try: - result = func(*args, **kwargs) - return result - finally: - duration = time.time() - start_time - if duration > 1.0: - logger.warning( - f"Slow function execution: {func.__name__}", - extra={ - 'function': func.__name__, - 'duration': duration, - 'request_id': getattr(g, 'request_id', 'unknown') - } - ) - - return wrapper - - -def track_error(error: BaseApplicationException): - error_metrics.record_error(error) - - if error.status_code >= 500: - logger.error( - f"Critical error occurred: {error.code}", - extra={ - 'error_code': error.code, - 'message': error.message, - 'details': error.details, - 'request_id': error.request_id - } - ) \ No newline at end of file diff --git a/v2_adminpanel/core/validators.py b/v2_adminpanel/core/validators.py deleted file mode 100644 index db715dc..0000000 --- a/v2_adminpanel/core/validators.py +++ /dev/null @@ -1,435 +0,0 @@ -import re -from typing import Any, Optional, List, Dict, Callable, Union -from datetime import datetime, date -from functools import wraps -import ipaddress -from flask import request - -from .exceptions import InputValidationError, ValidationException - - -class ValidationRules: - EMAIL_PATTERN = re.compile( - r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - ) - - PHONE_PATTERN = re.compile( - r'^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,10}$' - ) - - LICENSE_KEY_PATTERN = re.compile( - r'^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' - ) - - SAFE_STRING_PATTERN = re.compile( - r'^[a-zA-Z0-9\s\-\_\.\,\!\?\@\#\$\%\&\*\(\)\[\]\{\}\:\;\'\"\+\=\/\\]+$' - ) - - USERNAME_PATTERN = re.compile( - r'^[a-zA-Z0-9_\-\.]{3,50}$' - ) - - PASSWORD_MIN_LENGTH = 8 - PASSWORD_REQUIRE_UPPER = True - PASSWORD_REQUIRE_LOWER = True - PASSWORD_REQUIRE_DIGIT = True - PASSWORD_REQUIRE_SPECIAL = True - - -class Validators: - @staticmethod - def required(value: Any, field_name: str = "field") -> Any: - if value is None or (isinstance(value, str) and not value.strip()): - raise InputValidationError( - field=field_name, - message="This field is required", - value=value - ) - return value - - @staticmethod - def email(value: str, field_name: str = "email") -> str: - value = Validators.required(value, field_name).strip() - - if not ValidationRules.EMAIL_PATTERN.match(value): - raise InputValidationError( - field=field_name, - message="Invalid email format", - value=value, - expected_type="email" - ) - - return value.lower() - - @staticmethod - def phone(value: str, field_name: str = "phone") -> str: - value = Validators.required(value, field_name).strip() - - cleaned = re.sub(r'[\s\-\(\)]', '', value) - - if not ValidationRules.PHONE_PATTERN.match(value): - raise InputValidationError( - field=field_name, - message="Invalid phone number format", - value=value, - expected_type="phone" - ) - - return cleaned - - @staticmethod - def license_key(value: str, field_name: str = "license_key") -> str: - value = Validators.required(value, field_name).strip().upper() - - if not ValidationRules.LICENSE_KEY_PATTERN.match(value): - raise InputValidationError( - field=field_name, - message="Invalid license key format (expected: XXXX-XXXX-XXXX-XXXX)", - value=value, - expected_type="license_key" - ) - - return value - - @staticmethod - def integer( - value: Union[str, int], - field_name: str = "field", - min_value: Optional[int] = None, - max_value: Optional[int] = None - ) -> int: - try: - int_value = int(value) - except (ValueError, TypeError): - raise InputValidationError( - field=field_name, - message="Must be a valid integer", - value=value, - expected_type="integer" - ) - - if min_value is not None and int_value < min_value: - raise InputValidationError( - field=field_name, - message=f"Must be at least {min_value}", - value=int_value - ) - - if max_value is not None and int_value > max_value: - raise InputValidationError( - field=field_name, - message=f"Must be at most {max_value}", - value=int_value - ) - - return int_value - - @staticmethod - def float_number( - value: Union[str, float], - field_name: str = "field", - min_value: Optional[float] = None, - max_value: Optional[float] = None - ) -> float: - try: - float_value = float(value) - except (ValueError, TypeError): - raise InputValidationError( - field=field_name, - message="Must be a valid number", - value=value, - expected_type="float" - ) - - if min_value is not None and float_value < min_value: - raise InputValidationError( - field=field_name, - message=f"Must be at least {min_value}", - value=float_value - ) - - if max_value is not None and float_value > max_value: - raise InputValidationError( - field=field_name, - message=f"Must be at most {max_value}", - value=float_value - ) - - return float_value - - @staticmethod - def boolean(value: Union[str, bool], field_name: str = "field") -> bool: - if isinstance(value, bool): - return value - - if isinstance(value, str): - value_lower = value.lower() - if value_lower in ['true', '1', 'yes', 'on']: - return True - elif value_lower in ['false', '0', 'no', 'off']: - return False - - raise InputValidationError( - field=field_name, - message="Must be a valid boolean", - value=value, - expected_type="boolean" - ) - - @staticmethod - def string( - value: str, - field_name: str = "field", - min_length: Optional[int] = None, - max_length: Optional[int] = None, - pattern: Optional[re.Pattern] = None, - safe_only: bool = False - ) -> str: - value = Validators.required(value, field_name).strip() - - if min_length is not None and len(value) < min_length: - raise InputValidationError( - field=field_name, - message=f"Must be at least {min_length} characters", - value=value - ) - - if max_length is not None and len(value) > max_length: - raise InputValidationError( - field=field_name, - message=f"Must be at most {max_length} characters", - value=value - ) - - if safe_only and not ValidationRules.SAFE_STRING_PATTERN.match(value): - raise InputValidationError( - field=field_name, - message="Contains invalid characters", - value=value - ) - - if pattern and not pattern.match(value): - raise InputValidationError( - field=field_name, - message="Does not match required format", - value=value - ) - - return value - - @staticmethod - def username(value: str, field_name: str = "username") -> str: - value = Validators.required(value, field_name).strip() - - if not ValidationRules.USERNAME_PATTERN.match(value): - raise InputValidationError( - field=field_name, - message="Username must be 3-50 characters and contain only letters, numbers, _, -, or .", - value=value, - expected_type="username" - ) - - return value - - @staticmethod - def password(value: str, field_name: str = "password") -> str: - value = Validators.required(value, field_name) - - errors = [] - - if len(value) < ValidationRules.PASSWORD_MIN_LENGTH: - errors.append(f"at least {ValidationRules.PASSWORD_MIN_LENGTH} characters") - - if ValidationRules.PASSWORD_REQUIRE_UPPER and not re.search(r'[A-Z]', value): - errors.append("at least one uppercase letter") - - if ValidationRules.PASSWORD_REQUIRE_LOWER and not re.search(r'[a-z]', value): - errors.append("at least one lowercase letter") - - if ValidationRules.PASSWORD_REQUIRE_DIGIT and not re.search(r'\d', value): - errors.append("at least one digit") - - if ValidationRules.PASSWORD_REQUIRE_SPECIAL and not re.search(r'[!@#$%^&*(),.?":{}|<>]', value): - errors.append("at least one special character") - - if errors: - raise InputValidationError( - field=field_name, - message=f"Password must contain {', '.join(errors)}", - value="[hidden]" - ) - - return value - - @staticmethod - def date_string( - value: str, - field_name: str = "date", - format: str = "%Y-%m-%d", - min_date: Optional[date] = None, - max_date: Optional[date] = None - ) -> date: - value = Validators.required(value, field_name).strip() - - try: - date_value = datetime.strptime(value, format).date() - except ValueError: - raise InputValidationError( - field=field_name, - message=f"Invalid date format (expected: {format})", - value=value, - expected_type="date" - ) - - if min_date and date_value < min_date: - raise InputValidationError( - field=field_name, - message=f"Date must be after {min_date}", - value=value - ) - - if max_date and date_value > max_date: - raise InputValidationError( - field=field_name, - message=f"Date must be before {max_date}", - value=value - ) - - return date_value - - @staticmethod - def ip_address( - value: str, - field_name: str = "ip_address", - version: Optional[int] = None - ) -> str: - value = Validators.required(value, field_name).strip() - - try: - ip = ipaddress.ip_address(value) - if version and ip.version != version: - raise ValueError - except ValueError: - version_str = f"IPv{version}" if version else "IP" - raise InputValidationError( - field=field_name, - message=f"Invalid {version_str} address", - value=value, - expected_type="ip_address" - ) - - return str(ip) - - @staticmethod - def url( - value: str, - field_name: str = "url", - require_https: bool = False - ) -> str: - value = Validators.required(value, field_name).strip() - - url_pattern = re.compile( - r'^https?://' - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' - r'localhost|' - r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' - r'(?::\d+)?' - r'(?:/?|[/?]\S+)$', re.IGNORECASE - ) - - if not url_pattern.match(value): - raise InputValidationError( - field=field_name, - message="Invalid URL format", - value=value, - expected_type="url" - ) - - if require_https and not value.startswith('https://'): - raise InputValidationError( - field=field_name, - message="URL must use HTTPS", - value=value - ) - - return value - - @staticmethod - def enum( - value: Any, - field_name: str, - allowed_values: List[Any] - ) -> Any: - if value not in allowed_values: - raise InputValidationError( - field=field_name, - message=f"Must be one of: {', '.join(map(str, allowed_values))}", - value=value - ) - - return value - - -def validate(rules: Dict[str, Dict[str, Any]]) -> Callable: - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(*args, **kwargs): - data = request.get_json() if request.is_json else request.form - validated_data = {} - - for field_name, field_rules in rules.items(): - value = data.get(field_name) - - if 'required' in field_rules and field_rules['required']: - value = Validators.required(value, field_name) - elif value is None or value == '': - if 'default' in field_rules: - validated_data[field_name] = field_rules['default'] - continue - - validator_name = field_rules.get('type', 'string') - validator_func = getattr(Validators, validator_name, None) - - if not validator_func: - raise ValueError(f"Unknown validator type: {validator_name}") - - validator_params = { - k: v for k, v in field_rules.items() - if k not in ['type', 'required', 'default'] - } - validator_params['field_name'] = field_name - - validated_data[field_name] = validator_func(value, **validator_params) - - request.validated_data = validated_data - return func(*args, **kwargs) - - return wrapper - return decorator - - -def sanitize_html(value: str) -> str: - dangerous_tags = re.compile( - r'<(script|iframe|object|embed|form|input|button|textarea|select|link|meta|style).*?>.*?', - 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 \ No newline at end of file diff --git a/v2_adminpanel/db.py b/v2_adminpanel/db.py deleted file mode 100644 index be8284e..0000000 --- a/v2_adminpanel/db.py +++ /dev/null @@ -1,84 +0,0 @@ -import psycopg2 -from psycopg2.extras import Json, RealDictCursor -from contextlib import contextmanager -from config import DATABASE_CONFIG - - -def get_connection(): - """Create and return a new database connection""" - conn = psycopg2.connect(**DATABASE_CONFIG) - conn.set_client_encoding('UTF8') - return conn - - -@contextmanager -def get_db_connection(): - """Context manager for database connections""" - conn = get_connection() - try: - yield conn - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.close() - - -@contextmanager -def get_db_cursor(conn=None): - """Context manager for database cursors""" - if conn is None: - with get_db_connection() as connection: - cur = connection.cursor() - try: - yield cur - finally: - cur.close() - else: - cur = conn.cursor() - try: - yield cur - finally: - cur.close() - - -@contextmanager -def get_dict_cursor(conn=None): - """Context manager for dictionary cursors""" - if conn is None: - with get_db_connection() as connection: - cur = connection.cursor(cursor_factory=RealDictCursor) - try: - yield cur - finally: - cur.close() - else: - cur = conn.cursor(cursor_factory=RealDictCursor) - try: - yield cur - finally: - cur.close() - - -def execute_query(query, params=None, fetch_one=False, fetch_all=False, as_dict=False): - """Execute a query and optionally fetch results""" - with get_db_connection() as conn: - cursor_func = get_dict_cursor if as_dict else get_db_cursor - with cursor_func(conn) as cur: - cur.execute(query, params) - - if fetch_one: - return cur.fetchone() - elif fetch_all: - return cur.fetchall() - else: - return cur.rowcount - - -def execute_many(query, params_list): - """Execute a query multiple times with different parameters""" - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.executemany(query, params_list) - return cur.rowcount \ No newline at end of file diff --git a/v2_adminpanel/init.sql b/v2_adminpanel/init.sql deleted file mode 100644 index 896ba45..0000000 --- a/v2_adminpanel/init.sql +++ /dev/null @@ -1,704 +0,0 @@ --- UTF-8 Encoding für deutsche Sonderzeichen sicherstellen -SET client_encoding = 'UTF8'; - --- Zeitzone auf Europe/Berlin setzen -SET timezone = 'Europe/Berlin'; - -CREATE TABLE IF NOT EXISTS customers ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - email TEXT, - is_fake BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT unique_email UNIQUE (email) -); - -CREATE TABLE IF NOT EXISTS licenses ( - id SERIAL PRIMARY KEY, - license_key TEXT UNIQUE NOT NULL, - customer_id INTEGER REFERENCES customers(id), - license_type TEXT NOT NULL, - valid_from DATE NOT NULL, - valid_until DATE NOT NULL, - is_active BOOLEAN DEFAULT TRUE, - is_fake BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS sessions ( - id SERIAL PRIMARY KEY, - license_id INTEGER REFERENCES licenses(id), - license_key VARCHAR(60), -- Denormalized for performance - session_id TEXT UNIQUE NOT NULL, - username VARCHAR(50), - computer_name VARCHAR(100), - hardware_id VARCHAR(100), - ip_address TEXT, - user_agent TEXT, - app_version VARCHAR(20), - login_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, -- Alias for started_at - last_activity TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, -- Alias for last_heartbeat - logout_time TIMESTAMP WITH TIME ZONE, -- Alias for ended_at - started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - ended_at TIMESTAMP WITH TIME ZONE, - is_active BOOLEAN DEFAULT TRUE, - active BOOLEAN DEFAULT TRUE -- Alias for is_active -); - --- Audit-Log-Tabelle für Änderungsprotokolle -CREATE TABLE IF NOT EXISTS audit_log ( - id SERIAL PRIMARY KEY, - timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - username TEXT NOT NULL, - action TEXT NOT NULL, - entity_type TEXT NOT NULL, - entity_id INTEGER, - old_values JSONB, - new_values JSONB, - ip_address TEXT, - user_agent TEXT, - additional_info TEXT -); - --- Index für bessere Performance bei Abfragen -CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC); -CREATE INDEX idx_audit_log_username ON audit_log(username); -CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id); - --- Backup-Historie-Tabelle -CREATE TABLE IF NOT EXISTS backup_history ( - id SERIAL PRIMARY KEY, - filename TEXT NOT NULL, - filepath TEXT NOT NULL, - filesize BIGINT, - backup_type TEXT NOT NULL, -- 'manual' oder 'scheduled' - status TEXT NOT NULL, -- 'success', 'failed', 'in_progress' - error_message TEXT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - created_by TEXT NOT NULL, - tables_count INTEGER, - records_count INTEGER, - duration_seconds NUMERIC, - is_encrypted BOOLEAN DEFAULT TRUE -); - --- Index für bessere Performance -CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC); -CREATE INDEX idx_backup_history_status ON backup_history(status); - --- Login-Attempts-Tabelle für Rate-Limiting -CREATE TABLE IF NOT EXISTS login_attempts ( - ip_address VARCHAR(45) PRIMARY KEY, - attempt_count INTEGER DEFAULT 0, - first_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - last_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - blocked_until TIMESTAMP WITH TIME ZONE NULL, - last_username_tried TEXT, - last_error_message TEXT -); - --- Index für schnelle Abfragen -CREATE INDEX idx_login_attempts_blocked_until ON login_attempts(blocked_until); -CREATE INDEX idx_login_attempts_last_attempt ON login_attempts(last_attempt DESC); - --- Migration: Füge created_at zu licenses hinzu, falls noch nicht vorhanden -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'created_at') THEN - ALTER TABLE licenses ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP; - - -- Setze created_at für bestehende Einträge auf das valid_from Datum - UPDATE licenses SET created_at = valid_from WHERE created_at IS NULL; - END IF; -END $$; - --- ===================== RESOURCE POOL SYSTEM ===================== - --- Haupttabelle für den Resource Pool -CREATE TABLE IF NOT EXISTS resource_pools ( - id SERIAL PRIMARY KEY, - resource_type VARCHAR(20) NOT NULL CHECK (resource_type IN ('domain', 'ipv4', 'phone')), - resource_value VARCHAR(255) NOT NULL, - status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('available', 'allocated', 'quarantine')), - allocated_to_license INTEGER REFERENCES licenses(id) ON DELETE SET NULL, - status_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - status_changed_by VARCHAR(50), - quarantine_reason VARCHAR(100) CHECK (quarantine_reason IN ('abuse', 'defect', 'maintenance', 'blacklisted', 'expired', 'review', NULL)), - quarantine_until TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - notes TEXT, - is_fake BOOLEAN DEFAULT FALSE, - UNIQUE(resource_type, resource_value) -); - --- Resource History für vollständige Nachverfolgbarkeit -CREATE TABLE IF NOT EXISTS resource_history ( - id SERIAL PRIMARY KEY, - resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, - license_id INTEGER REFERENCES licenses(id) ON DELETE SET NULL, - action VARCHAR(50) NOT NULL CHECK (action IN ('allocated', 'deallocated', 'quarantined', 'released', 'created', 'deleted')), - action_by VARCHAR(50) NOT NULL, - action_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - details JSONB, - ip_address TEXT -); - --- Resource Metrics für Performance-Tracking und ROI -CREATE TABLE IF NOT EXISTS resource_metrics ( - id SERIAL PRIMARY KEY, - resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, - metric_date DATE NOT NULL, - usage_count INTEGER DEFAULT 0, - performance_score DECIMAL(5,2) DEFAULT 0.00, - cost DECIMAL(10,2) DEFAULT 0.00, - revenue DECIMAL(10,2) DEFAULT 0.00, - issues_count INTEGER DEFAULT 0, - availability_percent DECIMAL(5,2) DEFAULT 100.00, - UNIQUE(resource_id, metric_date) -); - --- Zuordnungstabelle zwischen Lizenzen und Ressourcen -CREATE TABLE IF NOT EXISTS license_resources ( - id SERIAL PRIMARY KEY, - license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, - resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, - assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - assigned_by VARCHAR(50), - is_active BOOLEAN DEFAULT TRUE, - UNIQUE(license_id, resource_id) -); - --- Erweiterung der licenses Tabelle um Resource-Counts -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'domain_count') THEN - ALTER TABLE licenses - ADD COLUMN domain_count INTEGER DEFAULT 1 CHECK (domain_count >= 0 AND domain_count <= 10), - ADD COLUMN ipv4_count INTEGER DEFAULT 1 CHECK (ipv4_count >= 0 AND ipv4_count <= 10), - ADD COLUMN phone_count INTEGER DEFAULT 1 CHECK (phone_count >= 0 AND phone_count <= 10); - END IF; -END $$; - --- Erweiterung der licenses Tabelle um device_limit -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'device_limit') THEN - ALTER TABLE licenses - ADD COLUMN device_limit INTEGER DEFAULT 3 CHECK (device_limit >= 1 AND device_limit <= 10); - END IF; -END $$; - --- Tabelle für Geräte-Registrierungen -CREATE TABLE IF NOT EXISTS device_registrations ( - id SERIAL PRIMARY KEY, - license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, - hardware_id TEXT NOT NULL, - device_name TEXT, - operating_system TEXT, - first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - is_active BOOLEAN DEFAULT TRUE, - deactivated_at TIMESTAMP WITH TIME ZONE, - deactivated_by TEXT, - ip_address TEXT, - user_agent TEXT, - UNIQUE(license_id, hardware_id) -); - --- Indizes für device_registrations -CREATE INDEX IF NOT EXISTS idx_device_license ON device_registrations(license_id); -CREATE INDEX IF NOT EXISTS idx_device_hardware ON device_registrations(hardware_id); -CREATE INDEX IF NOT EXISTS idx_device_active ON device_registrations(license_id, is_active) WHERE is_active = TRUE; - --- Indizes für Performance -CREATE INDEX IF NOT EXISTS idx_resource_status ON resource_pools(status); -CREATE INDEX IF NOT EXISTS idx_resource_type_status ON resource_pools(resource_type, status); -CREATE INDEX IF NOT EXISTS idx_resource_allocated ON resource_pools(allocated_to_license) WHERE allocated_to_license IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_resource_quarantine ON resource_pools(quarantine_until) WHERE status = 'quarantine'; -CREATE INDEX IF NOT EXISTS idx_resource_history_date ON resource_history(action_at DESC); -CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id); -CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC); -CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE; - --- Users table for authentication with password and 2FA support -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - email VARCHAR(100), - totp_secret VARCHAR(32), - totp_enabled BOOLEAN DEFAULT FALSE, - backup_codes TEXT, -- JSON array of hashed backup codes - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - password_reset_token VARCHAR(64), - password_reset_expires TIMESTAMP WITH TIME ZONE, - failed_2fa_attempts INTEGER DEFAULT 0, - last_failed_2fa TIMESTAMP WITH TIME ZONE -); - --- Index for faster login lookups -CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); -CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL; - --- Migration: Add is_fake column to licenses if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'is_fake') THEN - ALTER TABLE licenses ADD COLUMN is_fake BOOLEAN DEFAULT FALSE; - - -- Mark all existing licenses as fake data - UPDATE licenses SET is_fake = TRUE; - - -- Add index for better performance when filtering fake data - CREATE INDEX idx_licenses_is_fake ON licenses(is_fake); - END IF; -END $$; - --- Migration: Add is_fake column to customers if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'customers' AND column_name = 'is_fake') THEN - ALTER TABLE customers ADD COLUMN is_fake BOOLEAN DEFAULT FALSE; - - -- Mark all existing customers as fake data - UPDATE customers SET is_fake = TRUE; - - -- Add index for better performance - CREATE INDEX idx_customers_is_fake ON customers(is_fake); - END IF; -END $$; - --- Migration: Add is_fake column to resource_pools if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'resource_pools' AND column_name = 'is_fake') THEN - ALTER TABLE resource_pools ADD COLUMN is_fake BOOLEAN DEFAULT FALSE; - - -- Mark all existing resources as fake data - UPDATE resource_pools SET is_fake = TRUE; - - -- Add index for better performance - CREATE INDEX idx_resource_pools_is_fake ON resource_pools(is_fake); - END IF; -END $$; - --- Migration: Add missing columns to sessions table -DO $$ -BEGIN - -- Add license_key column - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'sessions' AND column_name = 'license_key') THEN - ALTER TABLE sessions ADD COLUMN license_key VARCHAR(60); - END IF; - - -- Add username column - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'sessions' AND column_name = 'username') THEN - ALTER TABLE sessions ADD COLUMN username VARCHAR(50); - END IF; - - -- Add computer_name column - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'sessions' AND column_name = 'computer_name') THEN - ALTER TABLE sessions ADD COLUMN computer_name VARCHAR(100); - END IF; - - -- Add hardware_id column - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'sessions' AND column_name = 'hardware_id') THEN - ALTER TABLE sessions ADD COLUMN hardware_id VARCHAR(100); - END IF; - - -- Add app_version column - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'sessions' AND column_name = 'app_version') THEN - ALTER TABLE sessions ADD COLUMN app_version VARCHAR(20); - END IF; - - -- Add login_time as alias for started_at - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'sessions' AND column_name = 'login_time') THEN - ALTER TABLE sessions ADD COLUMN login_time TIMESTAMP WITH TIME ZONE; - UPDATE sessions SET login_time = started_at; - END IF; - - -- Add last_activity as alias for last_heartbeat - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'sessions' AND column_name = 'last_activity') THEN - ALTER TABLE sessions ADD COLUMN last_activity TIMESTAMP WITH TIME ZONE; - UPDATE sessions SET last_activity = last_heartbeat; - END IF; - - -- Add logout_time as alias for ended_at - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'sessions' AND column_name = 'logout_time') THEN - ALTER TABLE sessions ADD COLUMN logout_time TIMESTAMP WITH TIME ZONE; - UPDATE sessions SET logout_time = ended_at; - END IF; - - -- Add active as alias for is_active - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'sessions' AND column_name = 'active') THEN - ALTER TABLE sessions ADD COLUMN active BOOLEAN DEFAULT TRUE; - UPDATE sessions SET active = is_active; - END IF; -END $$; - --- ===================== LICENSE SERVER TABLES ===================== --- Following best practices: snake_case for DB fields, clear naming conventions - --- Enable UUID extension -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- License tokens for offline validation -CREATE TABLE IF NOT EXISTS license_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, - token VARCHAR(512) NOT NULL UNIQUE, - hardware_id VARCHAR(255) NOT NULL, - valid_until TIMESTAMP NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_validated TIMESTAMP, - validation_count INTEGER DEFAULT 0 -); - -CREATE INDEX idx_token ON license_tokens(token); -CREATE INDEX idx_hardware ON license_tokens(hardware_id); -CREATE INDEX idx_valid_until ON license_tokens(valid_until); - --- Heartbeat tracking with partitioning support -CREATE TABLE IF NOT EXISTS license_heartbeats ( - id BIGSERIAL, - license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, - hardware_id VARCHAR(255) NOT NULL, - ip_address INET, - user_agent VARCHAR(500), - app_version VARCHAR(50), - timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - session_data JSONB, - PRIMARY KEY (id, timestamp) -) PARTITION BY RANGE (timestamp); - --- Create partitions for the current and next month -CREATE TABLE IF NOT EXISTS license_heartbeats_2025_01 PARTITION OF license_heartbeats - FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); - -CREATE TABLE IF NOT EXISTS license_heartbeats_2025_02 PARTITION OF license_heartbeats - FOR VALUES FROM ('2025-02-01') TO ('2025-03-01'); - --- Add June 2025 partition for current month -CREATE TABLE IF NOT EXISTS license_heartbeats_2025_06 PARTITION OF license_heartbeats - FOR VALUES FROM ('2025-06-01') TO ('2025-07-01'); - -CREATE INDEX idx_heartbeat_license_time ON license_heartbeats(license_id, timestamp DESC); -CREATE INDEX idx_heartbeat_hardware_time ON license_heartbeats(hardware_id, timestamp DESC); - --- Activation events tracking -CREATE TABLE IF NOT EXISTS activation_events ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, - event_type VARCHAR(50) NOT NULL CHECK (event_type IN ('activation', 'deactivation', 'reactivation', 'transfer')), - hardware_id VARCHAR(255), - previous_hardware_id VARCHAR(255), - ip_address INET, - user_agent VARCHAR(500), - success BOOLEAN DEFAULT true, - error_message TEXT, - metadata JSONB, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_license_events ON activation_events(license_id, created_at DESC); -CREATE INDEX idx_event_type ON activation_events(event_type, created_at DESC); - --- API rate limiting -CREATE TABLE IF NOT EXISTS api_rate_limits ( - id SERIAL PRIMARY KEY, - api_key VARCHAR(255) NOT NULL UNIQUE, - requests_per_minute INTEGER DEFAULT 60, - requests_per_hour INTEGER DEFAULT 1000, - requests_per_day INTEGER DEFAULT 10000, - burst_size INTEGER DEFAULT 100, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Anomaly detection -CREATE TABLE IF NOT EXISTS anomaly_detections ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - license_id INTEGER REFERENCES licenses(id), - anomaly_type VARCHAR(100) NOT NULL CHECK (anomaly_type IN ('multiple_ips', 'rapid_hardware_change', 'suspicious_pattern', 'concurrent_use', 'geo_anomaly')), - severity VARCHAR(20) NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')), - details JSONB NOT NULL, - detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - resolved BOOLEAN DEFAULT false, - resolved_at TIMESTAMP, - resolved_by VARCHAR(255), - action_taken TEXT -); - -CREATE INDEX idx_unresolved ON anomaly_detections(resolved, severity, detected_at DESC); -CREATE INDEX idx_license_anomalies ON anomaly_detections(license_id, detected_at DESC); - --- API clients for authentication -CREATE TABLE IF NOT EXISTS api_clients ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - client_name VARCHAR(255) NOT NULL, - api_key VARCHAR(255) NOT NULL UNIQUE, - secret_key VARCHAR(255) NOT NULL, - is_active BOOLEAN DEFAULT true, - allowed_endpoints TEXT[], - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Feature flags for gradual rollout -CREATE TABLE IF NOT EXISTS feature_flags ( - id SERIAL PRIMARY KEY, - feature_name VARCHAR(100) NOT NULL UNIQUE, - is_enabled BOOLEAN DEFAULT false, - rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage >= 0 AND rollout_percentage <= 100), - whitelist_license_ids INTEGER[], - blacklist_license_ids INTEGER[], - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Insert default feature flags -INSERT INTO feature_flags (feature_name, is_enabled, rollout_percentage) VALUES - ('anomaly_detection', true, 100), - ('offline_tokens', true, 100), - ('advanced_analytics', false, 0), - ('geo_restriction', false, 0) -ON CONFLICT (feature_name) DO NOTHING; - --- Session management for concurrent use tracking -CREATE TABLE IF NOT EXISTS active_sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, - hardware_id VARCHAR(255) NOT NULL, - session_token VARCHAR(512) NOT NULL UNIQUE, - ip_address INET, - started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP NOT NULL -); - -CREATE INDEX idx_session_license ON active_sessions(license_id); -CREATE INDEX idx_session_expires ON active_sessions(expires_at); - --- Update trigger for updated_at columns -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE TRIGGER update_api_rate_limits_updated_at BEFORE UPDATE ON api_rate_limits - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_api_clients_updated_at BEFORE UPDATE ON api_clients - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_feature_flags_updated_at BEFORE UPDATE ON feature_flags - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- Function to automatically create monthly partitions for heartbeats -CREATE OR REPLACE FUNCTION create_monthly_partition() -RETURNS void AS $$ -DECLARE - start_date date; - end_date date; - partition_name text; -BEGIN - start_date := date_trunc('month', CURRENT_DATE + interval '1 month'); - end_date := start_date + interval '1 month'; - partition_name := 'license_heartbeats_' || to_char(start_date, 'YYYY_MM'); - - EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)', - partition_name, start_date, end_date); -END; -$$ LANGUAGE plpgsql; - --- Migration: Add max_devices column to licenses if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'max_devices') THEN - ALTER TABLE licenses ADD COLUMN max_devices INTEGER DEFAULT 3 CHECK (max_devices >= 1); - END IF; -END $$; - --- Migration: Add expires_at column to licenses if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'expires_at') THEN - ALTER TABLE licenses ADD COLUMN expires_at TIMESTAMP; - -- Set expires_at based on valid_until for existing licenses - UPDATE licenses SET expires_at = valid_until::timestamp WHERE expires_at IS NULL; - END IF; -END $$; - --- Migration: Add features column to licenses if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'features') THEN - ALTER TABLE licenses ADD COLUMN features TEXT[] DEFAULT '{}'; - END IF; -END $$; - --- Migration: Add updated_at column to licenses if it doesn't exist -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'updated_at') THEN - ALTER TABLE licenses ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; - CREATE TRIGGER update_licenses_updated_at BEFORE UPDATE ON licenses - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - END IF; -END $$; - - --- Migration: Add device_type column to device_registrations table -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'device_registrations' AND column_name = 'device_type') THEN - ALTER TABLE device_registrations ADD COLUMN device_type VARCHAR(50) DEFAULT 'unknown'; - - -- Update existing records to have a device_type based on operating system - UPDATE device_registrations - SET device_type = CASE - WHEN operating_system ILIKE '%windows%' THEN 'desktop' - WHEN operating_system ILIKE '%mac%' THEN 'desktop' - WHEN operating_system ILIKE '%linux%' THEN 'desktop' - WHEN operating_system ILIKE '%android%' THEN 'mobile' - WHEN operating_system ILIKE '%ios%' THEN 'mobile' - ELSE 'unknown' - END - WHERE device_type IS NULL OR device_type = 'unknown'; - END IF; -END $$; - --- Client configuration table for Account Forger -CREATE TABLE IF NOT EXISTS client_configs ( - id SERIAL PRIMARY KEY, - client_name VARCHAR(100) NOT NULL DEFAULT 'Account Forger', - api_key VARCHAR(255) NOT NULL, - heartbeat_interval INTEGER DEFAULT 30, -- seconds - session_timeout INTEGER DEFAULT 60, -- seconds (2x heartbeat) - current_version VARCHAR(20) NOT NULL, - minimum_version VARCHAR(20) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- License sessions for single-session enforcement -CREATE TABLE IF NOT EXISTS license_sessions ( - id SERIAL PRIMARY KEY, - license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, - hardware_id VARCHAR(255) NOT NULL, - ip_address INET, - client_version VARCHAR(20), - session_token VARCHAR(255) UNIQUE NOT NULL, - started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_heartbeat TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(license_id) -- Only one active session per license -); - --- Session history for debugging -CREATE TABLE IF NOT EXISTS session_history ( - id SERIAL PRIMARY KEY, - license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, - hardware_id VARCHAR(255) NOT NULL, - ip_address INET, - client_version VARCHAR(20), - started_at TIMESTAMP, - ended_at TIMESTAMP, - end_reason VARCHAR(50) -- 'normal', 'timeout', 'forced', 'replaced' -); - --- Create indexes for performance -CREATE INDEX IF NOT EXISTS idx_license_sessions_license_id ON license_sessions(license_id); -CREATE INDEX IF NOT EXISTS idx_license_sessions_last_heartbeat ON license_sessions(last_heartbeat); -CREATE INDEX IF NOT EXISTS idx_session_history_license_id ON session_history(license_id); -CREATE INDEX IF NOT EXISTS idx_session_history_ended_at ON session_history(ended_at); - --- Insert default client configuration if not exists -INSERT INTO client_configs (client_name, api_key, current_version, minimum_version) -VALUES ('Account Forger', 'AF-' || gen_random_uuid()::text, '1.0.0', '1.0.0') -ON CONFLICT DO NOTHING; - --- ===================== SYSTEM API KEY TABLE ===================== --- Single API key for system-wide authentication -CREATE TABLE IF NOT EXISTS system_api_key ( - id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), -- Ensures single row - api_key VARCHAR(255) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - regenerated_at TIMESTAMP WITH TIME ZONE, - last_used_at TIMESTAMP WITH TIME ZONE, - usage_count INTEGER DEFAULT 0, - created_by VARCHAR(50), - regenerated_by VARCHAR(50) -); - --- Function to generate API key with AF-YYYY- prefix -CREATE OR REPLACE FUNCTION generate_api_key() RETURNS VARCHAR AS $$ -DECLARE - year_part VARCHAR(4); - random_part VARCHAR(32); -BEGIN - year_part := to_char(CURRENT_DATE, 'YYYY'); - random_part := upper(substring(md5(random()::text || clock_timestamp()::text) from 1 for 32)); - RETURN 'AF-' || year_part || '-' || random_part; -END; -$$ LANGUAGE plpgsql; - --- Initialize with a default API key if none exists -INSERT INTO system_api_key (api_key, created_by) -SELECT generate_api_key(), 'system' -WHERE NOT EXISTS (SELECT 1 FROM system_api_key); - --- Audit trigger for API key changes -CREATE OR REPLACE FUNCTION audit_api_key_changes() RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'UPDATE' AND OLD.api_key != NEW.api_key THEN - INSERT INTO audit_log ( - timestamp, - username, - action, - entity_type, - entity_id, - old_values, - new_values, - additional_info - ) VALUES ( - CURRENT_TIMESTAMP, - COALESCE(NEW.regenerated_by, 'system'), - 'api_key_regenerated', - 'system_api_key', - NEW.id, - jsonb_build_object('api_key', LEFT(OLD.api_key, 8) || '...'), - jsonb_build_object('api_key', LEFT(NEW.api_key, 8) || '...'), - 'API Key regenerated' - ); - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER audit_system_api_key_changes -AFTER UPDATE ON system_api_key -FOR EACH ROW EXECUTE FUNCTION audit_api_key_changes(); diff --git a/v2_adminpanel/leads/__init__.py b/v2_adminpanel/leads/__init__.py deleted file mode 100644 index fa003bc..0000000 --- a/v2_adminpanel/leads/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Lead Management Module -from flask import Blueprint - -leads_bp = Blueprint('leads', __name__, template_folder='templates') - -from . import routes \ No newline at end of file diff --git a/v2_adminpanel/leads/models.py b/v2_adminpanel/leads/models.py deleted file mode 100644 index 7671422..0000000 --- a/v2_adminpanel/leads/models.py +++ /dev/null @@ -1,48 +0,0 @@ -# Lead Management Data Models -from dataclasses import dataclass -from datetime import datetime -from typing import List, Optional, Dict, Any -from uuid import UUID - -@dataclass -class Institution: - id: UUID - name: str - metadata: Dict[str, Any] - created_at: datetime - updated_at: datetime - created_by: str - contact_count: Optional[int] = 0 - -@dataclass -class Contact: - id: UUID - institution_id: UUID - first_name: str - last_name: str - position: Optional[str] - extra_fields: Dict[str, Any] - created_at: datetime - updated_at: datetime - institution_name: Optional[str] = None - -@dataclass -class ContactDetail: - id: UUID - contact_id: UUID - detail_type: str # 'phone', 'email' - detail_value: str - detail_label: Optional[str] # 'Mobil', 'Geschäftlich', etc. - is_primary: bool - created_at: datetime - -@dataclass -class Note: - id: UUID - contact_id: UUID - note_text: str - version: int - is_current: bool - created_at: datetime - created_by: str - parent_note_id: Optional[UUID] = None \ No newline at end of file diff --git a/v2_adminpanel/leads/repositories.py b/v2_adminpanel/leads/repositories.py deleted file mode 100644 index 55123d4..0000000 --- a/v2_adminpanel/leads/repositories.py +++ /dev/null @@ -1,359 +0,0 @@ -# Database Repository for Lead Management -import psycopg2 -from psycopg2.extras import RealDictCursor -from uuid import UUID -from typing import List, Optional, Dict, Any -from datetime import datetime - -class LeadRepository: - def __init__(self, get_db_connection): - self.get_db_connection = get_db_connection - - # Institution Methods - def get_institutions_with_counts(self) -> List[Dict[str, Any]]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = """ - SELECT - i.id, - i.name, - i.metadata, - i.created_at, - i.updated_at, - i.created_by, - COUNT(c.id) as contact_count - FROM lead_institutions i - LEFT JOIN lead_contacts c ON c.institution_id = i.id - GROUP BY i.id - ORDER BY i.name - """ - - cur.execute(query) - results = cur.fetchall() - cur.close() - - return results - - def create_institution(self, name: str, created_by: str) -> Dict[str, Any]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = """ - INSERT INTO lead_institutions (name, created_by) - VALUES (%s, %s) - RETURNING * - """ - - cur.execute(query, (name, created_by)) - result = cur.fetchone() - cur.close() - - return result - - def get_institution_by_id(self, institution_id: UUID) -> Optional[Dict[str, Any]]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = """ - SELECT * FROM lead_institutions WHERE id = %s - """ - - cur.execute(query, (str(institution_id),)) - result = cur.fetchone() - cur.close() - - return result - - def update_institution(self, institution_id: UUID, name: str) -> Dict[str, Any]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = """ - UPDATE lead_institutions - SET name = %s, updated_at = CURRENT_TIMESTAMP - WHERE id = %s - RETURNING * - """ - - cur.execute(query, (name, str(institution_id))) - result = cur.fetchone() - cur.close() - - return result - - # Contact Methods - def get_contacts_by_institution(self, institution_id: UUID) -> List[Dict[str, Any]]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = """ - SELECT - c.*, - i.name as institution_name - FROM lead_contacts c - JOIN lead_institutions i ON i.id = c.institution_id - WHERE c.institution_id = %s - ORDER BY c.last_name, c.first_name - """ - - cur.execute(query, (str(institution_id),)) - results = cur.fetchall() - cur.close() - - return results - - def create_contact(self, data: Dict[str, Any]) -> Dict[str, Any]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = """ - INSERT INTO lead_contacts - (institution_id, first_name, last_name, position, extra_fields) - VALUES (%s, %s, %s, %s, %s) - RETURNING * - """ - - cur.execute(query, ( - str(data['institution_id']), - data['first_name'], - data['last_name'], - data.get('position'), - psycopg2.extras.Json(data.get('extra_fields', {})) - )) - result = cur.fetchone() - cur.close() - - return result - - def get_contact_with_details(self, contact_id: UUID) -> Dict[str, Any]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - # Get contact base info - query = """ - SELECT - c.*, - i.name as institution_name - FROM lead_contacts c - JOIN lead_institutions i ON i.id = c.institution_id - WHERE c.id = %s - """ - - cur.execute(query, (str(contact_id),)) - contact = cur.fetchone() - - if contact: - # Get contact details (phones, emails) - details_query = """ - SELECT * FROM lead_contact_details - WHERE contact_id = %s - ORDER BY detail_type, is_primary DESC, created_at - """ - cur.execute(details_query, (str(contact_id),)) - contact['details'] = cur.fetchall() - - # Get notes - notes_query = """ - SELECT * FROM lead_notes - WHERE contact_id = %s AND is_current = true - ORDER BY created_at DESC - """ - cur.execute(notes_query, (str(contact_id),)) - contact['notes'] = cur.fetchall() - - cur.close() - - return contact - - def update_contact(self, contact_id: UUID, data: Dict[str, Any]) -> Dict[str, Any]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = """ - UPDATE lead_contacts - SET first_name = %s, last_name = %s, position = %s, - updated_at = CURRENT_TIMESTAMP - WHERE id = %s - RETURNING * - """ - - cur.execute(query, ( - data['first_name'], - data['last_name'], - data.get('position'), - str(contact_id) - )) - result = cur.fetchone() - cur.close() - - return result - - # Contact Details Methods - def add_contact_detail(self, contact_id: UUID, detail_type: str, - detail_value: str, detail_label: str = None) -> Dict[str, Any]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = """ - INSERT INTO lead_contact_details - (contact_id, detail_type, detail_value, detail_label) - VALUES (%s, %s, %s, %s) - RETURNING * - """ - - cur.execute(query, ( - str(contact_id), - detail_type, - detail_value, - detail_label - )) - result = cur.fetchone() - cur.close() - - return result - - def get_contact_detail_by_id(self, detail_id: UUID) -> Optional[Dict[str, Any]]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = "SELECT * FROM lead_contact_details WHERE id = %s" - cur.execute(query, (str(detail_id),)) - result = cur.fetchone() - cur.close() - - return result - - def update_contact_detail(self, detail_id: UUID, detail_value: str, - detail_label: str = None) -> Dict[str, Any]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = """ - UPDATE lead_contact_details - SET detail_value = %s, detail_label = %s, updated_at = CURRENT_TIMESTAMP - WHERE id = %s - RETURNING * - """ - - cur.execute(query, (detail_value, detail_label, str(detail_id))) - result = cur.fetchone() - cur.close() - - return result - - def delete_contact_detail(self, detail_id: UUID) -> bool: - with self.get_db_connection() as conn: - cur = conn.cursor() - - query = "DELETE FROM lead_contact_details WHERE id = %s" - cur.execute(query, (str(detail_id),)) - - deleted = cur.rowcount > 0 - cur.close() - - return deleted - - # Notes Methods - def create_note(self, contact_id: UUID, note_text: str, created_by: str) -> Dict[str, Any]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = """ - INSERT INTO lead_notes - (contact_id, note_text, created_by) - VALUES (%s, %s, %s) - RETURNING * - """ - - cur.execute(query, ( - str(contact_id), - note_text, - created_by - )) - result = cur.fetchone() - cur.close() - - return result - - def update_note(self, note_id: UUID, note_text: str, updated_by: str) -> Dict[str, Any]: - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - # First, mark current version as not current - update_old = """ - UPDATE lead_notes - SET is_current = false - WHERE id = %s - """ - cur.execute(update_old, (str(note_id),)) - - # Create new version - create_new = """ - INSERT INTO lead_notes - (contact_id, note_text, created_by, parent_note_id, version) - SELECT contact_id, %s, %s, %s, version + 1 - FROM lead_notes - WHERE id = %s - RETURNING * - """ - - cur.execute(create_new, ( - note_text, - updated_by, - str(note_id), - str(note_id) - )) - result = cur.fetchone() - cur.close() - - return result - - def delete_note(self, note_id: UUID) -> bool: - with self.get_db_connection() as conn: - cur = conn.cursor() - - # Soft delete by marking as not current - query = """ - UPDATE lead_notes - SET is_current = false - WHERE id = %s - """ - cur.execute(query, (str(note_id),)) - - deleted = cur.rowcount > 0 - cur.close() - - return deleted - - def get_all_contacts_with_institutions(self) -> List[Dict[str, Any]]: - """Get all contacts with their institution information""" - with self.get_db_connection() as conn: - cur = conn.cursor(cursor_factory=RealDictCursor) - - query = """ - SELECT - c.id, - c.first_name, - c.last_name, - c.position, - c.created_at, - c.updated_at, - c.institution_id, - i.name as institution_name, - (SELECT COUNT(*) FROM lead_contact_details - WHERE contact_id = c.id AND detail_type = 'phone') as phone_count, - (SELECT COUNT(*) FROM lead_contact_details - WHERE contact_id = c.id AND detail_type = 'email') as email_count, - (SELECT COUNT(*) FROM lead_notes - WHERE contact_id = c.id AND is_current = true) as note_count - FROM lead_contacts c - JOIN lead_institutions i ON i.id = c.institution_id - ORDER BY c.last_name, c.first_name - """ - - cur.execute(query) - results = cur.fetchall() - cur.close() - - return results \ No newline at end of file diff --git a/v2_adminpanel/leads/routes.py b/v2_adminpanel/leads/routes.py deleted file mode 100644 index 136f545..0000000 --- a/v2_adminpanel/leads/routes.py +++ /dev/null @@ -1,397 +0,0 @@ -# Routes for Lead Management -from flask import render_template, request, jsonify, redirect, url_for, flash -from auth.decorators import login_required -from flask import session as flask_session -from . import leads_bp -from .services import LeadService -from .repositories import LeadRepository -from db import get_db_connection -from uuid import UUID -import traceback - -# Service will be initialized per request -lead_repository = None -lead_service = None - -def get_lead_service(): - """Get or create lead service instance""" - global lead_repository, lead_service - if lead_service is None: - lead_repository = LeadRepository(get_db_connection) # Pass the function, not call it - lead_service = LeadService(lead_repository) - return lead_service - -# HTML Routes -@leads_bp.route('/management') -@login_required -def lead_management(): - """Lead Management Dashboard""" - try: - # Get institutions with contact counts - institutions = get_lead_service().list_institutions() - - # Get all contacts with institution names - all_contacts = get_lead_service().list_all_contacts() - - # Calculate totals - total_institutions = len(institutions) - total_contacts = len(all_contacts) - - return render_template('leads/lead_management.html', - total_institutions=total_institutions, - total_contacts=total_contacts, - institutions=institutions, - all_contacts=all_contacts) - except Exception as e: - import traceback - print(f"Error in lead_management: {str(e)}") - print(traceback.format_exc()) - flash(f'Fehler beim Laden des Dashboards: {str(e)}', 'error') - current_user = flask_session.get('username', 'System') - return render_template('leads/lead_management.html', - total_institutions=0, - total_contacts=0, - institutions=[], - all_contacts=[]) - -@leads_bp.route('/') -@login_required -def institutions(): - """List all institutions""" - try: - institutions = get_lead_service().list_institutions() - return render_template('leads/institutions.html', institutions=institutions) - except Exception as e: - flash(f'Fehler beim Laden der Institutionen: {str(e)}', 'error') - return render_template('leads/institutions.html', institutions=[]) - -@leads_bp.route('/institution/add', methods=['POST']) -@login_required -def add_institution(): - """Add new institution from form""" - try: - name = request.form.get('name') - if not name: - flash('Name ist erforderlich', 'error') - return redirect(url_for('leads.lead_management')) - - # Add institution - get_lead_service().create_institution(name, flask_session.get('username', 'System')) - flash(f'Institution "{name}" wurde erfolgreich hinzugefügt', 'success') - except Exception as e: - flash(f'Fehler beim Hinzufügen der Institution: {str(e)}', 'error') - - return redirect(url_for('leads.lead_management')) - -@leads_bp.route('/contact/add', methods=['POST']) -@login_required -def add_contact(): - """Add new contact from form""" - try: - data = { - 'institution_id': request.form.get('institution_id'), - 'first_name': request.form.get('first_name'), - 'last_name': request.form.get('last_name'), - 'position': request.form.get('position') - } - - # Validate required fields - if not data['institution_id'] or not data['first_name'] or not data['last_name']: - flash('Institution, Vorname und Nachname sind erforderlich', 'error') - return redirect(url_for('leads.lead_management')) - - # Create contact - contact = get_lead_service().create_contact(data, flask_session.get('username', 'System')) - - # Add email if provided - email = request.form.get('email') - if email: - get_lead_service().add_email(contact['id'], email, 'Primär', flask_session.get('username', 'System')) - - # Add phone if provided - phone = request.form.get('phone') - if phone: - get_lead_service().add_phone(contact['id'], phone, 'Primär', flask_session.get('username', 'System')) - - flash(f'Kontakt "{data["first_name"]} {data["last_name"]}" wurde erfolgreich hinzugefügt', 'success') - except Exception as e: - flash(f'Fehler beim Hinzufügen des Kontakts: {str(e)}', 'error') - - return redirect(url_for('leads.lead_management')) - -@leads_bp.route('/institution/') -@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/') -@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/', 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/', 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//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//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/', 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/', 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//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/', 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/', 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() \ No newline at end of file diff --git a/v2_adminpanel/leads/services.py b/v2_adminpanel/leads/services.py deleted file mode 100644 index 64e9265..0000000 --- a/v2_adminpanel/leads/services.py +++ /dev/null @@ -1,171 +0,0 @@ -# Business Logic Service for Lead Management -from typing import List, Dict, Any, Optional -from uuid import UUID -from datetime import datetime -from .repositories import LeadRepository - -class LeadService: - def __init__(self, repository: LeadRepository): - self.repo = repository - - # Institution Services - def list_institutions(self) -> List[Dict[str, Any]]: - """Get all institutions with contact counts""" - return self.repo.get_institutions_with_counts() - - def create_institution(self, name: str, user: str) -> Dict[str, Any]: - """Create a new institution""" - # Validation - if not name or len(name.strip()) == 0: - raise ValueError("Institution name cannot be empty") - - # Create institution - institution = self.repo.create_institution(name.strip(), user) - - # Note: Audit logging removed as it requires different implementation - # Can be added later with proper audit system integration - - return institution - - def update_institution(self, institution_id: UUID, name: str, user: str) -> Dict[str, Any]: - """Update institution name""" - # Validation - if not name or len(name.strip()) == 0: - raise ValueError("Institution name cannot be empty") - - # Get current institution - current = self.repo.get_institution_by_id(institution_id) - if not current: - raise ValueError("Institution not found") - - # Update - institution = self.repo.update_institution(institution_id, name.strip()) - - return institution - - # Contact Services - def list_contacts_by_institution(self, institution_id: UUID) -> List[Dict[str, Any]]: - """Get all contacts for an institution""" - return self.repo.get_contacts_by_institution(institution_id) - - def create_contact(self, data: Dict[str, Any], user: str) -> Dict[str, Any]: - """Create a new contact""" - # Validation - if not data.get('first_name') or not data.get('last_name'): - raise ValueError("First and last name are required") - - if not data.get('institution_id'): - raise ValueError("Institution ID is required") - - # Create contact - contact = self.repo.create_contact(data) - - return contact - - def get_contact_details(self, contact_id: UUID) -> Dict[str, Any]: - """Get full contact information including details and notes""" - contact = self.repo.get_contact_with_details(contact_id) - if not contact: - raise ValueError("Contact not found") - - # Group details by type - contact['phones'] = [d for d in contact.get('details', []) if d['detail_type'] == 'phone'] - contact['emails'] = [d for d in contact.get('details', []) if d['detail_type'] == 'email'] - - return contact - - def update_contact(self, contact_id: UUID, data: Dict[str, Any], user: str) -> Dict[str, Any]: - """Update contact information""" - # Validation - if not data.get('first_name') or not data.get('last_name'): - raise ValueError("First and last name are required") - - # Update contact - contact = self.repo.update_contact(contact_id, data) - - return contact - - # Contact Details Services - def add_phone(self, contact_id: UUID, phone_number: str, - phone_type: str = None, user: str = None) -> Dict[str, Any]: - """Add phone number to contact""" - if not phone_number: - raise ValueError("Phone number is required") - - detail = self.repo.add_contact_detail( - contact_id, 'phone', phone_number, phone_type - ) - - return detail - - def add_email(self, contact_id: UUID, email: str, - email_type: str = None, user: str = None) -> Dict[str, Any]: - """Add email to contact""" - if not email: - raise ValueError("Email is required") - - # Basic email validation - if '@' not in email: - raise ValueError("Invalid email format") - - detail = self.repo.add_contact_detail( - contact_id, 'email', email, email_type - ) - - return detail - - def update_contact_detail(self, detail_id: UUID, detail_value: str, - detail_label: str = None, user: str = None) -> Dict[str, Any]: - """Update a contact detail (phone/email)""" - if not detail_value or len(detail_value.strip()) == 0: - raise ValueError("Detail value cannot be empty") - - # Get current detail to check type - current_detail = self.repo.get_contact_detail_by_id(detail_id) - if not current_detail: - raise ValueError("Contact detail not found") - - # Validation based on type - if current_detail['detail_type'] == 'email' and '@' not in detail_value: - raise ValueError("Invalid email format") - - detail = self.repo.update_contact_detail( - detail_id, detail_value.strip(), detail_label - ) - - return detail - - def delete_contact_detail(self, detail_id: UUID, user: str) -> bool: - """Delete a contact detail (phone/email)""" - success = self.repo.delete_contact_detail(detail_id) - - return success - - # Note Services - def add_note(self, contact_id: UUID, note_text: str, user: str) -> Dict[str, Any]: - """Add a note to contact""" - if not note_text or len(note_text.strip()) == 0: - raise ValueError("Note text cannot be empty") - - note = self.repo.create_note(contact_id, note_text.strip(), user) - - return note - - def update_note(self, note_id: UUID, note_text: str, user: str) -> Dict[str, Any]: - """Update a note (creates new version)""" - if not note_text or len(note_text.strip()) == 0: - raise ValueError("Note text cannot be empty") - - note = self.repo.update_note(note_id, note_text.strip(), user) - - return note - - def delete_note(self, note_id: UUID, user: str) -> bool: - """Delete a note (soft delete)""" - success = self.repo.delete_note(note_id) - - return success - - def list_all_contacts(self) -> List[Dict[str, Any]]: - """Get all contacts across all institutions with summary info""" - return self.repo.get_all_contacts_with_institutions() \ No newline at end of file diff --git a/v2_adminpanel/leads/templates/leads/all_contacts.html b/v2_adminpanel/leads/templates/leads/all_contacts.html deleted file mode 100644 index ecddeaa..0000000 --- a/v2_adminpanel/leads/templates/leads/all_contacts.html +++ /dev/null @@ -1,239 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Alle Kontakte{% endblock %} - -{% block content %} -
-
-
-

- Alle Kontakte -

-

Übersicht aller Kontakte aus allen Institutionen

-
- -
- - -
-
-
- - -
-
-
- -
-
-
- - - -
-
-
- - -
-
-
- - - - - - - - - - - - - - {% for contact in contacts %} - - - - - - - - - - {% endfor %} - -
NameInstitutionPositionKontaktdatenNotizenZuletzt aktualisiertAktionen
- - {{ contact.last_name }}, {{ contact.first_name }} - - - - {{ contact.institution_name }} - - {{ contact.position or '-' }} - {% if contact.phone_count > 0 %} - - {{ contact.phone_count }} - - {% endif %} - {% if contact.email_count > 0 %} - - {{ contact.email_count }} - - {% endif %} - {% if contact.phone_count == 0 and contact.email_count == 0 %} - - - {% endif %} - - {% if contact.note_count > 0 %} - - {{ contact.note_count }} - - {% else %} - - - {% endif %} - - {{ (contact.updated_at or contact.created_at).strftime('%d.%m.%Y %H:%M') }} - - - Details - -
- {% if not contacts %} -
-

Noch keine Kontakte vorhanden.

- - Zu Institutionen - -
- {% endif %} -
-
-
- - - {% if contacts %} -
-
-

- {{ contacts|length }} von {{ contacts|length }} Kontakten angezeigt -

-
-
- {% endif %} -
- - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/leads/templates/leads/contact_detail.html b/v2_adminpanel/leads/templates/leads/contact_detail.html deleted file mode 100644 index 58c8794..0000000 --- a/v2_adminpanel/leads/templates/leads/contact_detail.html +++ /dev/null @@ -1,622 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ contact.first_name }} {{ contact.last_name }} - Kontakt-Details{% endblock %} - -{% block content %} -
-
-
-

- {{ contact.first_name }} {{ contact.last_name }} -

-

- {{ contact.position or 'Keine Position' }} - - - {{ contact.institution_name }} - -

-
-
- - - Zurück - -
-
- -
- -
- -
-
-
Telefonnummern
- -
-
- {% if contact.phones %} -
    - {% for phone in contact.phones %} -
  • -
    - {{ phone.detail_value }} - {% if phone.detail_label %} - {{ phone.detail_label }} - {% endif %} -
    -
    - - -
    -
  • - {% endfor %} -
- {% else %} -

Keine Telefonnummern hinterlegt.

- {% endif %} -
-
- - -
-
-
E-Mail-Adressen
- -
-
- {% if contact.emails %} -
    - {% for email in contact.emails %} -
  • -
    - {{ email.detail_value }} - {% if email.detail_label %} - {{ email.detail_label }} - {% endif %} -
    -
    - - -
    -
  • - {% endfor %} -
- {% else %} -

Keine E-Mail-Adressen hinterlegt.

- {% endif %} -
-
-
- - -
-
-
-
Notizen
-
-
- -
- - -
- - -
- {% for note in contact.notes %} -
-
-
- - - {{ note.created_at.strftime('%d.%m.%Y %H:%M') }} - {% if note.created_by %} • {{ note.created_by }}{% endif %} - {% if note.version > 1 %} - v{{ note.version }} - {% endif %} - -
- - -
-
-
- {{ note.note_text|nl2br|safe }} -
-
- - - -
-
-
- {% endfor %} -
- {% if not contact.notes %} -

Noch keine Notizen vorhanden.

- {% endif %} -
-
-
-
-
- - - - - - - - - - - - - - - - - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/leads/templates/leads/institution_detail.html b/v2_adminpanel/leads/templates/leads/institution_detail.html deleted file mode 100644 index 8dc6a4d..0000000 --- a/v2_adminpanel/leads/templates/leads/institution_detail.html +++ /dev/null @@ -1,159 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ institution.name }} - Lead-Details{% endblock %} - -{% block content %} -
-
-
-

- {{ institution.name }} -

- - Erstellt am {{ institution.created_at.strftime('%d.%m.%Y') }} - {% if institution.created_by %}von {{ institution.created_by }}{% endif %} - -
-
- - - Zurück - -
-
- - -
-
-
- Kontakte - {{ contacts|length }} -
-
-
-
- - - - - - - - - - - {% for contact in contacts %} - - - - - - - {% endfor %} - -
NamePositionErstellt amAktionen
- - {{ contact.first_name }} {{ contact.last_name }} - - {{ contact.position or '-' }}{{ contact.created_at.strftime('%d.%m.%Y') }} - - Details - -
- {% if not contacts %} -
-

Noch keine Kontakte für diese Institution.

- -
- {% endif %} -
-
-
-
- - - - - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/leads/templates/leads/institutions.html b/v2_adminpanel/leads/templates/leads/institutions.html deleted file mode 100644 index d4635da..0000000 --- a/v2_adminpanel/leads/templates/leads/institutions.html +++ /dev/null @@ -1,189 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Lead-Verwaltung - Institutionen{% endblock %} - -{% block content %} -
-
-
-

- Lead-Institutionen -

-
-
- - - Alle Kontakte - - - Zurück zu Kunden - -
-
- - - - - -
-
-
- - - - - - - - - - - - {% for institution in institutions %} - - - - - - - - {% endfor %} - -
InstitutionAnzahl KontakteErstellt amErstellt vonAktionen
- - {{ institution.name }} - - - {{ institution.contact_count }} - {{ institution.created_at.strftime('%d.%m.%Y') }}{{ institution.created_by or '-' }} - - Details - - -
- {% if not institutions %} -
-

Noch keine Institutionen vorhanden.

- -
- {% endif %} -
-
-
-
- - - - - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/leads/templates/leads/lead_management.html b/v2_adminpanel/leads/templates/leads/lead_management.html deleted file mode 100644 index 6056181..0000000 --- a/v2_adminpanel/leads/templates/leads/lead_management.html +++ /dev/null @@ -1,367 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Lead Management{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

📊 Lead Management

-
- - -
-
-
-
-

{{ total_institutions }}

- Institutionen -
-
-
-
-
-
-

{{ total_contacts }}

- Kontakte -
-
-
-
- - -
-
-
Schnellaktionen
-
- - - - Exportieren - -
-
-
- - -
-
- - - - -
- -
- -
-
- -
-
- -
-
- -
-
- - -
- - - - - - - - - - - {% for institution in institutions %} - - - - - - - {% endfor %} - -
NameAnzahl KontakteErstellt amAktionen
- - {{ institution.name }} - - {{ institution.contact_count }}{{ institution.created_at.strftime('%d.%m.%Y') }} - - Details - -
- {% if not institutions %} -

Keine Institutionen vorhanden.

- {% endif %} -
-
- - -
- -
-
- -
-
- -
-
- -
-
- -
-
- - -
- - - - - - - - - - - - - {% for contact in all_contacts %} - - - - - - - - - {% endfor %} - -
NamePositionInstitutionE-MailTelefonAktionen
- - {{ contact.first_name }} {{ contact.last_name }} - - {{ contact.position or '-' }}{{ contact.institution_name }} - {% if contact.emails %} - {{ contact.emails[0] }} - {% else %} - - - {% endif %} - - {% if contact.phones %} - {{ contact.phones[0] }} - {% else %} - - - {% endif %} - - - Details - -
- {% if not all_contacts %} -

Keine Kontakte vorhanden.

- {% endif %} -
-
-
-
-
-
- - - - - - - -{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/middleware/__init__.py b/v2_adminpanel/middleware/__init__.py deleted file mode 100644 index 1186bdf..0000000 --- a/v2_adminpanel/middleware/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .error_middleware import ErrorHandlingMiddleware \ No newline at end of file diff --git a/v2_adminpanel/middleware/error_middleware.py b/v2_adminpanel/middleware/error_middleware.py deleted file mode 100644 index 3938fc7..0000000 --- a/v2_adminpanel/middleware/error_middleware.py +++ /dev/null @@ -1,54 +0,0 @@ -import time -import uuid -from typing import Optional -from flask import request, g -from werkzeug.exceptions import HTTPException - -from core.exceptions import BaseApplicationException -from core.monitoring import track_error -from core.logging_config import get_logger - - -logger = get_logger(__name__) - - -class ErrorHandlingMiddleware: - def __init__(self, app=None): - self.app = app - if app: - self.init_app(app) - - def init_app(self, app): - app.before_request(self._before_request) - app.teardown_appcontext(self._teardown_request) - - def _before_request(self): - g.request_id = request.headers.get('X-Request-ID', str(uuid.uuid4())) - g.start_time = time.time() - g.errors = [] - - def _teardown_request(self, exception=None): - if exception: - self._handle_exception(exception) - - if hasattr(g, 'errors') and g.errors: - for error in g.errors: - if isinstance(error, BaseApplicationException): - track_error(error) - - def _handle_exception(self, exception): - if isinstance(exception, BaseApplicationException): - track_error(exception) - elif isinstance(exception, HTTPException): - pass - else: - logger.error( - f"Unhandled exception: {type(exception).__name__}", - exc_info=True, - extra={ - 'request_id': getattr(g, 'request_id', 'unknown'), - 'endpoint': request.endpoint, - 'method': request.method, - 'path': request.path - } - ) \ No newline at end of file diff --git a/v2_adminpanel/migrations/add_device_type.sql b/v2_adminpanel/migrations/add_device_type.sql deleted file mode 100644 index 5d0ad66..0000000 --- a/v2_adminpanel/migrations/add_device_type.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Migration: Add device_type column to device_registrations table -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'device_registrations' AND column_name = 'device_type') THEN - ALTER TABLE device_registrations ADD COLUMN device_type VARCHAR(50) DEFAULT 'unknown'; - - -- Update existing records to have a device_type based on operating system - UPDATE device_registrations - SET device_type = CASE - WHEN operating_system ILIKE '%windows%' THEN 'desktop' - WHEN operating_system ILIKE '%mac%' THEN 'desktop' - WHEN operating_system ILIKE '%linux%' THEN 'desktop' - WHEN operating_system ILIKE '%android%' THEN 'mobile' - WHEN operating_system ILIKE '%ios%' THEN 'mobile' - ELSE 'unknown' - END - WHERE device_type IS NULL OR device_type = 'unknown'; - END IF; -END $$; \ No newline at end of file diff --git a/v2_adminpanel/migrations/add_fake_constraint.sql b/v2_adminpanel/migrations/add_fake_constraint.sql deleted file mode 100644 index e0c93e7..0000000 --- a/v2_adminpanel/migrations/add_fake_constraint.sql +++ /dev/null @@ -1,72 +0,0 @@ --- Add constraint to ensure licenses always inherit is_fake from their customer --- This migration adds a trigger to automatically sync is_fake status - --- Function to sync is_fake status -CREATE OR REPLACE FUNCTION sync_license_fake_status() -RETURNS TRIGGER AS $$ -BEGIN - -- When inserting or updating a license, get is_fake from customer - IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND NEW.customer_id != OLD.customer_id) THEN - SELECT is_fake INTO NEW.is_fake - FROM customers - WHERE id = NEW.customer_id; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- Trigger for licenses table -DROP TRIGGER IF EXISTS sync_license_fake_before_insert_update ON licenses; -CREATE TRIGGER sync_license_fake_before_insert_update - BEFORE INSERT OR UPDATE ON licenses - FOR EACH ROW - EXECUTE FUNCTION sync_license_fake_status(); - --- Function to update licenses when customer is_fake changes -CREATE OR REPLACE FUNCTION sync_customer_fake_to_licenses() -RETURNS TRIGGER AS $$ -BEGIN - -- When customer is_fake changes, update all their licenses - IF TG_OP = 'UPDATE' AND NEW.is_fake != OLD.is_fake THEN - UPDATE licenses - SET is_fake = NEW.is_fake - WHERE customer_id = NEW.id; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- Trigger for customers table -DROP TRIGGER IF EXISTS sync_customer_fake_after_update ON customers; -CREATE TRIGGER sync_customer_fake_after_update - AFTER UPDATE ON customers - FOR EACH ROW - EXECUTE FUNCTION sync_customer_fake_to_licenses(); - --- Verify current data is consistent (should return 0) -DO $$ -DECLARE - mismatch_count INTEGER; -BEGIN - SELECT COUNT(*) INTO mismatch_count - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_fake != c.is_fake; - - IF mismatch_count > 0 THEN - RAISE NOTICE 'Found % mismatches. Fixing...', mismatch_count; - - -- Fix any existing mismatches - UPDATE licenses l - SET is_fake = c.is_fake - FROM customers c - WHERE l.customer_id = c.id - AND l.is_fake != c.is_fake; - - RAISE NOTICE 'Fixed all mismatches.'; - ELSE - RAISE NOTICE 'No mismatches found. Data is consistent.'; - END IF; -END $$; \ No newline at end of file diff --git a/v2_adminpanel/migrations/add_june_2025_partition.sql b/v2_adminpanel/migrations/add_june_2025_partition.sql deleted file mode 100644 index 4a405f4..0000000 --- a/v2_adminpanel/migrations/add_june_2025_partition.sql +++ /dev/null @@ -1,58 +0,0 @@ --- Migration: Add June 2025 partition for license_heartbeats table --- This migration adds the missing partition for the current month (June 2025) - --- Check if the partition already exists before creating it -DO $$ -BEGIN - -- Check if the June 2025 partition exists - IF NOT EXISTS ( - SELECT 1 - FROM pg_tables - WHERE tablename = 'license_heartbeats_2025_06' - ) THEN - -- Create the June 2025 partition - EXECUTE 'CREATE TABLE license_heartbeats_2025_06 PARTITION OF license_heartbeats - FOR VALUES FROM (''2025-06-01'') TO (''2025-07-01'')'; - - RAISE NOTICE 'Created partition license_heartbeats_2025_06'; - ELSE - RAISE NOTICE 'Partition license_heartbeats_2025_06 already exists'; - END IF; -END $$; - --- Also create partitions for the next few months to avoid future issues -DO $$ -DECLARE - partition_name text; - start_date date; - end_date date; - i integer; -BEGIN - -- Create partitions for the next 6 months - FOR i IN 0..6 LOOP - start_date := date_trunc('month', CURRENT_DATE + (i || ' months')::interval); - end_date := start_date + interval '1 month'; - partition_name := 'license_heartbeats_' || to_char(start_date, 'YYYY_MM'); - - -- Check if partition already exists - IF NOT EXISTS ( - SELECT 1 - FROM pg_tables - WHERE tablename = partition_name - ) THEN - EXECUTE format('CREATE TABLE %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)', - partition_name, start_date, end_date); - - RAISE NOTICE 'Created partition %', partition_name; - END IF; - END LOOP; -END $$; - --- Verify the partitions were created -SELECT - schemaname, - tablename, - tableowner -FROM pg_tables -WHERE tablename LIKE 'license_heartbeats_%' -ORDER BY tablename; \ No newline at end of file diff --git a/v2_adminpanel/migrations/cleanup_orphaned_api_tables.sql b/v2_adminpanel/migrations/cleanup_orphaned_api_tables.sql deleted file mode 100644 index 1f5f222..0000000 --- a/v2_adminpanel/migrations/cleanup_orphaned_api_tables.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Cleanup orphaned API-related tables --- Since admin panel is exclusively for Account Forger, we only need system_api_key table - --- Drop tables that depend on api_clients -DROP TABLE IF EXISTS rate_limits CASCADE; -DROP TABLE IF EXISTS license_events CASCADE; - --- Drop orphaned API tables -DROP TABLE IF EXISTS api_clients CASCADE; -DROP TABLE IF EXISTS api_keys CASCADE; - --- Add comments to document the single API key system -COMMENT ON TABLE system_api_key IS 'Single API key table for Account Forger authentication. This is the ONLY API key system in use.'; - --- Log the cleanup -INSERT INTO audit_log (username, action, entity_type, details, ip_address) -VALUES ('SYSTEM', 'CLEANUP', 'database', 'Removed orphaned API tables: api_keys, api_clients, rate_limits, license_events', '127.0.0.1'); \ No newline at end of file diff --git a/v2_adminpanel/migrations/create_lead_tables.sql b/v2_adminpanel/migrations/create_lead_tables.sql deleted file mode 100644 index aa6e3f2..0000000 --- a/v2_adminpanel/migrations/create_lead_tables.sql +++ /dev/null @@ -1,107 +0,0 @@ --- Lead Management Tables Migration --- This creates all necessary tables for the lead management system - --- Enable UUID extension if not already enabled -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- 1. Lead Institutions (only name required) -CREATE TABLE IF NOT EXISTS lead_institutions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - -- Metadata for future extensions without schema changes - metadata JSONB DEFAULT '{}', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_by VARCHAR(100), - UNIQUE(name) -); - --- Index for fast lookups -CREATE INDEX IF NOT EXISTS idx_lead_institutions_name ON lead_institutions(name); - --- 2. Lead Contacts -CREATE TABLE IF NOT EXISTS lead_contacts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - institution_id UUID NOT NULL REFERENCES lead_institutions(id) ON DELETE CASCADE, - first_name VARCHAR(100) NOT NULL, - last_name VARCHAR(100) NOT NULL, - position VARCHAR(255), - -- Extra fields for future extensions - extra_fields JSONB DEFAULT '{}', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Indexes for performance -CREATE INDEX IF NOT EXISTS idx_lead_contacts_institution ON lead_contacts(institution_id); -CREATE INDEX IF NOT EXISTS idx_lead_contacts_name ON lead_contacts(last_name, first_name); - --- 3. Flexible Contact Details (phones, emails, etc.) -CREATE TABLE IF NOT EXISTS lead_contact_details ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - contact_id UUID NOT NULL REFERENCES lead_contacts(id) ON DELETE CASCADE, - detail_type VARCHAR(50) NOT NULL, -- 'phone', 'email', 'social', etc. - detail_value VARCHAR(255) NOT NULL, - detail_label VARCHAR(50), -- 'Mobil', 'Geschäftlich', 'Privat', etc. - is_primary BOOLEAN DEFAULT false, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Indexes for fast queries -CREATE INDEX IF NOT EXISTS idx_lead_details_contact_type ON lead_contact_details(contact_id, detail_type); -CREATE INDEX IF NOT EXISTS idx_lead_details_value ON lead_contact_details(detail_value); - --- 4. Versioned Notes with History -CREATE TABLE IF NOT EXISTS lead_notes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - contact_id UUID NOT NULL REFERENCES lead_contacts(id) ON DELETE CASCADE, - note_text TEXT NOT NULL, - version INTEGER DEFAULT 1, - is_current BOOLEAN DEFAULT true, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_by VARCHAR(100), - parent_note_id UUID REFERENCES lead_notes(id), - CHECK (note_text <> '') -); - --- Indexes for note queries -CREATE INDEX IF NOT EXISTS idx_lead_notes_contact_current ON lead_notes(contact_id, is_current); -CREATE INDEX IF NOT EXISTS idx_lead_notes_created ON lead_notes(created_at DESC); - --- Full text search preparation -CREATE INDEX IF NOT EXISTS idx_lead_contacts_search ON lead_contacts -USING gin(to_tsvector('german', - COALESCE(first_name, '') || ' ' || - COALESCE(last_name, '') || ' ' || - COALESCE(position, '') -)); - --- Update timestamp trigger function -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ language 'plpgsql'; - --- Apply update trigger to tables with updated_at -CREATE TRIGGER update_lead_institutions_updated_at - BEFORE UPDATE ON lead_institutions - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_lead_contacts_updated_at - BEFORE UPDATE ON lead_contacts - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - --- Add comments for documentation -COMMENT ON TABLE lead_institutions IS 'Organizations/Companies for lead management'; -COMMENT ON TABLE lead_contacts IS 'Contact persons within institutions'; -COMMENT ON TABLE lead_contact_details IS 'Flexible contact details (phone, email, etc.)'; -COMMENT ON TABLE lead_notes IS 'Versioned notes with full history'; - -COMMENT ON COLUMN lead_contact_details.detail_type IS 'Type of detail: phone, email, social, etc.'; -COMMENT ON COLUMN lead_notes.is_current IS 'Only current version is shown, old versions kept for history'; -COMMENT ON COLUMN lead_notes.parent_note_id IS 'References original note for version tracking'; \ No newline at end of file diff --git a/v2_adminpanel/migrations/create_license_heartbeats_table.sql b/v2_adminpanel/migrations/create_license_heartbeats_table.sql deleted file mode 100644 index a043122..0000000 --- a/v2_adminpanel/migrations/create_license_heartbeats_table.sql +++ /dev/null @@ -1,79 +0,0 @@ --- Migration: Create license_heartbeats partitioned table --- Date: 2025-06-19 --- Description: Creates the license_heartbeats table with monthly partitioning - --- Create the partitioned table -CREATE TABLE IF NOT EXISTS license_heartbeats ( - id BIGSERIAL, - license_id INTEGER NOT NULL, - hardware_id VARCHAR(255) NOT NULL, - ip_address INET NOT NULL, - timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - session_data JSONB, - PRIMARY KEY (id, timestamp), - FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE -) PARTITION BY RANGE (timestamp); - --- Create indexes for better query performance -CREATE INDEX IF NOT EXISTS idx_license_heartbeats_license_id_timestamp - ON license_heartbeats (license_id, timestamp DESC); - -CREATE INDEX IF NOT EXISTS idx_license_heartbeats_timestamp - ON license_heartbeats (timestamp DESC); - -CREATE INDEX IF NOT EXISTS idx_license_heartbeats_hardware_id - ON license_heartbeats (hardware_id); - -CREATE INDEX IF NOT EXISTS idx_license_heartbeats_ip_address - ON license_heartbeats (ip_address); - --- Create partitions for current and next month -DO $$ -DECLARE - current_year INTEGER; - current_month INTEGER; - next_year INTEGER; - next_month INTEGER; - partition_name TEXT; - start_date DATE; - end_date DATE; -BEGIN - -- Get current date info - current_year := EXTRACT(YEAR FROM CURRENT_DATE); - current_month := EXTRACT(MONTH FROM CURRENT_DATE); - - -- Calculate next month - IF current_month = 12 THEN - next_year := current_year + 1; - next_month := 1; - ELSE - next_year := current_year; - next_month := current_month + 1; - END IF; - - -- Create current month partition - partition_name := 'license_heartbeats_' || current_year || '_' || LPAD(current_month::TEXT, 2, '0'); - start_date := DATE_TRUNC('month', CURRENT_DATE); - end_date := DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'; - - EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)', - partition_name, start_date, end_date); - - -- Create next month partition - partition_name := 'license_heartbeats_' || next_year || '_' || LPAD(next_month::TEXT, 2, '0'); - start_date := end_date; - end_date := start_date + INTERVAL '1 month'; - - EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)', - partition_name, start_date, end_date); - - RAISE NOTICE 'Created partitions for current and next month'; -END $$; - --- Add comment to the table -COMMENT ON TABLE license_heartbeats IS 'Stores heartbeat data from license validations for real-time monitoring'; -COMMENT ON COLUMN license_heartbeats.license_id IS 'Foreign key to licenses table'; -COMMENT ON COLUMN license_heartbeats.hardware_id IS 'Hardware identifier of the device'; -COMMENT ON COLUMN license_heartbeats.ip_address IS 'IP address from which the heartbeat was sent'; -COMMENT ON COLUMN license_heartbeats.timestamp IS 'Timestamp of the heartbeat'; -COMMENT ON COLUMN license_heartbeats.session_data IS 'Additional session data in JSON format'; \ No newline at end of file diff --git a/v2_adminpanel/migrations/remove_duplicate_api_key.sql b/v2_adminpanel/migrations/remove_duplicate_api_key.sql deleted file mode 100644 index 9c52187..0000000 --- a/v2_adminpanel/migrations/remove_duplicate_api_key.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Remove duplicate API key from client_configs table --- Since admin panel is exclusively for Account Forger, we only need system_api_key - --- Remove the api_key column from client_configs -ALTER TABLE client_configs DROP COLUMN IF EXISTS api_key; - --- Update description -COMMENT ON TABLE client_configs IS 'Configuration for Account Forger client (versions, timeouts)'; -COMMENT ON TABLE system_api_key IS 'Single API key for Account Forger authentication'; \ No newline at end of file diff --git a/v2_adminpanel/migrations/rename_test_to_fake.sql b/v2_adminpanel/migrations/rename_test_to_fake.sql deleted file mode 100644 index f813a0d..0000000 --- a/v2_adminpanel/migrations/rename_test_to_fake.sql +++ /dev/null @@ -1,48 +0,0 @@ --- Migration script to rename is_test columns to is_fake --- This separates fake/demo data from test licenses - --- 1. Rename columns in all tables -DO $$ -BEGIN - -- Rename is_test to is_fake in customers table - IF EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'customers' AND column_name = 'is_test') THEN - ALTER TABLE customers RENAME COLUMN is_test TO is_fake; - END IF; - - -- Rename is_test to is_fake in licenses table - IF EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'licenses' AND column_name = 'is_test') THEN - ALTER TABLE licenses RENAME COLUMN is_test TO is_fake; - END IF; - - -- Rename is_test to is_fake in resource_pools table - IF EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name = 'resource_pools' AND column_name = 'is_test') THEN - ALTER TABLE resource_pools RENAME COLUMN is_test TO is_fake; - END IF; -END $$; - --- 2. Rename indexes -DO $$ -BEGIN - -- Rename index for customers - IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_customers_is_test') THEN - ALTER INDEX idx_customers_is_test RENAME TO idx_customers_is_fake; - END IF; - - -- Rename index for licenses - IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_licenses_is_test') THEN - ALTER INDEX idx_licenses_is_test RENAME TO idx_licenses_is_fake; - END IF; - - -- Rename index for resource_pools - IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_resource_pools_is_test') THEN - ALTER INDEX idx_resource_pools_is_test RENAME TO idx_resource_pools_is_fake; - END IF; -END $$; - --- 3. Add comments to clarify the purpose -COMMENT ON COLUMN customers.is_fake IS 'Marks fake/demo data, not to be confused with test licenses'; -COMMENT ON COLUMN licenses.is_fake IS 'Marks fake/demo data, not to be confused with test license type'; -COMMENT ON COLUMN resource_pools.is_fake IS 'Marks fake/demo resources'; \ No newline at end of file diff --git a/v2_adminpanel/models.py b/v2_adminpanel/models.py deleted file mode 100644 index 264fbc5..0000000 --- a/v2_adminpanel/models.py +++ /dev/null @@ -1,178 +0,0 @@ -# Temporary models file - will be expanded in Phase 3 -from db import execute_query, get_db_connection, get_db_cursor -import logging - -logger = logging.getLogger(__name__) - - -def get_user_by_username(username): - """Get user from database by username""" - result = execute_query( - """ - SELECT id, username, password_hash, email, totp_secret, totp_enabled, - backup_codes, last_password_change, failed_2fa_attempts - FROM users WHERE username = %s - """, - (username,), - fetch_one=True - ) - - if result: - return { - 'id': result[0], - 'username': result[1], - 'password_hash': result[2], - 'email': result[3], - 'totp_secret': result[4], - 'totp_enabled': result[5], - 'backup_codes': result[6], - 'last_password_change': result[7], - 'failed_2fa_attempts': result[8] - } - return None - - -def get_licenses(show_fake=False): - """Get all licenses from database""" - try: - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - if show_fake: - cur.execute(""" - SELECT l.*, c.name as customer_name - FROM licenses l - LEFT JOIN customers c ON l.customer_id = c.id - ORDER BY l.created_at DESC - """) - else: - cur.execute(""" - SELECT l.*, c.name as customer_name - FROM licenses l - LEFT JOIN customers c ON l.customer_id = c.id - WHERE l.is_fake = false - ORDER BY l.created_at DESC - """) - - columns = [desc[0] for desc in cur.description] - licenses = [] - for row in cur.fetchall(): - license_dict = dict(zip(columns, row)) - licenses.append(license_dict) - return licenses - except Exception as e: - logger.error(f"Error fetching licenses: {str(e)}") - return [] - - -def get_license_by_id(license_id): - """Get a specific license by ID""" - try: - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute(""" - SELECT l.*, c.name as customer_name - FROM licenses l - LEFT JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - row = cur.fetchone() - if row: - columns = [desc[0] for desc in cur.description] - return dict(zip(columns, row)) - return None - except Exception as e: - logger.error(f"Error fetching license {license_id}: {str(e)}") - return None - - -def get_customers(show_fake=False, search=None): - """Get all customers from database""" - try: - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - query = """ - SELECT c.*, - COUNT(DISTINCT l.id) as license_count, - COUNT(DISTINCT CASE WHEN l.is_active THEN l.id END) as active_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - where_clauses = [] - params = [] - - if not show_fake: - where_clauses.append("c.is_fake = false") - - if search: - where_clauses.append("(LOWER(c.name) LIKE LOWER(%s) OR LOWER(c.email) LIKE LOWER(%s))") - search_pattern = f'%{search}%' - params.extend([search_pattern, search_pattern]) - - if where_clauses: - query += " WHERE " + " AND ".join(where_clauses) - - query += " GROUP BY c.id ORDER BY c.name" - - cur.execute(query, params) - - columns = [desc[0] for desc in cur.description] - customers = [] - for row in cur.fetchall(): - customer_dict = dict(zip(columns, row)) - customers.append(customer_dict) - return customers - except Exception as e: - logger.error(f"Error fetching customers: {str(e)}") - return [] - - -def get_customer_by_id(customer_id): - """Get a specific customer by ID""" - try: - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute(""" - SELECT c.*, - COUNT(DISTINCT l.id) as license_count, - COUNT(DISTINCT CASE WHEN l.is_active THEN l.id END) as active_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id - """, (customer_id,)) - - row = cur.fetchone() - if row: - columns = [desc[0] for desc in cur.description] - return dict(zip(columns, row)) - return None - except Exception as e: - logger.error(f"Error fetching customer {customer_id}: {str(e)}") - return None - - -def get_active_sessions(): - """Get all is_active sessions""" - try: - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute(""" - SELECT s.*, l.license_key, c.name as customer_name - FROM sessions s - JOIN licenses l ON s.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.started_at DESC - """) - - columns = [desc[0] for desc in cur.description] - sessions = [] - for row in cur.fetchall(): - session_dict = dict(zip(columns, row)) - sessions.append(session_dict) - return sessions - except Exception as e: - logger.error(f"Error fetching is_active sessions: {str(e)}") - return [] \ No newline at end of file diff --git a/v2_adminpanel/requirements.txt b/v2_adminpanel/requirements.txt deleted file mode 100644 index 64d9b85..0000000 --- a/v2_adminpanel/requirements.txt +++ /dev/null @@ -1,17 +0,0 @@ -flask -flask-session -psycopg2-binary -python-dotenv -pyopenssl -pandas -openpyxl -cryptography -apscheduler -requests -python-dateutil -bcrypt -pyotp -qrcode[pil] -PyJWT -prometheus-flask-exporter -prometheus-client diff --git a/v2_adminpanel/routes/__init__.py b/v2_adminpanel/routes/__init__.py deleted file mode 100644 index 4f9ede3..0000000 --- a/v2_adminpanel/routes/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Routes module initialization -# This module contains all Flask blueprints organized by functionality \ No newline at end of file diff --git a/v2_adminpanel/routes/__pycache__/__init__.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a81c845..0000000 Binary files a/v2_adminpanel/routes/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/v2_adminpanel/routes/__pycache__/admin_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/admin_routes.cpython-312.pyc deleted file mode 100644 index 90c7614..0000000 Binary files a/v2_adminpanel/routes/__pycache__/admin_routes.cpython-312.pyc and /dev/null differ diff --git a/v2_adminpanel/routes/__pycache__/api_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/api_routes.cpython-312.pyc deleted file mode 100644 index 1b4b531..0000000 Binary files a/v2_adminpanel/routes/__pycache__/api_routes.cpython-312.pyc and /dev/null differ diff --git a/v2_adminpanel/routes/__pycache__/auth_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/auth_routes.cpython-312.pyc deleted file mode 100644 index e6d9a8c..0000000 Binary files a/v2_adminpanel/routes/__pycache__/auth_routes.cpython-312.pyc and /dev/null differ diff --git a/v2_adminpanel/routes/__pycache__/batch_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/batch_routes.cpython-312.pyc deleted file mode 100644 index 753606e..0000000 Binary files a/v2_adminpanel/routes/__pycache__/batch_routes.cpython-312.pyc and /dev/null differ diff --git a/v2_adminpanel/routes/__pycache__/customer_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/customer_routes.cpython-312.pyc deleted file mode 100644 index 7b5b6b0..0000000 Binary files a/v2_adminpanel/routes/__pycache__/customer_routes.cpython-312.pyc and /dev/null differ diff --git a/v2_adminpanel/routes/__pycache__/export_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/export_routes.cpython-312.pyc deleted file mode 100644 index 3f0aaae..0000000 Binary files a/v2_adminpanel/routes/__pycache__/export_routes.cpython-312.pyc and /dev/null differ diff --git a/v2_adminpanel/routes/__pycache__/license_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/license_routes.cpython-312.pyc deleted file mode 100644 index c0baaf7..0000000 Binary files a/v2_adminpanel/routes/__pycache__/license_routes.cpython-312.pyc and /dev/null differ diff --git a/v2_adminpanel/routes/__pycache__/resource_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/resource_routes.cpython-312.pyc deleted file mode 100644 index 34962e3..0000000 Binary files a/v2_adminpanel/routes/__pycache__/resource_routes.cpython-312.pyc and /dev/null differ diff --git a/v2_adminpanel/routes/__pycache__/session_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/session_routes.cpython-312.pyc deleted file mode 100644 index d1abdb6..0000000 Binary files a/v2_adminpanel/routes/__pycache__/session_routes.cpython-312.pyc and /dev/null differ diff --git a/v2_adminpanel/routes/admin_routes.py b/v2_adminpanel/routes/admin_routes.py deleted file mode 100644 index 067726d..0000000 --- a/v2_adminpanel/routes/admin_routes.py +++ /dev/null @@ -1,1441 +0,0 @@ -import os -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file, jsonify, current_app -import requests - -import config -from config import DATABASE_CONFIG -from auth.decorators import login_required -from utils.audit import log_audit -from utils.backup import create_backup, restore_backup -from utils.network import get_client_ip -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from utils.export import create_excel_export, prepare_audit_export_data - -# Create Blueprint -admin_bp = Blueprint('admin', __name__) - - -def check_service_health(): - """Check health status of critical services""" - services = [] - - # License Server Health Check - license_server = { - 'name': 'License Server', - 'status': 'unknown', - 'response_time': None, - 'icon': '🔐', - 'details': None - } - - try: - start_time = datetime.now() - response = requests.get('http://license-server:8443/health', timeout=2) - response_time = (datetime.now() - start_time).total_seconds() * 1000 - - if response.status_code == 200: - license_server['status'] = 'healthy' - license_server['response_time'] = round(response_time, 1) - license_server['details'] = 'Betriebsbereit' - else: - license_server['status'] = 'unhealthy' - license_server['details'] = f'HTTP {response.status_code}' - except requests.exceptions.Timeout: - license_server['status'] = 'down' - license_server['details'] = 'Timeout - Server antwortet nicht' - except requests.exceptions.ConnectionError: - license_server['status'] = 'down' - license_server['details'] = 'Verbindung fehlgeschlagen' - except Exception as e: - license_server['status'] = 'down' - license_server['details'] = f'Fehler: {str(e)}' - - services.append(license_server) - - # PostgreSQL Health Check - postgresql = { - 'name': 'PostgreSQL', - 'status': 'unknown', - 'response_time': None, - 'icon': '🗄️', - 'details': None - } - - try: - start_time = datetime.now() - with get_db_connection() as conn: - cur = conn.cursor() - cur.execute('SELECT 1') - cur.close() - response_time = (datetime.now() - start_time).total_seconds() * 1000 - - postgresql['status'] = 'healthy' - postgresql['response_time'] = round(response_time, 1) - postgresql['details'] = 'Datenbankverbindung aktiv' - except Exception as e: - postgresql['status'] = 'down' - postgresql['details'] = f'Verbindungsfehler: {str(e)}' - - services.append(postgresql) - - # Calculate overall health - healthy_count = sum(1 for s in services if s['status'] == 'healthy') - total_count = len(services) - - return { - 'services': services, - 'healthy_count': healthy_count, - 'total_count': total_count, - 'overall_status': 'healthy' if healthy_count == total_count else ('partial' if healthy_count > 0 else 'down') - } - - -@admin_bp.route("/") -@login_required -def dashboard(): - try: - conn = get_connection() - cur = conn.cursor() - try: - # Hole Statistiken mit sicheren Defaults - # Anzahl aktiver Lizenzen (nur echte Daten, keine Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_active = true AND is_fake = false") - active_licenses = cur.fetchone()[0] if cur.rowcount > 0 else 0 - - # Anzahl Kunden (nur echte Kunden, keine Fake-Kunden) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_fake = false") - total_customers = cur.fetchone()[0] if cur.rowcount > 0 else 0 - - # Testdaten separat zählen für optionale Anzeige - cur.execute("SELECT COUNT(*) FROM customers WHERE is_fake = true") - fake_customers_count = cur.fetchone()[0] if cur.rowcount > 0 else 0 - - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_fake = true") - test_licenses_count = cur.fetchone()[0] if cur.rowcount > 0 else 0 - - # Anzahl aktiver Sessions (Admin-Panel) - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = true") - active_sessions = cur.fetchone()[0] if cur.rowcount > 0 else 0 - - # Aktive Nutzung (Kunden-Software) - Lizenzen mit Heartbeats in den letzten 15 Minuten - active_usage = 0 - try: - # Prüfe ob Tabelle existiert - cur.execute(""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'license_heartbeats' - ) - """) - table_exists = cur.fetchone()[0] - - if table_exists: - cur.execute(""" - SELECT COUNT(DISTINCT lh.license_id) - FROM license_heartbeats lh - JOIN licenses l ON l.id = lh.license_id - WHERE lh.timestamp > NOW() - INTERVAL '15 minutes' - AND l.is_fake = false - """) - active_usage = cur.fetchone()[0] if cur.rowcount > 0 else 0 - except Exception as e: - # Bei Fehler einfach 0 verwenden - current_app.logger.warning(f"Could not get active usage: {str(e)}") - # Rollback der fehlgeschlagenen Transaktion - conn.rollback() - # Neue Transaktion starten - conn = get_connection() - cur = conn.cursor() - - # Top 10 Lizenzen - nur echte Lizenzen - cur.execute(""" - SELECT - l.license_key, - c.name as customer_name, - COUNT(s.id) as session_count - FROM licenses l - LEFT JOIN customers c ON l.customer_id = c.id - LEFT JOIN sessions s ON l.id = s.license_id - WHERE l.is_fake = false AND c.is_fake = false - GROUP BY l.license_key, c.name - ORDER BY session_count DESC - LIMIT 10 - """) - top_licenses = cur.fetchall() if cur.rowcount > 0 else [] - - # Letzte Aktivitäten - vereinfacht - cur.execute(""" - SELECT - id, - timestamp, - username, - action, - additional_info - FROM audit_log - ORDER BY timestamp DESC - LIMIT 10 - """) - recent_activities = cur.fetchall() if cur.rowcount > 0 else [] - - # Lizenztypen zählen (nur echte Lizenzen) - cur.execute(""" - SELECT - COUNT(CASE WHEN license_type = 'full' THEN 1 END) as full_licenses, - COUNT(CASE WHEN license_type = 'test' THEN 1 END) as test_licenses - FROM licenses - WHERE is_fake = false - """) - license_types = cur.fetchone() - full_licenses = license_types[0] if license_types and license_types[0] is not None else 0 - test_version_licenses = license_types[1] if license_types and license_types[1] is not None else 0 - - # Lizenzstatus zählen (nur echte Lizenzen) - cur.execute(""" - SELECT - COUNT(CASE WHEN is_active = true AND (valid_until IS NULL OR valid_until > NOW()) THEN 1 END) as active, - COUNT(CASE WHEN valid_until < NOW() THEN 1 END) as expired, - COUNT(CASE WHEN is_active = false THEN 1 END) as inactive - FROM licenses - WHERE is_fake = false - """) - license_status = cur.fetchone() - active_licenses_count = license_status[0] if license_status and license_status[0] else 0 - expired_licenses = license_status[1] if license_status and license_status[1] else 0 - inactive_licenses = license_status[2] if license_status and license_status[2] else 0 - - # Bald ablaufende Lizenzen (nur echte Lizenzen) - cur.execute(""" - SELECT - l.id, - l.license_key, - c.name as customer_name, - l.valid_until, - EXTRACT(DAY FROM (l.valid_until - NOW())) as days_remaining - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_fake = false - AND c.is_fake = false - AND l.is_active = true - AND l.valid_until IS NOT NULL - AND l.valid_until > NOW() - AND l.valid_until < NOW() + INTERVAL '30 days' - ORDER BY l.valid_until ASC - LIMIT 10 - """) - expiring_licenses = cur.fetchall() if cur.rowcount > 0 else [] - - # Zuletzt erstellte Lizenzen (nur echte Lizenzen) - cur.execute(""" - SELECT - l.id, - l.license_key, - c.name as customer_name, - l.created_at, - CASE - WHEN l.is_active = false THEN 'deaktiviert' - WHEN l.valid_until < NOW() THEN 'abgelaufen' - WHEN l.valid_until < NOW() + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_fake = false - AND c.is_fake = false - ORDER BY l.created_at DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() if cur.rowcount > 0 else [] - - # Stats Objekt für Template erstellen - stats = { - 'total_customers': total_customers, - 'total_licenses': active_licenses, # This was already filtered for is_fake = false - 'active_sessions': active_sessions, # Admin-Panel Sessions - 'active_usage': active_usage, # Aktive Kunden-Nutzung - 'active_licenses': active_licenses_count, - 'full_licenses': full_licenses or 0, - 'fake_licenses': test_version_licenses or 0, # Test versions (license_type='test'), not fake data - 'fake_data_count': test_licenses_count, # Actual test data count (is_fake=true) - 'fake_customers_count': fake_customers_count, - 'fake_resources_count': 0, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'last_backup': None, - 'security_level': 'success', - 'security_level_text': 'Sicher', - 'blocked_ips_count': 0, - 'failed_attempts_today': 0, - 'recent_security_events': [], - 'expiring_licenses': expiring_licenses, - 'recent_licenses': recent_licenses - } - - # Resource Pool Statistics (nur echte Ressourcen, keine Testdaten) - resource_stats = {} - resource_types = ['domain', 'ipv4', 'phone'] - - for resource_type in resource_types: - try: - cur.execute(""" - SELECT - COUNT(CASE WHEN status = 'available' THEN 1 END) as available, - COUNT(CASE WHEN status = 'allocated' THEN 1 END) as allocated, - COUNT(CASE WHEN status = 'quarantine' THEN 1 END) as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE resource_type = %s AND is_fake = false - """, (resource_type,)) - - result = cur.fetchone() - if result: - available = result[0] or 0 - allocated = result[1] or 0 - quarantine = result[2] or 0 - total = result[3] or 0 - available_percent = int((available / total * 100)) if total > 0 else 0 - - resource_stats[resource_type] = { - 'available': available, - 'allocated': allocated, - 'quarantine': quarantine, - 'total': total, - 'available_percent': available_percent - } - else: - resource_stats[resource_type] = { - 'available': 0, - 'allocated': 0, - 'quarantine': 0, - 'total': 0, - 'available_percent': 0 - } - except Exception as e: - # Falls die Tabelle nicht existiert - current_app.logger.warning(f"Could not get resource stats for {resource_type}: {str(e)}") - resource_stats[resource_type] = { - 'available': 0, - 'allocated': 0, - 'quarantine': 0, - 'total': 0, - 'available_percent': 0 - } - # Reset the connection after error - conn.rollback() - - # Count test resources separately - try: - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_fake = true") - fake_resources_count = cur.fetchone()[0] if cur.rowcount > 0 else 0 - stats['fake_resources_count'] = fake_resources_count - except: - pass - - license_distribution = [] - hourly_sessions = [] - - # Get service health status - service_health = check_service_health() - - return render_template('dashboard.html', - stats=stats, - top_licenses=top_licenses, - recent_activities=recent_activities, - license_distribution=license_distribution, - hourly_sessions=hourly_sessions, - resource_stats=resource_stats, - service_health=service_health, - username=session.get('username')) - finally: - cur.close() - conn.close() - - except Exception as e: - current_app.logger.error(f"Dashboard error: {str(e)}") - current_app.logger.error(f"Error type: {type(e).__name__}") - import traceback - current_app.logger.error(f"Traceback: {traceback.format_exc()}") - flash(f'Dashboard-Fehler: {str(e)}', 'error') - return redirect(url_for('auth.login')) - - -@admin_bp.route("/audit") -@login_required -def audit_log(): - page = request.args.get('page', 1, type=int) - per_page = 50 - search = request.args.get('search', '') - filter_user = request.args.get('user', '') - action_filter = request.args.get('action', '') - entity_filter = request.args.get('entity', '') - - conn = get_connection() - cur = conn.cursor() - - try: - # Base query - query = """ - SELECT - id, - timestamp AT TIME ZONE 'Europe/Berlin' as timestamp, - username, - action, - entity_type, - entity_id, - old_values::text, - new_values::text, - ip_address, - user_agent, - additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - # Suchfilter - if search: - query += """ AND ( - username ILIKE %s OR - action ILIKE %s OR - entity_type ILIKE %s OR - additional_info ILIKE %s OR - ip_address ILIKE %s - )""" - search_param = f"%{search}%" - params.extend([search_param] * 5) - - # Action Filter - if action_filter: - query += " AND action = %s" - params.append(action_filter) - - # Entity Filter - if entity_filter: - query += " AND entity_type = %s" - params.append(entity_filter) - - # User Filter - if filter_user: - query += " AND username = %s" - params.append(filter_user) - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as filtered" - cur.execute(count_query, params) - total_count = cur.fetchone()[0] - - # Add pagination - query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s" - params.extend([per_page, (page - 1) * per_page]) - - cur.execute(query, params) - logs = cur.fetchall() - - # Get unique actions and entities for filters - cur.execute("SELECT DISTINCT action FROM audit_log ORDER BY action") - actions = [row[0] for row in cur.fetchall()] - - cur.execute("SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type") - entities = [row[0] for row in cur.fetchall()] - - # Pagination info - total_pages = (total_count + per_page - 1) // per_page - - # Convert to dictionaries for easier template access - audit_logs = [] - for log in logs: - # Parse JSON strings for old_values and new_values - old_values = None - new_values = None - try: - if log[6]: - import json - old_values = json.loads(log[6]) - except: - old_values = log[6] - try: - if log[7]: - import json - new_values = json.loads(log[7]) - except: - new_values = log[7] - - audit_logs.append({ - 'id': log[0], - 'timestamp': log[1], - 'username': log[2], - 'action': log[3], - 'entity_type': log[4], - 'entity_id': log[5], - 'old_values': old_values, - 'new_values': new_values, - 'ip_address': log[8], - 'user_agent': log[9], - 'additional_info': log[10] - }) - - return render_template('audit_log.html', - logs=audit_logs, - page=page, - total_pages=total_pages, - total=total_count, - search=search, - filter_user=filter_user, - action_filter=action_filter, - entity_filter=entity_filter, - actions=actions, - entities=entities, - username=session.get('username')) - - finally: - cur.close() - conn.close() - - -@admin_bp.route("/backups") -@login_required -def backups(): - conn = get_connection() - cur = conn.cursor() - - try: - # Hole alle Backups - cur.execute(""" - SELECT - id, - filename, - created_at AT TIME ZONE 'Europe/Berlin' as created_at, - filesize, - backup_type, - status, - created_by, - duration_seconds, - tables_count, - records_count, - error_message, - is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - # Prüfe ob Dateien noch existieren - backups_with_status = [] - for backup in backups: - backup_dict = { - 'id': backup[0], - 'filename': backup[1], - 'created_at': backup[2], - 'filesize': backup[3], - 'backup_type': backup[4], - 'status': backup[5], - 'created_by': backup[6], - 'duration_seconds': backup[7], - 'tables_count': backup[8], - 'records_count': backup[9], - 'error_message': backup[10], - 'is_encrypted': backup[11], - 'file_exists': False - } - - # Prüfe ob Datei existiert - if backup[1]: # filename - filepath = config.BACKUP_DIR / backup[1] - backup_dict['file_exists'] = filepath.exists() - - backups_with_status.append(backup_dict) - - return render_template('backups.html', - backups=backups_with_status, - username=session.get('username')) - - finally: - cur.close() - conn.close() - - -@admin_bp.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Manuelles Backup erstellen""" - from flask import jsonify - success, result = create_backup(backup_type="manual", created_by=session.get('username')) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - - -@admin_bp.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Backup wiederherstellen""" - from flask import jsonify - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': f'Wiederherstellung fehlgeschlagen: {message}' - }), 500 - - -@admin_bp.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Backup herunterladen""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Backup-Info - cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,)) - result = cur.fetchone() - - if not result: - flash('Backup nicht gefunden', 'error') - return redirect(url_for('admin.backups')) - - filename, filepath = result - filepath = Path(filepath) - - if not filepath.exists(): - flash('Backup-Datei nicht gefunden', 'error') - return redirect(url_for('admin.backups')) - - # Audit-Log - log_audit('BACKUP_DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - - finally: - cur.close() - conn.close() - - -@admin_bp.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Backup löschen""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Backup-Info - cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,)) - result = cur.fetchone() - - if not result: - return jsonify({'success': False, 'message': 'Backup nicht gefunden'}), 404 - - filename, filepath = result - filepath = Path(filepath) - - # Lösche Datei wenn vorhanden - if filepath.exists(): - try: - filepath.unlink() - except Exception as e: - return jsonify({'success': False, 'message': f'Fehler beim Löschen der Datei: {str(e)}'}), 500 - - # Lösche Datenbank-Eintrag - cur.execute("DELETE FROM backup_history WHERE id = %s", (backup_id,)) - conn.commit() - - # Audit-Log - log_audit('BACKUP_DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({'success': True, 'message': 'Backup erfolgreich gelöscht'}) - - except Exception as e: - conn.rollback() - return jsonify({'success': False, 'message': str(e)}), 500 - - finally: - cur.close() - conn.close() - - -@admin_bp.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt gesperrte IP-Adressen""" - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute(""" - SELECT - ip_address, - attempt_count, - last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt, - blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - ORDER BY blocked_until DESC - """) - blocked = cur.fetchall() - - # Alle Login-Versuche (auch nicht gesperrte) - cur.execute(""" - SELECT - ip_address, - attempt_count, - last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt, - blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - ORDER BY last_attempt DESC - LIMIT 100 - """) - all_attempts = cur.fetchall() - - return render_template('blocked_ips.html', - blocked_ips=blocked, - all_attempts=all_attempts, - username=session.get('username')) - - finally: - cur.close() - conn.close() - - -@admin_bp.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if not ip_address: - flash('Keine IP-Adresse angegeben', 'error') - return redirect(url_for('admin.blocked_ips')) - - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - if cur.rowcount > 0: - conn.commit() - flash(f'IP-Adresse {ip_address} wurde entsperrt', 'success') - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP-Adresse entsperrt: {ip_address}") - else: - flash(f'IP-Adresse {ip_address} nicht gefunden', 'warning') - - except Exception as e: - conn.rollback() - flash(f'Fehler beim Entsperren: {str(e)}', 'error') - - finally: - cur.close() - conn.close() - - return redirect(url_for('admin.blocked_ips')) - - -@admin_bp.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche""" - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute("DELETE FROM login_attempts") - count = cur.rowcount - conn.commit() - - flash(f'{count} Login-Versuche wurden gelöscht', 'success') - log_audit('CLEAR_LOGIN_ATTEMPTS', 'security', - additional_info=f"{count} Login-Versuche gelöscht") - - except Exception as e: - conn.rollback() - flash(f'Fehler beim Löschen: {str(e)}', 'error') - - finally: - cur.close() - conn.close() - - return redirect(url_for('admin.blocked_ips')) - - -# ===================== LICENSE SERVER MONITORING ROUTES ===================== - -@admin_bp.route("/lizenzserver/monitor") -@login_required -def license_monitor(): - """Redirect to new analytics page""" - return redirect(url_for('monitoring.analytics')) - - -@admin_bp.route("/lizenzserver/analytics") -@login_required -def license_analytics(): - """License usage analytics""" - try: - conn = get_connection() - cur = conn.cursor() - - # Time range from query params - days = int(request.args.get('days', 30)) - - # Usage trends over time - cur.execute(""" - SELECT DATE(timestamp) as date, - COUNT(DISTINCT license_id) as unique_licenses, - COUNT(DISTINCT hardware_id) as unique_devices, - COUNT(*) as total_validations - FROM license_heartbeats - WHERE timestamp > NOW() - INTERVAL '%s days' - GROUP BY date - ORDER BY date - """, (days,)) - usage_trends = cur.fetchall() - - # License performance metrics - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name, - COUNT(DISTINCT lh.hardware_id) as device_count, - l.max_devices, - COUNT(*) as total_validations, - COUNT(DISTINCT DATE(lh.timestamp)) as active_days, - MIN(lh.timestamp) as first_seen, - MAX(lh.timestamp) as last_seen - FROM licenses l - JOIN customers c ON l.customer_id = c.id - LEFT JOIN license_heartbeats lh ON l.id = lh.license_id - WHERE lh.timestamp > NOW() - INTERVAL '%s days' - GROUP BY l.id, l.license_key, c.name, l.max_devices - ORDER BY total_validations DESC - """, (days,)) - license_metrics = cur.fetchall() - - # Device distribution - cur.execute(""" - SELECT l.max_devices as limit, - COUNT(*) as license_count, - AVG(device_count) as avg_usage - FROM licenses l - LEFT JOIN ( - SELECT license_id, COUNT(DISTINCT hardware_id) as device_count - FROM license_heartbeats - WHERE timestamp > NOW() - INTERVAL '30 days' - GROUP BY license_id - ) usage ON l.id = usage.license_id - WHERE l.is_active = true - GROUP BY l.max_devices - ORDER BY l.max_devices - """) - device_distribution = cur.fetchall() - - # Revenue analysis - cur.execute(""" - SELECT l.license_type, - COUNT(DISTINCT l.id) as license_count, - COUNT(DISTINCT CASE WHEN lh.license_id IS NOT NULL THEN l.id END) as active_licenses, - COUNT(DISTINCT lh.hardware_id) as total_devices - FROM licenses l - LEFT JOIN license_heartbeats lh ON l.id = lh.license_id - AND lh.timestamp > NOW() - INTERVAL '%s days' - GROUP BY l.license_type - """, (days,)) - revenue_analysis = cur.fetchall() - - return render_template('license_analytics.html', - days=days, - usage_trends=usage_trends, - license_metrics=license_metrics, - device_distribution=device_distribution, - revenue_analysis=revenue_analysis - ) - - except Exception as e: - flash(f'Fehler beim Laden der Analytics-Daten: {str(e)}', 'error') - return render_template('license_analytics.html', days=30) - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() - - -@admin_bp.route("/lizenzserver/anomalies") -@login_required -def license_anomalies(): - """Redirect to unified monitoring page""" - return redirect(url_for('monitoring.unified_monitoring')) - - -@admin_bp.route("/lizenzserver/anomaly//resolve", methods=["POST"]) -@login_required -def resolve_anomaly(anomaly_id): - """Resolve an anomaly""" - try: - conn = get_connection() - cur = conn.cursor() - - action_taken = request.form.get('action_taken', '') - - cur.execute(""" - UPDATE anomaly_detections - SET resolved = true, - resolved_at = NOW(), - resolved_by = %s, - action_taken = %s - WHERE id = %s - """, (session.get('username'), action_taken, str(anomaly_id))) - - conn.commit() - - flash('Anomalie wurde als behoben markiert', 'success') - log_audit('RESOLVE_ANOMALY', 'license_server', entity_id=str(anomaly_id), - additional_info=f"Action: {action_taken}") - - except Exception as e: - if 'conn' in locals(): - conn.rollback() - flash(f'Fehler beim Beheben der Anomalie: {str(e)}', 'error') - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() - - return redirect(url_for('admin.license_anomalies')) - - -@admin_bp.route("/lizenzserver/config") -@login_required -def license_config(): - """License server configuration""" - try: - conn = get_connection() - cur = conn.cursor() - - # Get client configuration - cur.execute(""" - SELECT id, client_name, heartbeat_interval, session_timeout, - current_version, minimum_version, created_at, updated_at - FROM client_configs - WHERE client_name = 'Account Forger' - """) - client_config = cur.fetchone() - - # Get active sessions - table doesn't exist, use empty list - active_sessions = [] - - # Get feature flags - table doesn't exist, use empty list - feature_flags = [] - - # Get rate limits - table doesn't exist, use empty list - rate_limits = [] - - # Get system API key - cur.execute(""" - SELECT api_key, created_at, regenerated_at, last_used_at, - usage_count, created_by, regenerated_by - FROM system_api_key - WHERE id = 1 - """) - api_key_data = cur.fetchone() - - if api_key_data: - system_api_key = { - 'api_key': api_key_data[0], - 'created_at': api_key_data[1], - 'regenerated_at': api_key_data[2], - 'last_used_at': api_key_data[3], - 'usage_count': api_key_data[4], - 'created_by': api_key_data[5], - 'regenerated_by': api_key_data[6] - } - else: - system_api_key = None - - return render_template('license_config.html', - client_config=client_config, - active_sessions=active_sessions, - feature_flags=feature_flags, - rate_limits=rate_limits, - system_api_key=system_api_key - ) - - except Exception as e: - import traceback - current_app.logger.error(f"Error in license_config: {str(e)}") - current_app.logger.error(traceback.format_exc()) - flash(f'Fehler beim Laden der Konfiguration: {str(e)}', 'error') - return render_template('license_config.html') - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() - - -@admin_bp.route("/lizenzserver/config/feature-flag/", methods=["POST"]) -@login_required -def update_feature_flag(flag_id): - """Update feature flag settings""" - try: - conn = get_connection() - cur = conn.cursor() - - is_enabled = request.form.get('is_enabled') == 'on' - rollout_percentage = int(request.form.get('rollout_percentage', 0)) - - cur.execute(""" - UPDATE feature_flags - SET is_enabled = %s, - rollout_percentage = %s, - updated_at = NOW() - WHERE id = %s - """, (is_enabled, rollout_percentage, flag_id)) - - conn.commit() - - flash('Feature Flag wurde aktualisiert', 'success') - log_audit('UPDATE_FEATURE_FLAG', 'license_server', entity_id=flag_id) - - except Exception as e: - if 'conn' in locals(): - conn.rollback() - flash(f'Fehler beim Aktualisieren: {str(e)}', 'error') - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() - - return redirect(url_for('admin.license_config')) - - - -@admin_bp.route("/lizenzserver/config/update", methods=["POST"]) -@login_required -def update_client_config(): - """Update client configuration""" - if session.get('username') not in ['rac00n', 'w@rh@mm3r']: - flash('Zugriff verweigert', 'error') - return redirect(url_for('admin.dashboard')) - - try: - conn = get_connection() - cur = conn.cursor() - - # Update configuration - cur.execute(""" - UPDATE client_configs - SET current_version = %s, - minimum_version = %s, - heartbeat_interval = %s, - session_timeout = %s, - updated_at = CURRENT_TIMESTAMP - WHERE client_name = 'Account Forger' - """, ( - request.form.get('current_version'), - request.form.get('minimum_version'), - 30, # heartbeat_interval - fixed - 60 # session_timeout - fixed - )) - - conn.commit() - flash('Client-Konfiguration wurde aktualisiert', 'success') - - # Log action - log_action( - username=session.get('username'), - action='UPDATE', - entity_type='client_config', - entity_id=1, - new_values={ - 'current_version': request.form.get('current_version'), - 'minimum_version': request.form.get('minimum_version') - } - ) - - except Exception as e: - flash(f'Fehler beim Aktualisieren: {str(e)}', 'error') - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() - - return redirect(url_for('admin.license_config')) - - -@admin_bp.route("/lizenzserver/sessions") -@login_required -def license_sessions(): - """Show active license sessions""" - try: - conn = get_connection() - cur = conn.cursor() - - # Get active sessions - cur.execute(""" - SELECT ls.id, ls.session_token, l.license_key, c.name as customer_name, - ls.hardware_id, ls.ip_address, ls.client_version, - ls.started_at AT TIME ZONE 'Europe/Berlin' as started_at, - ls.last_heartbeat AT TIME ZONE 'Europe/Berlin' as last_heartbeat, - EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - ls.last_heartbeat)) as seconds_since_heartbeat - FROM license_sessions ls - JOIN licenses l ON ls.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - ORDER BY ls.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Get session history (last 24h) - cur.execute(""" - SELECT sh.id, l.license_key, c.name as customer_name, - sh.hardware_id, sh.ip_address, sh.client_version, - sh.started_at AT TIME ZONE 'Europe/Berlin' as started_at, - sh.ended_at AT TIME ZONE 'Europe/Berlin' as ended_at, - sh.end_reason, - EXTRACT(EPOCH FROM (sh.ended_at - sh.started_at)) as duration_seconds - FROM session_history sh - JOIN licenses l ON sh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE sh.ended_at > CURRENT_TIMESTAMP - INTERVAL '24 hours' - ORDER BY sh.ended_at DESC - LIMIT 100 - """) - history = cur.fetchall() - - return render_template('license_sessions.html', - active_sessions=sessions, - session_history=history) - - except Exception as e: - flash(f'Fehler beim Laden der Sessions: {str(e)}', 'error') - return render_template('license_sessions.html') - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() - - -@admin_bp.route("/lizenzserver/sessions//terminate", methods=["POST"]) -@login_required -def terminate_session(session_id): - """Force terminate a session""" - if session.get('username') not in ['rac00n', 'w@rh@mm3r']: - flash('Zugriff verweigert', 'error') - return redirect(url_for('admin.license_sessions')) - - try: - conn = get_connection() - cur = conn.cursor() - - # Get session info - cur.execute(""" - SELECT license_id, hardware_id, ip_address, client_version, started_at - FROM license_sessions - WHERE id = %s - """, (session_id,)) - session_info = cur.fetchone() - - if session_info: - # 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, 'forced') - """, session_info) - - # Delete session - cur.execute("DELETE FROM license_sessions WHERE id = %s", (session_id,)) - - conn.commit() - flash('Session wurde beendet', 'success') - - # Log action - log_action( - username=session.get('username'), - action='TERMINATE_SESSION', - entity_type='license_session', - entity_id=session_id, - additional_info={'hardware_id': session_info[1]} - ) - else: - flash('Session nicht gefunden', 'error') - - except Exception as e: - flash(f'Fehler beim Beenden der Session: {str(e)}', 'error') - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() - - return redirect(url_for('admin.license_sessions')) - - -@admin_bp.route("/api/admin/lizenzserver/live-stats") -@login_required -def license_live_stats(): - """API endpoint for live statistics (for AJAX updates)""" - try: - conn = get_connection() - cur = conn.cursor() - - # Get real-time stats - cur.execute(""" - SELECT COUNT(DISTINCT license_id) as active_licenses, - COUNT(*) as validations_per_minute, - COUNT(DISTINCT hardware_id) as active_devices - FROM license_heartbeats - WHERE timestamp > NOW() - INTERVAL '1 minute' - """) - stats = cur.fetchone() - - # Get active sessions count - cur.execute(""" - SELECT COUNT(*) FROM license_sessions - """) - active_count = cur.fetchone()[0] - - # Get latest sessions - cur.execute(""" - SELECT ls.id, l.license_key, c.name as customer_name, - ls.client_version, ls.last_heartbeat AT TIME ZONE 'Europe/Berlin' as last_heartbeat, - EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - ls.last_heartbeat)) as seconds_since - FROM license_sessions ls - JOIN licenses l ON ls.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - ORDER BY ls.last_heartbeat DESC - LIMIT 5 - """) - latest_sessions = cur.fetchall() - - return jsonify({ - 'active_licenses': active_count, - 'validations_per_minute': stats[1] or 0, - 'active_devices': stats[2] or 0, - 'latest_sessions': [ - { - 'customer_name': s[2], - 'version': s[3], - 'last_heartbeat': s[4].strftime('%H:%M:%S'), - 'seconds_since': int(s[5]) - } for s in latest_sessions - ] - }) - - except Exception as e: - return jsonify({'error': str(e)}), 500 - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() - - -@admin_bp.route("/api/admin/license/auth-token") -@login_required -def get_analytics_token(): - """Get JWT token for accessing Analytics Service""" - import jwt - from datetime import datetime, timedelta - - # Generate a short-lived token for the analytics service - payload = { - 'sub': session.get('user_id', 'admin'), - 'type': 'analytics_access', - 'exp': datetime.utcnow() + timedelta(hours=1), - 'iat': datetime.utcnow() - } - - # Use the same secret as configured in the analytics service - jwt_secret = os.environ.get('JWT_SECRET', 'your-secret-key') - token = jwt.encode(payload, jwt_secret, algorithm='HS256') - - return jsonify({'token': token}) - - -# ===================== API KEY MANAGEMENT ===================== - -@admin_bp.route("/api-key/regenerate", methods=["POST"]) -@login_required -def regenerate_api_key(): - """Regenerate the system API key""" - import string - import random - - conn = get_connection() - cur = conn.cursor() - - try: - # Generate new API key - year_part = datetime.now().strftime('%Y') - random_part = ''.join(random.choices(string.ascii_uppercase + string.digits, k=32)) - new_api_key = f"AF-{year_part}-{random_part}" - - # Update the API key - cur.execute(""" - UPDATE system_api_key - SET api_key = %s, - regenerated_at = CURRENT_TIMESTAMP, - regenerated_by = %s - WHERE id = 1 - """, (new_api_key, session.get('username'))) - - conn.commit() - - flash('API Key wurde erfolgreich regeneriert', 'success') - - # Log action - log_audit('API_KEY_REGENERATED', 'system_api_key', 1, - additional_info="API Key regenerated") - - except Exception as e: - conn.rollback() - flash(f'Fehler beim Regenerieren des API Keys: {str(e)}', 'error') - - finally: - cur.close() - conn.close() - - return redirect(url_for('admin.license_config')) - - -@admin_bp.route("/test-api-key") -@login_required -def test_api_key(): - """Test route to check API key in database""" - try: - conn = get_connection() - cur = conn.cursor() - - # Test if table exists - cur.execute(""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'system_api_key' - ); - """) - table_exists = cur.fetchone()[0] - - # Get API key if table exists - api_key = None - if table_exists: - cur.execute("SELECT api_key FROM system_api_key WHERE id = 1;") - result = cur.fetchone() - if result: - api_key = result[0] - - return jsonify({ - 'table_exists': table_exists, - 'api_key': api_key, - 'database': DATABASE_CONFIG['dbname'] - }) - - except Exception as e: - return jsonify({ - 'error': str(e), - 'database': DATABASE_CONFIG.get('dbname', 'unknown') - }) - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() - - -@admin_bp.route("/test-license-types") -@login_required -def test_license_types(): - """Test route to check license type counts""" - try: - conn = get_connection() - cur = conn.cursor() - - # Count license types - cur.execute(""" - SELECT - COUNT(CASE WHEN license_type = 'full' THEN 1 END) as full_licenses, - COUNT(CASE WHEN license_type = 'test' THEN 1 END) as test_licenses, - COUNT(*) as total_licenses - FROM licenses - WHERE is_fake = false - """) - result = cur.fetchone() - - # Count all licenses by type - cur.execute(""" - SELECT license_type, COUNT(*) as count - FROM licenses - GROUP BY license_type - ORDER BY license_type - """) - all_types = cur.fetchall() - - return jsonify({ - 'full_licenses': result[0] if result and result[0] is not None else 0, - 'test_licenses': result[1] if result and result[1] is not None else 0, - 'total_non_fake': result[2] if result and result[2] is not None else 0, - 'all_license_types': [{'type': row[0], 'count': row[1]} for row in all_types] if all_types else [] - }) - - except Exception as e: - return jsonify({ - 'error': str(e) - }) - finally: - if 'cur' in locals(): - cur.close() - if 'conn' in locals(): - conn.close() - - -@admin_bp.route("/admin/licenses/check-expiration", methods=["POST"]) -@login_required -def check_license_expiration(): - """Manually trigger license expiration check""" - if session.get('username') not in ['rac00n', 'w@rh@mm3r']: - return jsonify({'error': 'Zugriff verweigert'}), 403 - - try: - from scheduler import deactivate_expired_licenses - deactivate_expired_licenses() - - flash('License expiration check completed successfully', 'success') - log_audit('MANUAL_LICENSE_EXPIRATION_CHECK', 'system', - additional_info="Manual license expiration check triggered") - - return jsonify({'success': True, 'message': 'License expiration check completed'}) - - except Exception as e: - current_app.logger.error(f"Error in manual license expiration check: {str(e)}") - return jsonify({'error': str(e)}), 500 diff --git a/v2_adminpanel/routes/api_routes.py b/v2_adminpanel/routes/api_routes.py deleted file mode 100644 index e4f442f..0000000 --- a/v2_adminpanel/routes/api_routes.py +++ /dev/null @@ -1,1021 +0,0 @@ -import logging -from datetime import datetime -from zoneinfo import ZoneInfo -from flask import Blueprint, request, jsonify, session - -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 generate_license_key -from db import get_connection, get_db_connection, get_db_cursor -from models import get_license_by_id, get_customers - -# Create Blueprint -api_bp = Blueprint('api', __name__, url_prefix='/api') - - -@api_bp.route("/customers", methods=["GET"]) -@login_required -def api_customers(): - """API endpoint for customer search (used by Select2)""" - search = request.args.get('q', '').strip() - page = int(request.args.get('page', 1)) - per_page = 20 - - try: - # Get all customers (with optional search) - customers = get_customers(show_fake=True, search=search) - - # Pagination - start = (page - 1) * per_page - end = start + per_page - paginated_customers = customers[start:end] - - # Format for Select2 - results = [] - for customer in paginated_customers: - results.append({ - 'id': customer['id'], - 'text': f"{customer['name']} ({customer['email'] or 'keine E-Mail'})", - 'is_fake': customer.get('is_fake', False) # Include the is_fake field - }) - - return jsonify({ - 'results': results, - 'pagination': { - 'more': len(customers) > end - } - }) - - except Exception as e: - logging.error(f"Error in api_customers: {str(e)}") - return jsonify({'error': 'Fehler beim Laden der Kunden'}), 500 - - -@api_bp.route("/license//toggle", methods=["POST"]) -@login_required -def toggle_license(license_id): - """Toggle license is_active status""" - conn = get_connection() - cur = conn.cursor() - - try: - # Get current status - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - new_status = not license_data['is_active'] - - # Update status - cur.execute("UPDATE licenses SET is_active = %s WHERE id = %s", (new_status, license_id)) - conn.commit() - - # Log change - log_audit('TOGGLE', 'license', license_id, - old_values={'is_active': license_data['is_active']}, - new_values={'is_active': new_status}) - - return jsonify({'success': True, 'is_active': new_status}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Umschalten der Lizenz: {str(e)}", exc_info=True) - return jsonify({'error': 'Fehler beim Umschalten der Lizenz'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Aktiviere mehrere Lizenzen gleichzeitig""" - data = request.get_json() - license_ids = data.get('license_ids', []) - - if not license_ids: - return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Update all selected licenses - cur.execute(""" - UPDATE licenses - SET is_active = true - WHERE id = ANY(%s) AND is_active = false - RETURNING id - """, (license_ids,)) - - updated_ids = [row[0] for row in cur.fetchall()] - conn.commit() - - # Log changes - for license_id in updated_ids: - log_audit('BULK_ACTIVATE', 'license', license_id, - new_values={'is_active': True}) - - return jsonify({ - 'success': True, - 'updated_count': len(updated_ids) - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Bulk-Aktivieren: {str(e)}") - return jsonify({'error': 'Fehler beim Aktivieren der Lizenzen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deaktiviere mehrere Lizenzen gleichzeitig""" - data = request.get_json() - license_ids = data.get('license_ids', []) - - if not license_ids: - return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Update all selected licenses - cur.execute(""" - UPDATE licenses - SET is_active = false - WHERE id = ANY(%s) AND is_active = true - RETURNING id - """, (license_ids,)) - - updated_ids = [row[0] for row in cur.fetchall()] - conn.commit() - - # Log changes - for license_id in updated_ids: - log_audit('BULK_DEACTIVATE', 'license', license_id, - new_values={'is_active': False}) - - return jsonify({ - 'success': True, - 'updated_count': len(updated_ids) - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Bulk-Deaktivieren: {str(e)}") - return jsonify({'error': 'Fehler beim Deaktivieren der Lizenzen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle Geräte einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Lizenz-Info - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Hole registrierte Geräte - cur.execute(""" - SELECT - dr.id, - dr.hardware_id, - dr.device_name, - dr.device_type, - dr.first_seen as registration_date, - dr.last_seen, - dr.is_active, - dr.operating_system, - dr.ip_address, - (SELECT COUNT(*) FROM sessions s - WHERE s.license_key = l.license_key - AND s.hardware_id = dr.hardware_id - AND s.is_active = true) as active_sessions - FROM device_registrations dr - JOIN licenses l ON dr.license_id = l.id - WHERE l.license_key = %s - ORDER BY dr.first_seen DESC - """, (license_data['license_key'],)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2], - 'device_type': row[3], - 'registration_date': row[4].isoformat() if row[4] else None, - 'last_seen': row[5].isoformat() if row[5] else None, - 'is_active': row[6], - 'operating_system': row[7] or 'Unknown', - 'ip_address': row[8] or 'Unknown', - 'active_sessions': row[9], - 'first_seen': row[4].isoformat() if row[4] else None - }) - - return jsonify({ - 'success': True, - 'license_key': license_data['license_key'], - 'device_limit': license_data['device_limit'], - 'devices': devices, - 'device_count': len(devices), - 'active_count': len([d for d in devices if d['is_active']]) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'error': 'Fehler beim Abrufen der Geräte'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//register-device", methods=["POST"]) -@login_required -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - data = request.get_json() - - hardware_id = data.get('hardware_id') - device_name = data.get('device_name') - device_type = data.get('device_type', 'unknown') - - if not hardware_id or not device_name: - return jsonify({'error': 'Geräte-ID und Name erforderlich'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Lizenz-Info - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations dr - JOIN licenses l ON dr.license_id = l.id - WHERE l.license_key = %s AND dr.is_active = true - """, (license_data['license_key'],)) - - active_device_count = cur.fetchone()[0] - - if active_device_count >= license_data['device_limit']: - return jsonify({'error': 'Gerätelimit erreicht'}), 400 - - # Prüfe ob Gerät bereits registriert - cur.execute(""" - SELECT dr.id, dr.is_active FROM device_registrations dr - JOIN licenses l ON dr.license_id = l.id - WHERE l.license_key = %s AND dr.hardware_id = %s - """, (license_data['license_key'], hardware_id)) - - existing = cur.fetchone() - - if existing: - if existing[1]: # is_active - return jsonify({'error': 'Gerät bereits registriert'}), 400 - else: - # Reaktiviere Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = true, last_seen = CURRENT_TIMESTAMP - WHERE id = %s - """, (existing[0],)) - else: - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, device_type, is_active) - VALUES (%s, %s, %s, %s, true) - """, (license_id, hardware_id, device_name, device_type)) - - conn.commit() - - # Audit-Log - log_audit('DEVICE_REGISTER', 'license', license_id, - additional_info=f"Gerät {device_name} ({hardware_id}) registriert") - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Registrieren des Geräts: {str(e)}") - return jsonify({'error': 'Fehler beim Registrieren des Geräts'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein Gerät einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob Gerät zur Lizenz gehört - cur.execute(""" - SELECT dr.device_name, dr.hardware_id, l.license_key - FROM device_registrations dr - JOIN licenses l ON dr.license_id = l.id - WHERE dr.id = %s AND l.id = %s - """, (device_id, license_id)) - - device = cur.fetchone() - if not device: - return jsonify({'error': 'Gerät nicht gefunden'}), 404 - - # Deaktiviere Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = false - WHERE id = %s - """, (device_id,)) - - # Beende aktive Sessions - cur.execute(""" - UPDATE sessions - SET is_active = false, ended_at = CURRENT_TIMESTAMP - WHERE license_key = %s AND hardware_id = %s AND is_active = true - """, (device[2], device[1])) - - conn.commit() - - # Audit-Log - log_audit('DEVICE_DEACTIVATE', 'license', license_id, - additional_info=f"Gerät {device[0]} ({device[1]}) deaktiviert") - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'error': 'Fehler beim Deaktivieren des Geräts'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Lösche mehrere Lizenzen gleichzeitig mit Sicherheitsprüfungen""" - data = request.get_json() - # Accept both 'ids' (from frontend) and 'license_ids' for compatibility - license_ids = data.get('ids', data.get('license_ids', [])) - force_delete = data.get('force', False) - - if not license_ids: - return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - deleted_count = 0 - skipped_licenses = [] - active_licenses = [] - recently_used_licenses = [] - - for license_id in license_ids: - # Hole vollständige Lizenz-Info - cur.execute(""" - SELECT l.id, l.license_key, l.is_active, l.is_fake, - c.name as customer_name - FROM licenses l - LEFT JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - result = cur.fetchone() - - if not result: - continue - - license_id, license_key, is_active, is_fake, customer_name = result - - # Safety check: Don't delete active licenses unless forced - if is_active and not force_delete: - active_licenses.append(f"{license_key} ({customer_name})") - skipped_licenses.append(license_id) - continue - - # Check for recent activity (heartbeats in last 24 hours) - if not force_delete: - 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: - recently_used_licenses.append(f"{license_key} ({recent_heartbeats} activities)") - skipped_licenses.append(license_id) - continue - except: - # If heartbeats table doesn't exist, continue - pass - - # Check for active devices - if not force_delete: - 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: - recently_used_licenses.append(f"{license_key} ({active_devices} active devices)") - skipped_licenses.append(license_id) - continue - except: - # If activations table doesn't exist, continue - pass - - # Delete associated data - cur.execute("DELETE FROM sessions WHERE license_key = %s", (license_key,)) - - try: - cur.execute("DELETE FROM device_registrations WHERE license_id = %s", (license_id,)) - except: - pass - - try: - cur.execute("DELETE FROM license_heartbeats WHERE license_id = %s", (license_id,)) - except: - pass - - 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,)) - - # Audit-Log - log_audit('BULK_DELETE', 'license', license_id, - old_values={ - 'license_key': license_key, - 'customer_name': customer_name, - 'was_active': is_active, - 'forced': force_delete - }) - - deleted_count += 1 - - conn.commit() - - # Build response message - message = f"{deleted_count} Lizenz(en) gelöscht." - warnings = [] - - if active_licenses: - warnings.append(f"Aktive Lizenzen übersprungen: {', '.join(active_licenses[:3])}{'...' if len(active_licenses) > 3 else ''}") - - if recently_used_licenses: - warnings.append(f"Kürzlich genutzte Lizenzen übersprungen: {', '.join(recently_used_licenses[:3])}{'...' if len(recently_used_licenses) > 3 else ''}") - - return jsonify({ - 'success': True, - 'deleted_count': deleted_count, - 'skipped_count': len(skipped_licenses), - 'message': message, - 'warnings': warnings - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Bulk-Löschen: {str(e)}") - return jsonify({'error': 'Fehler beim Löschen der Lizenzen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//quick-edit", methods=['POST']) -@login_required -def quick_edit_license(license_id): - """Schnellbearbeitung einer Lizenz""" - data = request.get_json() - - conn = get_connection() - cur = conn.cursor() - - try: - # Hole aktuelle Lizenz für Vergleich - current_license = get_license_by_id(license_id) - if not current_license: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Update nur die übergebenen Felder - updates = [] - params = [] - old_values = {} - new_values = {} - - if 'device_limit' in data: - updates.append("device_limit = %s") - params.append(int(data['device_limit'])) - old_values['device_limit'] = current_license['device_limit'] - new_values['device_limit'] = int(data['device_limit']) - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - old_values['valid_until'] = str(current_license['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(bool(data['is_active'])) - old_values['is_active'] = current_license['is_active'] - new_values['is_active'] = bool(data['is_active']) - - if not updates: - return jsonify({'error': 'Keine Änderungen angegeben'}), 400 - - # Führe Update aus - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('QUICK_EDIT', 'license', license_id, - old_values=old_values, - new_values=new_values) - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Schnellbearbeitung: {str(e)}") - return jsonify({'error': 'Fehler bei der Bearbeitung'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//resources") -@login_required -def get_license_resources(license_id): - """Hole alle Ressourcen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Lizenz-Info - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Hole zugewiesene Ressourcen - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.is_fake, - rp.status_changed_at, - lr.assigned_at, - lr.assigned_by - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'type': row[1], - 'value': row[2], - 'is_fake': row[3], - 'status_changed_at': row[4].isoformat() if row[4] else None, - 'assigned_at': row[5].isoformat() if row[5] else None, - 'assigned_by': row[6] - }) - - # Gruppiere nach Typ - grouped = {} - for resource in resources: - res_type = resource['type'] - if res_type not in grouped: - grouped[res_type] = [] - grouped[res_type].append(resource) - - return jsonify({ - 'success': True, - 'license_key': license_data['license_key'], - 'resources': resources, - 'grouped': grouped, - 'total_count': len(resources) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Ressourcen: {str(e)}") - return jsonify({'error': 'Fehler beim Abrufen der Ressourcen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/resources/allocate", methods=['POST']) -@login_required -def allocate_resources(): - """Weise Ressourcen einer Lizenz zu""" - data = request.get_json() - - license_id = data.get('license_id') - resource_ids = data.get('resource_ids', []) - - if not license_id or not resource_ids: - return jsonify({'error': 'Lizenz-ID und Ressourcen erforderlich'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe Lizenz - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - allocated_count = 0 - errors = [] - - for resource_id in resource_ids: - try: - # Prüfe ob Ressource verfügbar ist - cur.execute(""" - SELECT resource_value, status, is_fake - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - - resource = cur.fetchone() - if not resource: - errors.append(f"Ressource {resource_id} nicht gefunden") - continue - - if resource[1] != 'available': - errors.append(f"Ressource {resource[0]} ist nicht verfügbar") - continue - - # Prüfe Test/Produktion Kompatibilität - if resource[2] != license_data['is_fake']: - errors.append(f"Ressource {resource[0]} ist {'Test' if resource[2] else 'Produktion'}, Lizenz ist {'Test' if license_data['is_fake'] else 'Produktion'}") - continue - - # Weise Ressource zu - 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)) - - # Erstelle Verknüpfung - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - # History-Eintrag - 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())) - - allocated_count += 1 - - except Exception as e: - errors.append(f"Fehler bei Ressource {resource_id}: {str(e)}") - - conn.commit() - - # Audit-Log - if allocated_count > 0: - log_audit('RESOURCE_ALLOCATE', 'license', license_id, - additional_info=f"{allocated_count} Ressourcen zugewiesen") - - return jsonify({ - 'success': True, - 'allocated_count': allocated_count, - 'errors': errors - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Zuweisen der Ressourcen: {str(e)}") - return jsonify({'error': 'Fehler beim Zuweisen der Ressourcen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/resources/check-availability", methods=['GET']) -@login_required -def check_resource_availability(): - """Prüfe Verfügbarkeit von Ressourcen""" - # Einzelne Ressource prüfen (alte API) - resource_type = request.args.get('type') - if resource_type: - count = int(request.args.get('count', 1)) - is_fake = request.args.get('is_fake', 'false') == 'true' - show_fake = request.args.get('show_fake', 'false') == 'true' - - conn = get_connection() - cur = conn.cursor() - - try: - # Hole verfügbare Ressourcen mit Details - if show_fake: - # Zeige alle verfügbaren Ressourcen (Test und Produktion) - cur.execute(""" - SELECT id, resource_value, is_fake - FROM resource_pools - WHERE resource_type = %s - AND status = 'available' - ORDER BY is_fake, resource_value - LIMIT %s - """, (resource_type, count)) - else: - # Zeige nur Produktions-Ressourcen - cur.execute(""" - SELECT id, resource_value, is_fake - FROM resource_pools - WHERE resource_type = %s - AND status = 'available' - AND is_fake = false - ORDER BY resource_value - LIMIT %s - """, (resource_type, count)) - - available_resources = [] - for row in cur.fetchall(): - available_resources.append({ - 'id': row[0], - 'value': row[1], - 'is_fake': row[2] - }) - - return jsonify({ - 'resource_type': resource_type, - 'requested': count, - 'available': available_resources, - 'sufficient': len(available_resources) >= count, - 'show_fake': show_fake - }) - - except Exception as e: - logging.error(f"Fehler beim Prüfen der Verfügbarkeit: {str(e)}") - return jsonify({'error': 'Fehler beim Prüfen der Verfügbarkeit'}), 500 - finally: - cur.close() - conn.close() - - # Mehrere Ressourcen gleichzeitig prüfen (für Batch) - domain_count = int(request.args.get('domain', 0)) - ipv4_count = int(request.args.get('ipv4', 0)) - phone_count = int(request.args.get('phone', 0)) - is_fake = request.args.get('is_fake', 'false') == 'true' - - conn = get_connection() - cur = conn.cursor() - - try: - # Zähle verfügbare Ressourcen für jeden Typ - result = {} - - # Domains - cur.execute(""" - SELECT COUNT(*) - FROM resource_pools - WHERE resource_type = 'domain' - AND status = 'available' - AND is_fake = %s - """, (is_fake,)) - domain_available = cur.fetchone()[0] - - # IPv4 - cur.execute(""" - SELECT COUNT(*) - FROM resource_pools - WHERE resource_type = 'ipv4' - AND status = 'available' - AND is_fake = %s - """, (is_fake,)) - ipv4_available = cur.fetchone()[0] - - # Phones - cur.execute(""" - SELECT COUNT(*) - FROM resource_pools - WHERE resource_type = 'phone' - AND status = 'available' - AND is_fake = %s - """, (is_fake,)) - phone_available = cur.fetchone()[0] - - return jsonify({ - 'domain_requested': domain_count, - 'domain_available': domain_available, - 'domain_sufficient': domain_available >= domain_count, - 'ipv4_requested': ipv4_count, - 'ipv4_available': ipv4_available, - 'ipv4_sufficient': ipv4_available >= ipv4_count, - 'phone_requested': phone_count, - 'phone_available': phone_available, - 'phone_sufficient': phone_available >= phone_count, - 'all_sufficient': ( - domain_available >= domain_count and - ipv4_available >= ipv4_count and - phone_available >= phone_count - ), - 'is_fake': is_fake - }) - - except Exception as e: - logging.error(f"Fehler beim Prüfen der Verfügbarkeit: {str(e)}") - return jsonify({'error': 'Fehler beim Prüfen der Verfügbarkeit'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/global-search", methods=['GET']) -@login_required -def global_search(): - """Globale Suche über alle Entitäten""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 3: - return jsonify({'error': 'Suchbegriff muss mindestens 3 Zeichen haben'}), 400 - - conn = get_connection() - cur = conn.cursor() - - results = { - 'licenses': [], - 'customers': [], - 'resources': [], - 'sessions': [] - } - - try: - # Suche in Lizenzen - cur.execute(""" - SELECT id, license_key, customer_name, is_active - FROM licenses - WHERE license_key ILIKE %s - OR customer_name ILIKE %s - OR customer_email ILIKE %s - LIMIT 10 - """, (f'%{query}%', f'%{query}%', f'%{query}%')) - - for row in cur.fetchall(): - results['licenses'].append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2], - 'is_active': row[3] - }) - - # Suche in Kunden - cur.execute(""" - SELECT id, name, email, is_fake - FROM customers - WHERE name ILIKE %s OR email ILIKE %s - LIMIT 10 - """, (f'%{query}%', f'%{query}%')) - - for row in cur.fetchall(): - results['customers'].append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'is_fake': row[3] - }) - - # Suche in Ressourcen - cur.execute(""" - SELECT id, resource_type, resource_value, status - FROM resource_pools - WHERE resource_value ILIKE %s - LIMIT 10 - """, (f'%{query}%',)) - - for row in cur.fetchall(): - results['resources'].append({ - 'id': row[0], - 'type': row[1], - 'value': row[2], - 'status': row[3] - }) - - # Suche in Sessions - cur.execute(""" - SELECT id, license_key, username, hardware_id, is_active - FROM sessions - WHERE username ILIKE %s OR hardware_id ILIKE %s - ORDER BY started_at DESC - LIMIT 10 - """, (f'%{query}%', f'%{query}%')) - - for row in cur.fetchall(): - results['sessions'].append({ - 'id': row[0], - 'license_key': row[1], - 'username': row[2], - 'hardware_id': row[3], - 'is_active': row[4] - }) - - return jsonify(results) - - except Exception as e: - logging.error(f"Fehler bei der globalen Suche: {str(e)}") - return jsonify({'error': 'Fehler bei der Suche'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - - - diff --git a/v2_adminpanel/routes/auth_routes.py b/v2_adminpanel/routes/auth_routes.py deleted file mode 100644 index 69a5c7d..0000000 --- a/v2_adminpanel/routes/auth_routes.py +++ /dev/null @@ -1,377 +0,0 @@ -import time -import json -from datetime import datetime -from zoneinfo import ZoneInfo -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify - -import config -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from models import get_user_by_username -from db import get_db_connection, get_db_cursor -from utils.recaptcha import verify_recaptcha - -# Create Blueprint -auth_bp = Blueprint('auth', __name__) - - -@auth_bp.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = config.RECAPTCHA_SITE_KEY - if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('auth.verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('admin.dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - error_type="failed", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - -@auth_bp.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('auth.login')) - - -@auth_bp.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('auth.login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('auth.login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('auth.login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('admin.dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('admin.dashboard')) - - # Failed verification - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - - -@auth_bp.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('admin.dashboard')) - return render_template('profile.html', user=user) - - -@auth_bp.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('auth.profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('auth.profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('auth.profile')) - - # Update password - new_hash = hash_password(new_password) - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('auth.profile')) - - -@auth_bp.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('auth.profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - - -@auth_bp.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('auth.setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('auth.setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - backup_codes_hashed = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA for user - user = get_user_by_username(session['username']) - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = true, backup_codes = %s - WHERE id = %s - """, (totp_secret, json.dumps(backup_codes_hashed), user['id'])) - - # Clear temp secret - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', entity_id=user['id'], - additional_info="2FA successfully enabled") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - - -@auth_bp.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password. 2FA was not disabled.', 'error') - return redirect(url_for('auth.profile')) - - # Disable 2FA - with get_db_connection() as conn: - with get_db_cursor(conn) as cur: - cur.execute(""" - UPDATE users - SET totp_enabled = false, totp_secret = NULL, backup_codes = NULL - WHERE id = %s - """, (user['id'],)) - - log_audit('2FA_DISABLED', 'user', entity_id=user['id'], - additional_info="2FA disabled by user") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('auth.profile')) - - -@auth_bp.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) \ No newline at end of file diff --git a/v2_adminpanel/routes/batch_routes.py b/v2_adminpanel/routes/batch_routes.py deleted file mode 100644 index 266282f..0000000 --- a/v2_adminpanel/routes/batch_routes.py +++ /dev/null @@ -1,439 +0,0 @@ -import os -import logging -import secrets -import string -from datetime import datetime, timedelta -from pathlib import Path -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file - -import config -from auth.decorators import login_required -from utils.audit import log_audit -from utils.network import get_client_ip -from utils.export import create_batch_export -from db import get_connection, get_db_connection, get_db_cursor -from models import get_customers - -# Create Blueprint -batch_bp = Blueprint('batch', __name__) - - -def generate_license_key(): - """Generiert einen zufälligen Lizenzschlüssel""" - chars = string.ascii_uppercase + string.digits - return '-'.join([''.join(secrets.choice(chars) for _ in range(4)) for _ in range(4)]) - - -@batch_bp.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_create(): - """Batch-Erstellung von Lizenzen""" - customers = get_customers() - - if request.method == "POST": - conn = get_connection() - cur = conn.cursor() - - try: - # Form data - customer_id = int(request.form['customer_id']) - license_type = request.form['license_type'] - count = int(request.form['quantity']) # Korrigiert von 'count' zu 'quantity' - valid_from = request.form['valid_from'] - valid_until = request.form['valid_until'] - device_limit = int(request.form['device_limit']) - - # Resource allocation parameters - domain_count = int(request.form.get('domain_count', 0)) - ipv4_count = int(request.form.get('ipv4_count', 0)) - phone_count = int(request.form.get('phone_count', 0)) - - # Validierung - if count < 1 or count > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch.batch_create')) - - # Hole Kundendaten inkl. is_fake Status - cur.execute("SELECT name, email, is_fake FROM customers WHERE id = %s", (customer_id,)) - customer = cur.fetchone() - if not customer: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch.batch_create')) - - # Lizenz erbt immer den is_fake Status vom Kunden - is_fake = customer[2] - - created_licenses = [] - - # Erstelle Lizenzen - for i in range(count): - license_key = generate_license_key() - - # Prüfe ob Schlüssel bereits existiert - while True: - cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - license_key = generate_license_key() - - # Erstelle Lizenz - cur.execute(""" - INSERT INTO licenses ( - license_key, customer_id, - license_type, valid_from, valid_until, device_limit, - is_fake, created_at - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """, ( - license_key, customer_id, - license_type, valid_from, valid_until, device_limit, - is_fake, datetime.now() - )) - - license_id = cur.fetchone()[0] - created_licenses.append({ - 'id': license_id, - 'license_key': license_key - }) - - # Allocate resources if requested - if domain_count > 0 or ipv4_count > 0 or phone_count > 0: - # Allocate domains - if domain_count > 0: - cur.execute(""" - UPDATE resource_pool - SET status = 'allocated', - license_id = %s, - allocated_at = NOW() - WHERE id IN ( - SELECT id FROM resource_pool - WHERE type = 'domain' - AND status = 'available' - AND is_fake = %s - ORDER BY id - LIMIT %s - ) - """, (license_id, is_fake, domain_count)) - - # Allocate IPv4s - if ipv4_count > 0: - cur.execute(""" - UPDATE resource_pool - SET status = 'allocated', - license_id = %s, - allocated_at = NOW() - WHERE id IN ( - SELECT id FROM resource_pool - WHERE type = 'ipv4' - AND status = 'available' - AND is_fake = %s - ORDER BY id - LIMIT %s - ) - """, (license_id, is_fake, ipv4_count)) - - # Allocate phones - if phone_count > 0: - cur.execute(""" - UPDATE resource_pool - SET status = 'allocated', - license_id = %s, - allocated_at = NOW() - WHERE id IN ( - SELECT id FROM resource_pool - WHERE type = 'phone' - AND status = 'available' - AND is_fake = %s - ORDER BY id - LIMIT %s - ) - """, (license_id, is_fake, phone_count)) - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer[0], - 'batch_creation': True - }) - - conn.commit() - - # Speichere erstellte Lizenzen in Session für Export - session['batch_created_licenses'] = created_licenses - session['batch_customer_name'] = customer[0] - session['batch_customer_email'] = customer[1] - - flash(f'{count} Lizenzen erfolgreich erstellt!', 'success') - - # Weiterleitung zum Export - return redirect(url_for('batch.batch_export')) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Erstellung: {str(e)}") - flash('Fehler bei der Batch-Erstellung!', 'error') - finally: - cur.close() - conn.close() - - return render_template("batch_form.html", customers=customers) - - -@batch_bp.route("/batch/export") -@login_required -def batch_export(): - """Exportiert die zuletzt erstellten Batch-Lizenzen""" - created_licenses = session.get('batch_created_licenses', []) - - if not created_licenses: - flash('Keine Lizenzen zum Exportieren gefunden!', 'error') - return redirect(url_for('batch.batch_create')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Hole vollständige Lizenzdaten - license_ids = [l['id'] for l in created_licenses] - - cur.execute(""" - SELECT - l.license_key, c.name, c.email, - l.license_type, l.valid_from, l.valid_until, - l.device_limit, l.is_fake, l.created_at - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = ANY(%s) - ORDER BY l.id - """, (license_ids,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'license_key': row[0], - 'customer_name': row[1], - 'customer_email': row[2], - 'license_type': row[3], - 'valid_from': row[4], - 'valid_until': row[5], - 'device_limit': row[6], - 'is_fake': row[7], - 'created_at': row[8] - }) - - # Lösche aus Session - session.pop('batch_created_licenses', None) - session.pop('batch_customer_name', None) - session.pop('batch_customer_email', None) - - # Erstelle und sende Excel-Export - return create_batch_export(licenses) - - except Exception as e: - logging.error(f"Fehler beim Export: {str(e)}") - flash('Fehler beim Exportieren der Lizenzen!', 'error') - return redirect(url_for('batch.batch_create')) - finally: - cur.close() - conn.close() - - -@batch_bp.route("/batch/update", methods=["GET", "POST"]) -@login_required -def batch_update(): - """Batch-Update von Lizenzen""" - if request.method == "POST": - conn = get_connection() - cur = conn.cursor() - - try: - # Form data - license_keys = request.form.get('license_keys', '').strip().split('\n') - license_keys = [key.strip() for key in license_keys if key.strip()] - - if not license_keys: - flash('Keine Lizenzschlüssel angegeben!', 'error') - return redirect(url_for('batch.batch_update')) - - # Update-Parameter - updates = [] - params = [] - - if 'update_valid_until' in request.form and request.form['valid_until']: - updates.append("valid_until = %s") - params.append(request.form['valid_until']) - - if 'update_device_limit' in request.form and request.form['device_limit']: - updates.append("device_limit = %s") - params.append(int(request.form['device_limit'])) - - if 'update_active' in request.form: - updates.append("is_active = %s") - params.append('is_active' in request.form) - - if not updates: - flash('Keine Änderungen angegeben!', 'error') - return redirect(url_for('batch.batch_update')) - - # Führe Updates aus - updated_count = 0 - not_found = [] - - for license_key in license_keys: - # Prüfe ob Lizenz existiert - cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,)) - result = cur.fetchone() - - if not result: - not_found.append(license_key) - continue - - license_id = result[0] - - # Update ausführen - update_params = params + [license_id] - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, update_params) - - # Audit-Log - log_audit('BATCH_UPDATE', 'license', license_id, - additional_info=f"Batch-Update: {', '.join(updates)}") - - updated_count += 1 - - conn.commit() - - # Feedback - flash(f'{updated_count} Lizenzen erfolgreich aktualisiert!', 'success') - - if not_found: - flash(f'{len(not_found)} Lizenzen nicht gefunden: {", ".join(not_found[:5])}{"..." if len(not_found) > 5 else ""}', 'warning') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Update: {str(e)}") - flash('Fehler beim Batch-Update!', 'error') - finally: - cur.close() - conn.close() - - return render_template("batch_update.html") - - -@batch_bp.route("/batch/import", methods=["GET", "POST"]) -@login_required -def batch_import(): - """Import von Lizenzen aus CSV/Excel""" - if request.method == "POST": - if 'file' not in request.files: - flash('Keine Datei ausgewählt!', 'error') - return redirect(url_for('batch.batch_import')) - - file = request.files['file'] - if file.filename == '': - flash('Keine Datei ausgewählt!', 'error') - return redirect(url_for('batch.batch_import')) - - # Verarbeite Datei - try: - import pandas as pd - - # Lese Datei - if file.filename.endswith('.csv'): - df = pd.read_csv(file) - elif file.filename.endswith(('.xlsx', '.xls')): - df = pd.read_excel(file) - else: - flash('Ungültiges Dateiformat! Nur CSV und Excel erlaubt.', 'error') - return redirect(url_for('batch.batch_import')) - - # Validiere Spalten - required_columns = ['customer_email', 'license_type', 'valid_from', 'valid_until', 'device_limit'] - missing_columns = [col for col in required_columns if col not in df.columns] - - if missing_columns: - flash(f'Fehlende Spalten: {", ".join(missing_columns)}', 'error') - return redirect(url_for('batch.batch_import')) - - conn = get_connection() - cur = conn.cursor() - - imported_count = 0 - errors = [] - - for index, row in df.iterrows(): - try: - # Finde oder erstelle Kunde - email = row['customer_email'] - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - customer = cur.fetchone() - - if not customer: - # Erstelle neuen Kunden - name = row.get('customer_name', email.split('@')[0]) - # Neue Kunden werden immer als Fake erstellt in der Testphase - # TODO: Nach Testphase muss hier die Business-Logik angepasst werden - is_fake = True - cur.execute(""" - INSERT INTO customers (name, email, is_fake, created_at) - VALUES (%s, %s, %s, %s) - RETURNING id - """, (name, email, is_fake, datetime.now())) - customer_id = cur.fetchone()[0] - customer_name = name - else: - customer_id = customer[0] - customer_name = customer[1] - # Hole is_fake Status vom existierenden Kunden - cur.execute("SELECT is_fake FROM customers WHERE id = %s", (customer_id,)) - is_fake = cur.fetchone()[0] - - # Generiere Lizenzschlüssel - license_key = row.get('license_key', generate_license_key()) - - # Erstelle Lizenz - is_fake wird vom Kunden geerbt - cur.execute(""" - INSERT INTO licenses ( - license_key, customer_id, - license_type, valid_from, valid_until, device_limit, - is_fake, created_at - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """, ( - license_key, customer_id, - row['license_type'], row['valid_from'], row['valid_until'], - int(row['device_limit']), is_fake, - datetime.now() - )) - - license_id = cur.fetchone()[0] - imported_count += 1 - - # Audit-Log - log_audit('IMPORT', 'license', license_id, - additional_info=f"Importiert aus {file.filename}") - - except Exception as e: - errors.append(f"Zeile {index + 2}: {str(e)}") - - conn.commit() - - # Feedback - flash(f'{imported_count} Lizenzen erfolgreich importiert!', 'success') - - if errors: - flash(f'{len(errors)} Fehler aufgetreten. Erste Fehler: {"; ".join(errors[:3])}', 'warning') - - except Exception as e: - logging.error(f"Fehler beim Import: {str(e)}") - flash(f'Fehler beim Import: {str(e)}', 'error') - finally: - if 'conn' in locals(): - cur.close() - conn.close() - - return render_template("batch_import.html") \ No newline at end of file diff --git a/v2_adminpanel/routes/customer_routes.py b/v2_adminpanel/routes/customer_routes.py deleted file mode 100644 index 1d6dcfb..0000000 --- a/v2_adminpanel/routes/customer_routes.py +++ /dev/null @@ -1,466 +0,0 @@ -import os -import logging -from datetime import datetime -from zoneinfo import ZoneInfo -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify - -import config -from auth.decorators import login_required -from utils.audit import log_audit -from db import get_connection, get_db_connection, get_db_cursor -from models import get_customers, get_customer_by_id - -# Create Blueprint -customer_bp = Blueprint('customers', __name__) - -# Test route -@customer_bp.route("/test-customers") -def test_customers(): - return "Customer blueprint is working!" - - -@customer_bp.route("/customers") -@login_required -def customers(): - show_fake = request.args.get('show_fake', 'false').lower() == 'true' - search = request.args.get('search', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - sort = request.args.get('sort', 'name') - order = request.args.get('order', 'asc') - - customers_list = get_customers(show_fake=show_fake, search=search) - - # Sortierung - if sort == 'name': - customers_list.sort(key=lambda x: x['name'].lower(), reverse=(order == 'desc')) - elif sort == 'email': - customers_list.sort(key=lambda x: x['email'].lower(), reverse=(order == 'desc')) - elif sort == 'created_at': - customers_list.sort(key=lambda x: x['created_at'], reverse=(order == 'desc')) - - # Paginierung - total_customers = len(customers_list) - total_pages = (total_customers + per_page - 1) // per_page - start = (page - 1) * per_page - end = start + per_page - paginated_customers = customers_list[start:end] - - return render_template("customers.html", - customers=paginated_customers, - show_fake=show_fake, - search=search, - page=page, - per_page=per_page, - total_pages=total_pages, - total_customers=total_customers, - sort=sort, - order=order, - current_order=order) - - -@customer_bp.route("/customer/edit/", 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/", 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//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//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() \ No newline at end of file diff --git a/v2_adminpanel/routes/export_routes.py b/v2_adminpanel/routes/export_routes.py deleted file mode 100644 index 255674c..0000000 --- a/v2_adminpanel/routes/export_routes.py +++ /dev/null @@ -1,495 +0,0 @@ -import logging -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from flask import Blueprint, request, send_file - -import config -from auth.decorators import login_required -from utils.export import create_excel_export, create_csv_export, prepare_audit_export_data, format_datetime_for_export -from db import get_connection - -# Create Blueprint -export_bp = Blueprint('export', __name__, url_prefix='/export') - - -@export_bp.route("/licenses") -@login_required -def export_licenses(): - """Exportiert Lizenzen als Excel-Datei""" - conn = get_connection() - cur = conn.cursor() - - try: - # Nur reale Daten exportieren - keine Fake-Daten - query = """ - SELECT - l.id, - l.license_key, - c.name as customer_name, - c.email as customer_email, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - l.device_limit, - l.created_at, - l.is_fake, - CASE - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.is_active = false THEN 'Deaktiviert' - ELSE 'Aktiv' - END as status, - (SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.is_active = true) as active_sessions, - (SELECT COUNT(DISTINCT hardware_id) FROM sessions s WHERE s.license_key = l.license_key) as registered_devices - FROM licenses l - LEFT JOIN customers c ON l.customer_id = c.id - WHERE l.is_fake = false - ORDER BY l.created_at DESC - """ - - cur.execute(query) - - # Daten für Export vorbereiten - data = [] - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', 'Gültig von', - 'Gültig bis', 'Aktiv', 'Gerätelimit', 'Erstellt am', 'Fake-Lizenz', - 'Status', 'Aktive Sessions', 'Registrierte Geräte'] - - for row in cur.fetchall(): - row_data = list(row) - # Format datetime fields - if row_data[5]: # valid_from - row_data[5] = format_datetime_for_export(row_data[5]) - if row_data[6]: # valid_until - row_data[6] = format_datetime_for_export(row_data[6]) - if row_data[9]: # created_at - row_data[9] = format_datetime_for_export(row_data[9]) - data.append(row_data) - - # Format prüfen - format_type = request.args.get('format', 'excel').lower() - - if format_type == 'csv': - # CSV-Datei erstellen - return create_csv_export(data, columns, 'lizenzen') - else: - # Excel-Datei erstellen - return create_excel_export(data, columns, 'lizenzen') - - except Exception as e: - logging.error(f"Fehler beim Export: {str(e)}") - return "Fehler beim Exportieren der Lizenzen", 500 - finally: - cur.close() - conn.close() - - -@export_bp.route("/audit") -@login_required -def export_audit(): - """Exportiert Audit-Logs als Excel-Datei""" - conn = get_connection() - cur = conn.cursor() - - try: - # Filter aus Request - days = int(request.args.get('days', 30)) - action_filter = request.args.get('action', '') - entity_type_filter = request.args.get('entity_type', '') - - # Query aufbauen - query = """ - SELECT - id, timestamp, username, action, entity_type, entity_id, - ip_address, user_agent, old_values, new_values, additional_info - FROM audit_log - WHERE timestamp >= CURRENT_TIMESTAMP - INTERVAL '%s days' - """ - params = [days] - - if action_filter: - query += " AND action = %s" - params.append(action_filter) - - if entity_type_filter: - query += " AND entity_type = %s" - params.append(entity_type_filter) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - - # Daten in Dictionary-Format umwandeln - audit_logs = [] - for row in cur.fetchall(): - audit_logs.append({ - 'id': row[0], - 'timestamp': row[1], - 'username': row[2], - 'action': row[3], - 'entity_type': row[4], - 'entity_id': row[5], - 'ip_address': row[6], - 'user_agent': row[7], - 'old_values': row[8], - 'new_values': row[9], - 'additional_info': row[10] - }) - - # Daten für Export vorbereiten - data = prepare_audit_export_data(audit_logs) - - # Excel-Datei erstellen - columns = ['ID', 'Zeitstempel', 'Benutzer', 'Aktion', 'Entität', 'Entität ID', - 'IP-Adresse', 'User Agent', 'Alte Werte', 'Neue Werte', 'Zusatzinfo'] - - # Format prüfen - format_type = request.args.get('format', 'excel').lower() - - if format_type == 'csv': - # CSV-Datei erstellen - return create_csv_export(data, columns, 'audit_log') - else: - # Excel-Datei erstellen - return create_excel_export(data, columns, 'audit_log') - - except Exception as e: - logging.error(f"Fehler beim Export: {str(e)}") - return "Fehler beim Exportieren der Audit-Logs", 500 - finally: - cur.close() - conn.close() - - -@export_bp.route("/customers") -@login_required -def export_customers(): - """Exportiert Kunden als Excel-Datei""" - conn = get_connection() - cur = conn.cursor() - - try: - # SQL Query - nur reale Kunden exportieren - cur.execute(""" - SELECT - c.id, - c.name, - c.email, - c.phone, - c.address, - c.created_at, - c.is_fake, - COUNT(l.id) as license_count, - COUNT(CASE WHEN l.is_active = true THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_fake = false - GROUP BY c.id, c.name, c.email, c.phone, c.address, c.created_at, c.is_fake - ORDER BY c.name - """) - - # Daten für Export vorbereiten - data = [] - columns = ['ID', 'Name', 'E-Mail', 'Telefon', 'Adresse', 'Erstellt am', - 'Test-Kunde', 'Anzahl Lizenzen', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - for row in cur.fetchall(): - # Format datetime fields (created_at ist Spalte 5) - row_data = list(row) - if row_data[5]: # created_at - row_data[5] = format_datetime_for_export(row_data[5]) - data.append(row_data) - - # Format prüfen - format_type = request.args.get('format', 'excel').lower() - - if format_type == 'csv': - # CSV-Datei erstellen - return create_csv_export(data, columns, 'kunden') - else: - # Excel-Datei erstellen - return create_excel_export(data, columns, 'kunden') - - except Exception as e: - logging.error(f"Fehler beim Export: {str(e)}") - return "Fehler beim Exportieren der Kunden", 500 - finally: - cur.close() - conn.close() - - -@export_bp.route("/sessions") -@login_required -def export_sessions(): - """Exportiert Sessions als Excel-Datei""" - conn = get_connection() - cur = conn.cursor() - - try: - # Filter aus Request - days = int(request.args.get('days', 7)) - active_only = request.args.get('active_only', 'false') == 'true' - - # SQL Query - if active_only: - query = """ - SELECT - s.id, - s.license_key, - l.customer_name, - s.username, - s.hardware_id, - s.started_at, - s.ended_at, - s.last_heartbeat, - s.is_active, - l.license_type, - l.is_fake - FROM sessions s - LEFT JOIN licenses l ON s.license_key = l.license_key - WHERE s.is_active = true AND l.is_fake = false - ORDER BY s.started_at DESC - """ - cur.execute(query) - else: - query = """ - SELECT - s.id, - s.license_key, - l.customer_name, - s.username, - s.hardware_id, - s.started_at, - s.ended_at, - s.last_heartbeat, - s.is_active, - l.license_type, - l.is_fake - FROM sessions s - LEFT JOIN licenses l ON s.license_key = l.license_key - WHERE s.started_at >= CURRENT_TIMESTAMP - INTERVAL '%s days' AND l.is_fake = false - ORDER BY s.started_at DESC - """ - cur.execute(query, (days,)) - - # Daten für Export vorbereiten - data = [] - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'Benutzer', 'Geräte-ID', - 'Login-Zeit', 'Logout-Zeit', 'Letzte Aktivität', 'Aktiv', - 'Lizenztyp', 'Fake-Lizenz'] - - for row in cur.fetchall(): - row_data = list(row) - # Format datetime fields - if row_data[5]: # started_at - row_data[5] = format_datetime_for_export(row_data[5]) - if row_data[6]: # ended_at - row_data[6] = format_datetime_for_export(row_data[6]) - if row_data[7]: # last_heartbeat - row_data[7] = format_datetime_for_export(row_data[7]) - data.append(row_data) - - # Format prüfen - format_type = request.args.get('format', 'excel').lower() - - if format_type == 'csv': - # CSV-Datei erstellen - return create_csv_export(data, columns, 'sessions') - else: - # Excel-Datei erstellen - return create_excel_export(data, columns, 'sessions') - - except Exception as e: - logging.error(f"Fehler beim Export: {str(e)}") - return "Fehler beim Exportieren der Sessions", 500 - finally: - cur.close() - conn.close() - - -@export_bp.route("/resources") -@login_required -def export_resources(): - """Exportiert Ressourcen als Excel-Datei""" - conn = get_connection() - cur = conn.cursor() - - try: - # Filter aus Request - resource_type = request.args.get('type', 'all') - status_filter = request.args.get('status', 'all') - - # SQL Query aufbauen - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.is_fake, - l.license_key, - c.name as customer_name, - rp.created_at, - rp.created_by, - rp.status_changed_at, - rp.status_changed_by, - rp.quarantine_reason - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - - params = [] - - if resource_type != 'all': - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter != 'all': - query += " AND rp.status = %s" - params.append(status_filter) - - # Immer nur reale Ressourcen exportieren - query += " AND rp.is_fake = false" - - query += " ORDER BY rp.resource_type, rp.resource_value" - - cur.execute(query, params) - - # Daten für Export vorbereiten - data = [] - columns = ['ID', 'Typ', 'Wert', 'Status', 'Test-Ressource', 'Lizenzschlüssel', - 'Kunde', 'Erstellt am', 'Erstellt von', 'Status geändert am', - 'Status geändert von', 'Quarantäne-Grund'] - - for row in cur.fetchall(): - row_data = list(row) - # Format datetime fields - if row_data[7]: # created_at - row_data[7] = format_datetime_for_export(row_data[7]) - if row_data[9]: # status_changed_at - row_data[9] = format_datetime_for_export(row_data[9]) - data.append(row_data) - - # Format prüfen - format_type = request.args.get('format', 'excel').lower() - - if format_type == 'csv': - # CSV-Datei erstellen - return create_csv_export(data, columns, 'ressourcen') - else: - # Excel-Datei erstellen - return create_excel_export(data, columns, 'ressourcen') - - except Exception as e: - logging.error(f"Fehler beim Export: {str(e)}") - return "Fehler beim Exportieren der Ressourcen", 500 - finally: - cur.close() - conn.close() - - -@export_bp.route("/monitoring") -@login_required -def export_monitoring(): - """Exportiert Monitoring-Daten als Excel/CSV-Datei""" - conn = get_connection() - cur = conn.cursor() - - try: - # Zeitraum aus Request - hours = int(request.args.get('hours', 24)) - - # Monitoring-Daten sammeln - data = [] - columns = ['Zeitstempel', 'Lizenz-ID', 'Lizenzschlüssel', 'Kunde', 'Hardware-ID', - 'IP-Adresse', 'Ereignis-Typ', 'Schweregrad', 'Beschreibung'] - - # Query für Heartbeats und optionale Anomalien - query = """ - WITH monitoring_data AS ( - -- Lizenz-Heartbeats - SELECT - lh.timestamp, - lh.license_id, - l.license_key, - c.name as customer_name, - lh.hardware_id, - lh.ip_address, - 'Heartbeat' as event_type, - 'Normal' as severity, - 'License validation' as description - FROM license_heartbeats lh - JOIN licenses l ON l.id = lh.license_id - JOIN customers c ON c.id = l.customer_id - WHERE lh.timestamp > CURRENT_TIMESTAMP - INTERVAL '%s hours' - AND l.is_fake = false - """ - - # Check if anomaly_detections table exists - cur.execute(""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'anomaly_detections' - ) - """) - has_anomalies = cur.fetchone()[0] - - if has_anomalies: - query += """ - UNION ALL - - -- Anomalien - SELECT - ad.detected_at as timestamp, - ad.license_id, - l.license_key, - c.name as customer_name, - ad.hardware_id, - ad.ip_address, - ad.anomaly_type as event_type, - ad.severity, - ad.description - FROM anomaly_detections ad - LEFT JOIN licenses l ON l.id = ad.license_id - LEFT JOIN customers c ON c.id = l.customer_id - WHERE ad.detected_at > CURRENT_TIMESTAMP - INTERVAL '%s hours' - AND (l.is_fake = false OR l.is_fake IS NULL) - """ - params = [hours, hours] - else: - params = [hours] - - query += """ - ) - SELECT * FROM monitoring_data - ORDER BY timestamp DESC - """ - - cur.execute(query, params) - - for row in cur.fetchall(): - row_data = list(row) - # Format datetime field (timestamp ist Spalte 0) - if row_data[0]: # timestamp - row_data[0] = format_datetime_for_export(row_data[0]) - data.append(row_data) - - # Format prüfen - format_type = request.args.get('format', 'excel').lower() - - if format_type == 'csv': - # CSV-Datei erstellen - return create_csv_export(data, columns, 'monitoring') - else: - # Excel-Datei erstellen - return create_excel_export(data, columns, 'monitoring') - - except Exception as e: - logging.error(f"Fehler beim Export: {str(e)}") - return "Fehler beim Exportieren der Monitoring-Daten", 500 - finally: - cur.close() - conn.close() \ No newline at end of file diff --git a/v2_adminpanel/routes/license_routes.py b/v2_adminpanel/routes/license_routes.py deleted file mode 100644 index 1e8e3c0..0000000 --- a/v2_adminpanel/routes/license_routes.py +++ /dev/null @@ -1,506 +0,0 @@ -import os -import logging -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from dateutil.relativedelta import relativedelta -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify - -import config -from auth.decorators import login_required -from utils.audit import log_audit -from utils.network import get_client_ip -from utils.license import validate_license_key -from db import get_connection, get_db_connection, get_db_cursor -from models import get_licenses, get_license_by_id - -# Create Blueprint -license_bp = Blueprint('licenses', __name__) - - -@license_bp.route("/licenses") -@login_required -def licenses(): - from datetime import datetime, timedelta - - # Get filter parameters - search = request.args.get('search', '').strip() - data_source = request.args.get('data_source', 'real') # real, fake, all - license_type = request.args.get('license_type', '') # '', full, test - license_status = request.args.get('license_status', '') # '', active, expiring, expired, inactive - sort = request.args.get('sort', 'created_at') - order = request.args.get('order', 'desc') - page = request.args.get('page', 1, type=int) - per_page = 50 - - # Get licenses based on data source - if data_source == 'fake': - licenses_list = get_licenses(show_fake=True) - licenses_list = [l for l in licenses_list if l.get('is_fake')] - elif data_source == 'all': - licenses_list = get_licenses(show_fake=True) - else: # real - licenses_list = get_licenses(show_fake=False) - - # Type filtering - if license_type: - if license_type == 'full': - licenses_list = [l for l in licenses_list if l.get('license_type') == 'full'] - elif license_type == 'test': - licenses_list = [l for l in licenses_list if l.get('license_type') == 'test'] - - # Status filtering - if license_status: - now = datetime.now().date() - filtered_licenses = [] - - for license in licenses_list: - if license_status == 'active' and license.get('is_active'): - # Active means is_active=true, regardless of expiration date - filtered_licenses.append(license) - elif license_status == 'expired' and license.get('valid_until') and license.get('valid_until') <= now: - # Expired means past valid_until date, regardless of is_active - filtered_licenses.append(license) - elif license_status == 'inactive' and not license.get('is_active'): - # Inactive means is_active=false, regardless of date - filtered_licenses.append(license) - - licenses_list = filtered_licenses - - # Search filtering - if search: - search_lower = search.lower() - licenses_list = [l for l in licenses_list if - search_lower in str(l.get('license_key', '')).lower() or - search_lower in str(l.get('customer_name', '')).lower() or - search_lower in str(l.get('customer_email', '')).lower()] - - # Calculate pagination - total = len(licenses_list) - total_pages = (total + per_page - 1) // per_page - start = (page - 1) * per_page - end = start + per_page - licenses_list = licenses_list[start:end] - - return render_template("licenses.html", - licenses=licenses_list, - search=search, - data_source=data_source, - license_type=license_type, - license_status=license_status, - sort=sort, - order=order, - page=page, - total=total, - total_pages=total_pages, - per_page=per_page, - now=datetime.now, - timedelta=timedelta) - - -@license_bp.route("/license/edit/", 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/", 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) \ No newline at end of file diff --git a/v2_adminpanel/routes/monitoring_routes.py b/v2_adminpanel/routes/monitoring_routes.py deleted file mode 100644 index 193eeb8..0000000 --- a/v2_adminpanel/routes/monitoring_routes.py +++ /dev/null @@ -1,428 +0,0 @@ -from flask import Blueprint, render_template, jsonify, request, session, redirect, url_for -from functools import wraps -import psycopg2 -from psycopg2.extras import RealDictCursor -import os -import requests -from datetime import datetime, timedelta -import logging -from utils.partition_helper import ensure_partition_exists, check_table_exists - -# Configure logging -logger = logging.getLogger(__name__) - -# Create a function to get database connection -def get_db_connection(): - return psycopg2.connect( - host=os.environ.get('POSTGRES_HOST', 'postgres'), - database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'), - user=os.environ.get('POSTGRES_USER', 'postgres'), - password=os.environ.get('POSTGRES_PASSWORD', 'postgres') - ) - -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'user_id' not in session: - return redirect(url_for('auth.login')) - return f(*args, **kwargs) - return decorated_function - -# Create Blueprint -monitoring_bp = Blueprint('monitoring', __name__) - -@monitoring_bp.route('/monitoring') -@login_required -def unified_monitoring(): - """Unified monitoring dashboard combining live activity and anomaly detection""" - try: - conn = get_db_connection() - cur = conn.cursor(cursor_factory=RealDictCursor) - - # Initialize default values - system_status = 'normal' - status_color = 'success' - active_alerts = 0 - live_metrics = { - 'active_licenses': 0, - 'total_validations': 0, - 'unique_devices': 0, - 'unique_ips': 0, - 'avg_response_time': 0 - } - trend_data = [] - activity_stream = [] - geo_data = [] - top_licenses = [] - anomaly_distribution = [] - performance_data = [] - - # Check if tables exist before querying - has_heartbeats = check_table_exists(conn, 'license_heartbeats') - has_anomalies = check_table_exists(conn, 'anomaly_detections') - - if has_anomalies: - # Get active alerts count - cur.execute(""" - SELECT COUNT(*) as count - FROM anomaly_detections - WHERE resolved = false - AND detected_at > NOW() - INTERVAL '24 hours' - """) - active_alerts = cur.fetchone()['count'] or 0 - - # Determine system status based on alerts - if active_alerts == 0: - system_status = 'normal' - status_color = 'success' - elif active_alerts < 5: - system_status = 'warning' - status_color = 'warning' - else: - system_status = 'critical' - status_color = 'danger' - - if has_heartbeats: - # Ensure current month partition exists - ensure_partition_exists(conn, 'license_heartbeats', datetime.now()) - - # Executive summary metrics - cur.execute(""" - SELECT - COUNT(DISTINCT license_id) as active_licenses, - COUNT(*) as total_validations, - COUNT(DISTINCT hardware_id) as unique_devices, - COUNT(DISTINCT ip_address) as unique_ips, - 0 as avg_response_time - FROM license_heartbeats - WHERE timestamp > NOW() - INTERVAL '5 minutes' - """) - result = cur.fetchone() - if result: - live_metrics = result - - # Get 24h trend data for metrics - cur.execute(""" - SELECT - DATE_TRUNC('hour', timestamp) as hour, - COUNT(DISTINCT license_id) as licenses, - COUNT(*) as validations - FROM license_heartbeats - WHERE timestamp > NOW() - INTERVAL '24 hours' - GROUP BY hour - ORDER BY hour - """) - trend_data = cur.fetchall() - - # Activity stream - just validations if no anomalies table - if has_anomalies: - cur.execute(""" - WITH combined_events AS ( - -- Normal validations - SELECT - lh.timestamp, - 'validation' as event_type, - 'normal' as severity, - l.license_key, - c.name as customer_name, - lh.ip_address, - lh.hardware_id, - NULL as anomaly_type, - NULL as description - FROM license_heartbeats lh - JOIN licenses l ON l.id = lh.license_id - JOIN customers c ON c.id = l.customer_id - WHERE lh.timestamp > NOW() - INTERVAL '1 hour' - - UNION ALL - - -- Anomalies - SELECT - ad.detected_at as timestamp, - 'anomaly' as event_type, - ad.severity, - l.license_key, - c.name as customer_name, - ad.ip_address, - ad.hardware_id, - ad.anomaly_type, - ad.description - FROM anomaly_detections ad - LEFT JOIN licenses l ON l.id = ad.license_id - LEFT JOIN customers c ON c.id = l.customer_id - WHERE ad.detected_at > NOW() - INTERVAL '1 hour' - ) - SELECT * FROM combined_events - ORDER BY timestamp DESC - LIMIT 100 - """) - else: - # Just show validations - cur.execute(""" - SELECT - lh.timestamp, - 'validation' as event_type, - 'normal' as severity, - l.license_key, - c.name as customer_name, - lh.ip_address, - lh.hardware_id, - NULL as anomaly_type, - NULL as description - FROM license_heartbeats lh - JOIN licenses l ON l.id = lh.license_id - JOIN customers c ON c.id = l.customer_id - WHERE lh.timestamp > NOW() - INTERVAL '1 hour' - ORDER BY lh.timestamp DESC - LIMIT 100 - """) - activity_stream = cur.fetchall() - - # Geographic distribution - cur.execute(""" - SELECT - ip_address, - COUNT(*) as request_count, - COUNT(DISTINCT license_id) as license_count - FROM license_heartbeats - WHERE timestamp > NOW() - INTERVAL '1 hour' - GROUP BY ip_address - ORDER BY request_count DESC - LIMIT 20 - """) - geo_data = cur.fetchall() - - # Top active licenses - if has_anomalies: - cur.execute(""" - SELECT - l.id, - l.license_key, - c.name as customer_name, - COUNT(DISTINCT lh.hardware_id) as device_count, - COUNT(lh.*) as validation_count, - MAX(lh.timestamp) as last_seen, - COUNT(DISTINCT ad.id) as anomaly_count - FROM licenses l - JOIN customers c ON c.id = l.customer_id - LEFT JOIN license_heartbeats lh ON l.id = lh.license_id - AND lh.timestamp > NOW() - INTERVAL '1 hour' - LEFT JOIN anomaly_detections ad ON l.id = ad.license_id - AND ad.detected_at > NOW() - INTERVAL '24 hours' - WHERE lh.license_id IS NOT NULL - GROUP BY l.id, l.license_key, c.name - ORDER BY validation_count DESC - LIMIT 10 - """) - else: - cur.execute(""" - SELECT - l.id, - l.license_key, - c.name as customer_name, - COUNT(DISTINCT lh.hardware_id) as device_count, - COUNT(lh.*) as validation_count, - MAX(lh.timestamp) as last_seen, - 0 as anomaly_count - FROM licenses l - JOIN customers c ON c.id = l.customer_id - LEFT JOIN license_heartbeats lh ON l.id = lh.license_id - AND lh.timestamp > NOW() - INTERVAL '1 hour' - WHERE lh.license_id IS NOT NULL - GROUP BY l.id, l.license_key, c.name - ORDER BY validation_count DESC - LIMIT 10 - """) - top_licenses = cur.fetchall() - - # Performance metrics - cur.execute(""" - SELECT - DATE_TRUNC('minute', timestamp) as minute, - 0 as avg_response_time, - 0 as max_response_time, - COUNT(*) as request_count - FROM license_heartbeats - WHERE timestamp > NOW() - INTERVAL '30 minutes' - GROUP BY minute - ORDER BY minute DESC - """) - performance_data = cur.fetchall() - - if has_anomalies: - # Anomaly distribution - cur.execute(""" - SELECT - anomaly_type, - COUNT(*) as count, - MAX(severity) as max_severity - FROM anomaly_detections - WHERE detected_at > NOW() - INTERVAL '24 hours' - GROUP BY anomaly_type - ORDER BY count DESC - """) - anomaly_distribution = cur.fetchall() - - cur.close() - conn.close() - - return render_template('monitoring/unified_monitoring.html', - system_status=system_status, - status_color=status_color, - active_alerts=active_alerts, - live_metrics=live_metrics, - trend_data=trend_data, - activity_stream=activity_stream, - geo_data=geo_data, - top_licenses=top_licenses, - anomaly_distribution=anomaly_distribution, - performance_data=performance_data) - - except Exception as e: - logger.error(f"Error in unified monitoring: {str(e)}") - return render_template('error.html', - error='Fehler beim Laden des Monitorings', - details=str(e)) - -@monitoring_bp.route('/live-dashboard') -@login_required -def live_dashboard(): - """Redirect to unified monitoring dashboard""" - return redirect(url_for('monitoring.unified_monitoring')) - - -@monitoring_bp.route('/alerts') -@login_required -def alerts(): - """Show active alerts from Alertmanager""" - alerts = [] - - try: - # Get alerts from Alertmanager - response = requests.get('http://alertmanager:9093/api/v1/alerts', timeout=2) - if response.status_code == 200: - alerts = response.json() - except: - # Fallback to database anomalies if table exists - conn = get_db_connection() - if check_table_exists(conn, 'anomaly_detections'): - cur = conn.cursor(cursor_factory=RealDictCursor) - - cur.execute(""" - SELECT - ad.*, - l.license_key, - c.name as company_name - FROM anomaly_detections ad - LEFT JOIN licenses l ON l.id = ad.license_id - LEFT JOIN customers c ON c.id = l.customer_id - WHERE ad.resolved = false - ORDER BY ad.detected_at DESC - LIMIT 50 - """) - alerts = cur.fetchall() - - cur.close() - conn.close() - - return render_template('monitoring/alerts.html', alerts=alerts) - -@monitoring_bp.route('/analytics') -@login_required -def analytics(): - """Combined analytics and license server status page""" - try: - conn = get_db_connection() - cur = conn.cursor(cursor_factory=RealDictCursor) - - # Initialize default values - live_stats = [0, 0, 0, 0] - validation_rates = [] - - if check_table_exists(conn, 'license_heartbeats'): - # Get live statistics for the top cards - cur.execute(""" - SELECT - COUNT(DISTINCT license_id) as active_licenses, - COUNT(*) as total_validations, - COUNT(DISTINCT hardware_id) as unique_devices, - COUNT(DISTINCT ip_address) as unique_ips - FROM license_heartbeats - WHERE timestamp > NOW() - INTERVAL '5 minutes' - """) - live_stats_data = cur.fetchone() - live_stats = [ - live_stats_data['active_licenses'] or 0, - live_stats_data['total_validations'] or 0, - live_stats_data['unique_devices'] or 0, - live_stats_data['unique_ips'] or 0 - ] - - # Get validation rates for the chart (last 30 minutes, aggregated by minute) - cur.execute(""" - SELECT - DATE_TRUNC('minute', timestamp) as minute, - COUNT(*) as count - FROM license_heartbeats - WHERE timestamp > NOW() - INTERVAL '30 minutes' - GROUP BY minute - ORDER BY minute DESC - LIMIT 30 - """) - validation_rates = [(row['minute'].isoformat(), row['count']) for row in cur.fetchall()] - - cur.close() - conn.close() - - return render_template('monitoring/analytics.html', - live_stats=live_stats, - validation_rates=validation_rates) - - except Exception as e: - logger.error(f"Error in analytics: {str(e)}") - return render_template('error.html', - error='Fehler beim Laden der Analytics', - details=str(e)) - - -@monitoring_bp.route('/analytics/stream') -@login_required -def analytics_stream(): - """Server-sent event stream for live analytics updates""" - def generate(): - while True: - try: - conn = get_db_connection() - cur = conn.cursor(cursor_factory=RealDictCursor) - - data = {'active_licenses': 0, 'total_validations': 0, - 'unique_devices': 0, 'unique_ips': 0} - - if check_table_exists(conn, 'license_heartbeats'): - cur.execute(""" - SELECT - COUNT(DISTINCT license_id) as active_licenses, - COUNT(*) as total_validations, - COUNT(DISTINCT hardware_id) as unique_devices, - COUNT(DISTINCT ip_address) as unique_ips - FROM license_heartbeats - WHERE timestamp > NOW() - INTERVAL '5 minutes' - """) - result = cur.fetchone() - if result: - data = dict(result) - - cur.close() - conn.close() - - yield f"data: {jsonify(data).get_data(as_text=True)}\n\n" - - except Exception as e: - logger.error(f"Error in analytics stream: {str(e)}") - yield f"data: {jsonify({'error': str(e)}).get_data(as_text=True)}\n\n" - - import time - time.sleep(5) # Update every 5 seconds - - from flask import Response - return Response(generate(), mimetype="text/event-stream") \ No newline at end of file diff --git a/v2_adminpanel/routes/resource_routes.py b/v2_adminpanel/routes/resource_routes.py deleted file mode 100644 index baa70fd..0000000 --- a/v2_adminpanel/routes/resource_routes.py +++ /dev/null @@ -1,721 +0,0 @@ -import logging -from datetime import datetime -from zoneinfo import ZoneInfo -from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify, send_file - -import config -from auth.decorators import login_required -from utils.audit import log_audit -from utils.network import get_client_ip -from db import get_connection, get_db_connection, get_db_cursor - -# Create Blueprint -resource_bp = Blueprint('resources', __name__) - - -@resource_bp.route('/resources') -@login_required -def resources(): - """Zeigt die Ressourcenpool-Übersicht""" - import logging - logging.info("=== RESOURCES ROUTE CALLED ===") - - conn = get_connection() - cur = conn.cursor() - - try: - # Filter aus Query-Parametern - resource_type = request.args.get('type', 'all') - status_filter = request.args.get('status', 'all') - search_query = request.args.get('search', '') - show_fake = request.args.get('show_fake', 'false') == 'true' - - logging.info(f"Filters: type={resource_type}, status={status_filter}, search={search_query}, show_fake={show_fake}") - - # Basis-Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.is_fake, - rp.allocated_to_license, - rp.created_at, - rp.status_changed_at, - rp.status_changed_by, - c.name as customer_name, - l.license_type - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - - params = [] - - # Filter anwenden - if resource_type != 'all': - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter != 'all': - query += " AND rp.status = %s" - params.append(status_filter) - - if search_query: - query += " AND (rp.resource_value ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%']) - - if not show_fake: - query += " AND rp.is_fake = false" - - query += " ORDER BY rp.resource_type, rp.resource_value" - - cur.execute(query, params) - - resources_list = [] - rows = cur.fetchall() - logging.info(f"Query returned {len(rows)} rows") - - for row in rows: - resources_list.append({ - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'is_fake': row[4], - 'allocated_to_license': row[5], - 'created_at': row[6], - 'status_changed_at': row[7], - 'status_changed_by': row[8], - 'customer_name': row[9], - 'license_type': row[10] - }) - - # Statistiken - stats_query = """ - SELECT - resource_type, - status, - is_fake, - COUNT(*) as count - FROM resource_pools - """ - - # Apply test filter to statistics as well - if not show_fake: - stats_query += " WHERE is_fake = false" - - stats_query += " GROUP BY resource_type, status, is_fake" - - cur.execute(stats_query) - - stats = {} - for row in cur.fetchall(): - res_type = row[0] - status = row[1] - is_fake = row[2] - count = row[3] - - if res_type not in stats: - stats[res_type] = { - 'total': 0, - 'available': 0, - 'allocated': 0, - 'quarantined': 0, - 'test': 0, - 'prod': 0, - 'available_percent': 0 - } - - stats[res_type]['total'] += count - stats[res_type][status] = stats[res_type].get(status, 0) + count - if is_fake: - stats[res_type]['test'] += count - else: - stats[res_type]['prod'] += count - - # Calculate percentages - for res_type in stats: - if stats[res_type]['total'] > 0: - stats[res_type]['available_percent'] = int((stats[res_type]['available'] / stats[res_type]['total']) * 100) - - # Pagination parameters (simple defaults for now) - try: - page = int(request.args.get('page', '1') or '1') - except (ValueError, TypeError): - page = 1 - per_page = 50 - total = len(resources_list) - total_pages = (total + per_page - 1) // per_page if total > 0 else 1 - - # Sort parameters - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'asc') - - return render_template('resources.html', - resources=resources_list, - stats=stats, - resource_type=resource_type, - status_filter=status_filter, - search=search_query, # Changed from search_query to search - show_fake=show_fake, - total=total, - page=page, - total_pages=total_pages, - sort_by=sort_by, - sort_order=sort_order, - recent_activities=[], # Empty for now - datetime=datetime) # For template datetime usage - - except Exception as e: - import traceback - logging.error(f"Fehler beim Laden der Ressourcen: {str(e)}") - logging.error(f"Traceback: {traceback.format_exc()}") - flash('Fehler beim Laden der Ressourcen!', 'error') - return redirect(url_for('admin.dashboard')) - finally: - cur.close() - conn.close() - - -# Old add_resource function removed - using add_resources instead - - -@resource_bp.route('/resources/quarantine/', 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/') -@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) \ No newline at end of file diff --git a/v2_adminpanel/routes/session_routes.py b/v2_adminpanel/routes/session_routes.py deleted file mode 100644 index 0a3afe8..0000000 --- a/v2_adminpanel/routes/session_routes.py +++ /dev/null @@ -1,429 +0,0 @@ -import logging -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from flask import Blueprint, render_template, request, redirect, session, url_for, flash - -import config -from auth.decorators import login_required -from utils.audit import log_audit -from utils.network import get_client_ip -from db import get_connection, get_db_connection, get_db_cursor -from models import get_active_sessions - -# Create Blueprint -session_bp = Blueprint('sessions', __name__) - - -@session_bp.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - try: - # Get is_active sessions with calculated inactive time - cur.execute(""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY s.last_heartbeat DESC - """) - active_sessions = cur.fetchall() - - # Get recent ended sessions - cur.execute(""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY s.ended_at DESC - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions) - - except Exception as e: - logging.error(f"Error loading sessions: {str(e)}") - flash('Fehler beim Laden der Sessions!', 'error') - return redirect(url_for('admin.dashboard')) - finally: - cur.close() - conn.close() - - -@session_bp.route("/sessions/history") -@login_required -def session_history(): - """Zeigt die Session-Historie""" - conn = get_connection() - cur = conn.cursor() - - try: - # Query parameters - license_key = request.args.get('license_key', '') - username = request.args.get('username', '') - days = int(request.args.get('days', 7)) - - # Base query - query = """ - SELECT - s.id, - s.license_key, - s.username, - s.hardware_id, - s.started_at, - s.ended_at, - s.last_heartbeat, - s.is_active, - l.customer_name, - l.license_type, - l.is_test - FROM sessions s - LEFT JOIN licenses l ON s.license_key = l.license_key - WHERE 1=1 - """ - - params = [] - - # Apply filters - if license_key: - query += " AND s.license_key = %s" - params.append(license_key) - - if username: - query += " AND s.username ILIKE %s" - params.append(f'%{username}%') - - # Time filter - query += " AND s.started_at >= CURRENT_TIMESTAMP - INTERVAL '%s days'" - params.append(days) - - query += " ORDER BY s.started_at DESC LIMIT 1000" - - cur.execute(query, params) - - sessions_list = [] - for row in cur.fetchall(): - session_duration = None - if row[4] and row[5]: # started_at and ended_at - duration = row[5] - row[4] - hours = int(duration.total_seconds() // 3600) - minutes = int((duration.total_seconds() % 3600) // 60) - session_duration = f"{hours}h {minutes}m" - elif row[4] and row[7]: # started_at and is_active - duration = datetime.now(ZoneInfo("UTC")) - row[4] - hours = int(duration.total_seconds() // 3600) - minutes = int((duration.total_seconds() % 3600) // 60) - session_duration = f"{hours}h {minutes}m (aktiv)" - - sessions_list.append({ - 'id': row[0], - 'license_key': row[1], - 'username': row[2], - 'hardware_id': row[3], - 'started_at': row[4], - 'ended_at': row[5], - 'last_heartbeat': row[6], - 'is_active': row[7], - 'customer_name': row[8], - 'license_type': row[9], - 'is_test': row[10], - 'duration': session_duration - }) - - # Get unique license keys for filter dropdown - cur.execute(""" - SELECT DISTINCT s.license_key, l.customer_name - FROM sessions s - LEFT JOIN licenses l ON s.license_key = l.license_key - WHERE s.started_at >= CURRENT_TIMESTAMP - INTERVAL '30 days' - ORDER BY l.customer_name, s.license_key - """) - - available_licenses = [] - for row in cur.fetchall(): - available_licenses.append({ - 'license_key': row[0], - 'customer_name': row[1] or 'Unbekannt' - }) - - return render_template("session_history.html", - sessions=sessions_list, - available_licenses=available_licenses, - filters={ - 'license_key': license_key, - 'username': username, - 'days': days - }) - - except Exception as e: - logging.error(f"Fehler beim Laden der Session-Historie: {str(e)}") - flash('Fehler beim Laden der Session-Historie!', 'error') - return redirect(url_for('sessions.sessions')) - finally: - cur.close() - conn.close() - - -@session_bp.route("/session/end/", 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/", 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() \ No newline at end of file diff --git a/v2_adminpanel/scheduler.py b/v2_adminpanel/scheduler.py deleted file mode 100644 index 9626ed4..0000000 --- a/v2_adminpanel/scheduler.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -Scheduler module for handling background tasks -""" -import logging -from apscheduler.schedulers.background import BackgroundScheduler -import config -from utils.backup import create_backup -from db import get_connection - - -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - - -def cleanup_expired_sessions(): - """Clean up expired license sessions""" - try: - conn = get_connection() - cur = conn.cursor() - - # Get client config for timeout value - cur.execute(""" - SELECT session_timeout - FROM client_configs - WHERE client_name = 'Account Forger' - """) - result = cur.fetchone() - timeout_seconds = result[0] if result else 60 - - # Find expired sessions - cur.execute(""" - SELECT id, license_id, hardware_id, ip_address, client_version, started_at - FROM license_sessions - WHERE last_heartbeat < CURRENT_TIMESTAMP - INTERVAL '%s seconds' - """, (timeout_seconds,)) - - expired_sessions = cur.fetchall() - - if expired_sessions: - logging.info(f"Found {len(expired_sessions)} expired sessions to clean up") - - for session in expired_sessions: - # Log to history - cur.execute(""" - INSERT INTO session_history - (license_id, hardware_id, ip_address, client_version, started_at, ended_at, end_reason) - VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP, 'timeout') - """, (session[1], session[2], session[3], session[4], session[5])) - - # Delete session - cur.execute("DELETE FROM license_sessions WHERE id = %s", (session[0],)) - - conn.commit() - logging.info(f"Cleaned up {len(expired_sessions)} expired sessions") - - cur.close() - conn.close() - - except Exception as e: - logging.error(f"Error cleaning up sessions: {str(e)}") - if 'conn' in locals(): - conn.rollback() - - -def deactivate_expired_licenses(): - """Deactivate licenses that have expired""" - try: - conn = get_connection() - cur = conn.cursor() - - # Find active licenses that have expired - # Check valid_until < today (at midnight) - cur.execute(""" - SELECT id, license_key, customer_id, valid_until - FROM licenses - WHERE is_active = true - AND valid_until IS NOT NULL - AND valid_until < CURRENT_DATE - AND is_fake = false - """) - - expired_licenses = cur.fetchall() - - if expired_licenses: - logging.info(f"Found {len(expired_licenses)} expired licenses to deactivate") - - for license in expired_licenses: - license_id, license_key, customer_id, valid_until = license - - # Deactivate the license - cur.execute(""" - UPDATE licenses - SET is_active = false, - updated_at = CURRENT_TIMESTAMP - WHERE id = %s - """, (license_id,)) - - # Log to audit trail - cur.execute(""" - INSERT INTO audit_log - (timestamp, username, action, entity_type, entity_id, - old_values, new_values, additional_info) - VALUES (CURRENT_TIMESTAMP, 'system', 'DEACTIVATE', 'license', %s, - jsonb_build_object('is_active', true), - jsonb_build_object('is_active', false), - %s) - """, (license_id, f"License automatically deactivated due to expiration. Valid until: {valid_until}")) - - logging.info(f"Deactivated expired license: {license_key} (ID: {license_id})") - - conn.commit() - logging.info(f"Successfully deactivated {len(expired_licenses)} expired licenses") - else: - logging.debug("No expired licenses found to deactivate") - - cur.close() - conn.close() - - except Exception as e: - logging.error(f"Error deactivating expired licenses: {str(e)}") - if 'conn' in locals(): - conn.rollback() - - -def init_scheduler(): - """Initialize and configure the scheduler""" - scheduler = BackgroundScheduler() - - # Configure daily backup job - scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True - ) - - # Configure session cleanup job - runs every 60 seconds - scheduler.add_job( - cleanup_expired_sessions, - 'interval', - seconds=60, - id='session_cleanup', - replace_existing=True - ) - - # Configure license expiration job - runs daily at midnight - scheduler.add_job( - deactivate_expired_licenses, - 'cron', - hour=0, - minute=0, - id='license_expiration_check', - replace_existing=True - ) - - scheduler.start() - logging.info(f"Scheduler started. Daily backup scheduled at {config.SCHEDULER_CONFIG['backup_hour']:02d}:{config.SCHEDULER_CONFIG['backup_minute']:02d}") - logging.info("Session cleanup job scheduled to run every 60 seconds") - logging.info("License expiration check scheduled to run daily at midnight") - - return scheduler \ No newline at end of file diff --git a/v2_adminpanel/templates/404.html b/v2_adminpanel/templates/404.html deleted file mode 100644 index a500061..0000000 --- a/v2_adminpanel/templates/404.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Seite nicht gefunden{% endblock %} - -{% block content %} -
-
-
-
-
-

404

-

Seite nicht gefunden

-

Die angeforderte Seite konnte nicht gefunden werden.

- Zur Startseite -
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/500.html b/v2_adminpanel/templates/500.html deleted file mode 100644 index 11f70d1..0000000 --- a/v2_adminpanel/templates/500.html +++ /dev/null @@ -1,47 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Serverfehler{% endblock %} - -{% block content %} -
-
-
-
-
-
- -
-

500

-

Interner Serverfehler

-

- Es tut uns leid, aber es ist ein unerwarteter Fehler aufgetreten. - Unser Team wurde benachrichtigt und arbeitet an einer Lösung. -

- - {% if error %} -
-

Fehlermeldung: {{ error }}

-
- {% endif %} - -
- - Zum Dashboard - - -
- -
- - Fehler-ID: {{ request_id or 'Nicht verfügbar' }}
- Zeitstempel: {{ timestamp or 'Nicht verfügbar' }} -
-
-
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/add_resources.html b/v2_adminpanel/templates/add_resources.html deleted file mode 100644 index e60fe89..0000000 --- a/v2_adminpanel/templates/add_resources.html +++ /dev/null @@ -1,439 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Ressourcen hinzufügen{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-
- -
-
-

Ressourcen hinzufügen

-

Fügen Sie neue Domains, IPs oder Telefonnummern zum Pool hinzu

-
- - ← Zurück zur Übersicht - -
- -
- -
-
-
1️⃣ Ressourcentyp wählen
-
-
- -
-
-
🌐
-
Domain
- Webseiten-Adressen -
-
-
🖥️
-
IPv4
- IP-Adressen -
-
-
📱
-
Telefon
- Telefonnummern -
-
-
-
- - -
-
-
2️⃣ Ressourcen eingeben
-
-
-
- - -
- - Geben Sie jede Ressource in eine neue Zeile ein. Duplikate werden automatisch übersprungen. -
-
- - -
-
📊 Live-Vorschau
-
-
-
0
-
Gültig
-
-
-
0
-
Duplikate
-
-
-
0
-
Ungültig
-
-
- -
-
-
- - -
-
-
💡 Format-Beispiele
-
-
-
-
-
-
-
- 🌐 Domains -
-
example.com
-test-domain.net
-meine-seite.de
-subdomain.example.org
-my-website.io
-
- - Format: Ohne http(s)://
- Erlaubt: Buchstaben, Zahlen, Punkt, Bindestrich -
-
-
-
-
-
-
-
-
- 🖥️ IPv4-Adressen -
-
192.168.1.10
-192.168.1.11
-10.0.0.1
-172.16.0.5
-8.8.8.8
-
- - Format: xxx.xxx.xxx.xxx
- Bereich: 0-255 pro Oktett -
-
-
-
-
-
-
-
-
- 📱 Telefonnummern -
-
+491701234567
-+493012345678
-+33123456789
-+441234567890
-+12125551234
-
- - Format: Mit Ländervorwahl
- Start: Immer mit + -
-
-
-
-
-
- - -
- - -
-
-
- - -
- - -
-
-
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/audit_log.html b/v2_adminpanel/templates/audit_log.html deleted file mode 100644 index 91fcfba..0000000 --- a/v2_adminpanel/templates/audit_log.html +++ /dev/null @@ -1,391 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Log{% endblock %} - -{% macro sortable_header(label, field, current_sort, current_order) %} - - {% if current_sort == field %} - - {% else %} - - {% endif %} - {{ label }} - - {% if current_sort == field %} - {% if current_order == 'asc' %}↑{% else %}↓{% endif %} - {% else %} - ↕ - {% endif %} - - - -{% endmacro %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

📝 Log

-
- - -
-
-
-
-
- - -
-
- - -
-
- - -
- -
-
-
-
- - -
-
-
- - - - - - - - - - - - - {% for log in logs %} - - - - - - - - - {% endfor %} - -
ZeitstempelBenutzerAktionEntitätDetailsIP-Adresse
{{ log.timestamp.strftime('%d.%m.%Y %H:%M:%S') }}{{ log.username }} - - {% 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 %} - - - {{ log.entity_type }} - {% if log.entity_id %} - #{{ log.entity_id }} - {% endif %} - - {% if log.additional_info %} -
{{ log.additional_info }}
- {% endif %} - - {% if log.old_values and log.action == 'DELETE' %} -
- Gelöschte Werte -
- {% for key, value in log.old_values.items() %} - {{ key }}: {{ value }}
- {% endfor %} -
-
- {% elif log.old_values and log.new_values and log.action == 'UPDATE' %} -
- Änderungen anzeigen -
- Vorher:
- {% for key, value in log.old_values.items() %} - {% if log.new_values[key] != value %} - {{ key }}: {{ value }}
- {% endif %} - {% endfor %} -
- Nachher:
- {% for key, value in log.new_values.items() %} - {% if log.old_values[key] != value %} - {{ key }}: {{ value }}
- {% endif %} - {% endfor %} -
-
- {% elif log.new_values and log.action == 'CREATE' %} -
- Erstellte Werte -
- {% for key, value in log.new_values.items() %} - {{ key }}: {{ value }}
- {% endfor %} -
-
- {% endif %} -
- {{ log.ip_address or '-' }} -
- - {% if not logs %} -
-

Keine Audit-Log-Einträge gefunden.

-
- {% endif %} -
- - - {% if total_pages > 1 %} - - {% endif %} -
-
-
-{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/backup_codes.html b/v2_adminpanel/templates/backup_codes.html deleted file mode 100644 index 17e7616..0000000 --- a/v2_adminpanel/templates/backup_codes.html +++ /dev/null @@ -1,228 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Backup-Codes{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-
- -
-
-

2FA erfolgreich aktiviert!

-

Ihre Zwei-Faktor-Authentifizierung ist jetzt aktiv.

-
- - -
-
-

- ⚠️ - Wichtig: Ihre Backup-Codes -

-
-
-
- Was sind Backup-Codes?
- Diese Codes ermöglichen Ihnen den Zugang zu Ihrem Account, falls Sie keinen Zugriff auf Ihre Authenticator-App haben. - Jeder Code kann nur einmal verwendet werden. -
- - -
-
Ihre 8 Backup-Codes:
-
- {% for code in backup_codes %} -
-
{{ code }}
-
- {% endfor %} -
-
- - -
- - - -
- -
- - -
-
-
-
❌ Nicht empfohlen:
-
    -
  • Im selben Passwort-Manager wie Ihr Passwort
  • -
  • Als Foto auf Ihrem Handy
  • -
  • In einer unverschlüsselten Datei
  • -
  • Per E-Mail an sich selbst
  • -
-
-
-
-
-
✅ Empfohlen:
-
    -
  • Ausgedruckt in einem Safe
  • -
  • In einem separaten Passwort-Manager
  • -
  • Verschlüsselt auf einem USB-Stick
  • -
  • An einem sicheren Ort zu Hause
  • -
-
-
-
- - -
-
- - -
- -
-
-
-
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/backups.html b/v2_adminpanel/templates/backups.html deleted file mode 100644 index 983b0f2..0000000 --- a/v2_adminpanel/templates/backups.html +++ /dev/null @@ -1,301 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Backup-Verwaltung{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

💾 Backup-Verwaltung

-
-
-
- - -
-
-
-
-
📅 Letztes erfolgreiches Backup
- {% if last_backup %} -

Zeitpunkt: {{ last_backup.id.strftime('%d.%m.%Y %H:%M:%S') }}

-

Größe: {{ (last_backup.filename / 1024 / 1024)|round(2) }} MB

-

Dauer: {{ last_backup.filesize|round(1) }} Sekunden

- {% else %} -

Noch kein Backup vorhanden

- {% endif %} -
-
-
-
-
-
-
🔧 Backup-Aktionen
- -

- Automatische Backups: Täglich um 03:00 Uhr -

-
-
-
-
- - -
-
-
📋 Backup-Historie
-
-
-
- - - - - - - - - - - - - - - {% for backup in backups %} - - - - - - - - - - - {% endfor %} - -
ZeitstempelDateinameGrößeTypStatusErstellt vonDetailsAktionen
{{ backup.created_at.strftime('%d.%m.%Y %H:%M:%S') }} - {{ backup.filename }} - {% if backup.is_encrypted %} - 🔒 Verschlüsselt - {% endif %} - - {% if backup.filesize %} - {{ (backup.filesize / 1024 / 1024)|round(2) }} MB - {% else %} - - - {% endif %} - - {% if backup.backup_type == 'manual' %} - Manuell - {% else %} - Automatisch - {% endif %} - - {% if backup.status == 'success' %} - ✅ Erfolgreich - {% elif backup.status == 'failed' %} - ❌ Fehlgeschlagen - {% else %} - ⏳ In Bearbeitung - {% endif %} - {{ backup.created_by }} - {% if backup.tables_count and backup.records_count %} - - {{ backup.tables_count }} Tabellen
- {{ backup.records_count }} Datensätze
- {% if backup.duration_seconds %} - {{ backup.duration_seconds|round(1) }}s - {% endif %} -
- {% else %} - - - {% endif %} -
- {% if backup.status == 'success' %} -
- - 📥 Download - - - -
- {% endif %} -
- - {% if not backups %} -
-

Noch keine Backups vorhanden.

-
- {% endif %} -
-
-
-
- - - -{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/base.html b/v2_adminpanel/templates/base.html deleted file mode 100644 index 3a77ef3..0000000 --- a/v2_adminpanel/templates/base.html +++ /dev/null @@ -1,705 +0,0 @@ - - - - - - {% block title %}Admin Panel{% endblock %} - Lizenzverwaltung - - - - - {% block extra_css %}{% endblock %} - - - - - - - - - -
- -
- {% block content %}{% endblock %} -
-
- - - - - - - - - - - - - - {% block extra_js %}{% endblock %} - - \ No newline at end of file diff --git a/v2_adminpanel/templates/batch_form.html b/v2_adminpanel/templates/batch_form.html deleted file mode 100644 index 51f1d78..0000000 --- a/v2_adminpanel/templates/batch_form.html +++ /dev/null @@ -1,514 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Batch-Lizenzen erstellen{% endblock %} - -{% block content %} -
-
-

🔑 Batch-Lizenzen erstellen

- ← Zurück zur Übersicht -
- -
- ℹ️ Batch-Generierung: Erstellen Sie mehrere Lizenzen auf einmal für einen Kunden. - Die Lizenzen werden automatisch generiert und können anschließend als CSV exportiert werden. -
- - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - - {% endfor %} - {% endif %} - {% endwith %} - - - - -
-
-
- - -
- - - -
- - -
Max. 100 Lizenzen pro Batch
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
- - -
-
-
- Ressourcen-Zuweisung pro Lizenz - -
-
-
-
-
- - - - Verfügbar: - - | Benötigt: - - -
-
- - - - Verfügbar: - - | Benötigt: - - -
-
- - - - Verfügbar: - - | Benötigt: - - -
-
- -
-
- - -
-
-
- Gerätelimit pro Lizenz -
-
-
-
-
- - - - Jede generierte Lizenz kann auf maximal dieser Anzahl von Geräten gleichzeitig aktiviert werden. - -
-
-
-
- - -
- - -
-
- - - -
- - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/batch_result.html b/v2_adminpanel/templates/batch_result.html deleted file mode 100644 index 7a96174..0000000 --- a/v2_adminpanel/templates/batch_result.html +++ /dev/null @@ -1,156 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Batch-Lizenzen generiert{% endblock %} - -{% block content %} -
-
-

✅ Batch-Lizenzen erfolgreich generiert

- -
- -
-
🎉 {{ licenses|length }} Lizenzen wurden erfolgreich generiert!
-

Die Lizenzen wurden in der Datenbank gespeichert und dem Kunden zugeordnet.

-
- - -
-
-
📋 Kundeninformationen
-
-
-
-
-

Kunde: {{ customer }}

-

E-Mail: {{ email or 'Nicht angegeben' }}

-
-
-

Gültig von: {{ valid_from }}

-

Gültig bis: {{ valid_until }}

-
-
-
-
- - -
-
-
📥 Export-Optionen
-
-
-

Exportieren Sie die generierten Lizenzen für den Kunden:

-
- - 📄 Als CSV exportieren - - - -
-
-
- - -
-
-
🔑 Generierte Lizenzen
-
-
-
- - - - - - - - - - - {% for license in licenses %} - - - - - - - {% endfor %} - -
#LizenzschlüsselTypAktionen
{{ loop.index }}{{ license.key }} - {% if license.type == 'full' %} - Vollversion - {% else %} - Testversion - {% endif %} - - -
-
-
-
- - -
- 💡 Tipp: Die generierten Lizenzen sind sofort aktiv und können verwendet werden. - Sie finden alle Lizenzen auch in der Lizenzübersicht. -
-
- - - - - - - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/blocked_ips.html b/v2_adminpanel/templates/blocked_ips.html deleted file mode 100644 index 35a9e85..0000000 --- a/v2_adminpanel/templates/blocked_ips.html +++ /dev/null @@ -1,98 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Gesperrte IPs{% endblock %} - -{% block content %} -
-
-

🔒 Gesperrte IPs

-
-
-
- -
-
-
IP-Sperrverwaltung
-
-
- {% if blocked_ips %} -
- - - - - - - - - - - - - - - - {% for ip in blocked_ips %} - - - - - - - - - - - - {% endfor %} - -
IP-AdresseVersucheErster VersuchLetzter VersuchGesperrt bisLetzter UserLetzte MeldungStatusAktionen
{{ ip.ip_address }}{{ ip.attempt_count }}{{ ip.first_attempt }}{{ ip.last_attempt }}{{ ip.blocked_until }}{{ ip.last_username or '-' }}{{ ip.last_error or '-' }} - {% if ip.is_active %} - GESPERRT - {% else %} - ABGELAUFEN - {% endif %} - -
- {% if ip.is_active %} -
- - -
- {% endif %} -
- - -
-
-
-
- {% else %} -
- Keine gesperrten IPs vorhanden. - Das System läuft ohne Sicherheitsvorfälle. -
- {% endif %} -
-
- -
-
-
ℹ️ Informationen
-
    -
  • IPs werden nach {{ 5 }} fehlgeschlagenen Login-Versuchen für 24 Stunden gesperrt.
  • -
  • Nach 2 Versuchen wird ein CAPTCHA angezeigt.
  • -
  • Bei 5 Versuchen wird eine E-Mail-Benachrichtigung gesendet (wenn aktiviert).
  • -
  • Gesperrte IPs können manuell entsperrt werden.
  • -
  • Die Fehlermeldungen werden zufällig ausgewählt für zusätzliche Verwirrung.
  • -
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/create_customer.html b/v2_adminpanel/templates/create_customer.html deleted file mode 100644 index 03a7eff..0000000 --- a/v2_adminpanel/templates/create_customer.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Neuer Kunde{% endblock %} - -{% block content %} -
-
-

👤 Neuer Kunde anlegen

- ← Zurück zur Übersicht -
- -
-
-
-
-
- - -
Der Name des Kunden oder der Firma
-
-
- - -
Kontakt-E-Mail-Adresse des Kunden
-
-
- -
- - -
- - - -
- - Abbrechen -
-
-
-
-
- - -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} - - {% endfor %} -
- {% endif %} -{% endwith %} -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/customers.html b/v2_adminpanel/templates/customers.html deleted file mode 100644 index aaed406..0000000 --- a/v2_adminpanel/templates/customers.html +++ /dev/null @@ -1,185 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Kundenverwaltung{% endblock %} - -{% macro sortable_header(label, field, current_sort, current_order) %} - - {% if current_sort == field %} - - {% else %} - - {% endif %} - {{ label }} - - {% if current_sort == field %} - {% if current_order == 'asc' %}↑{% else %}↓{% endif %} - {% else %} - ↕ - {% endif %} - - - -{% endmacro %} - -{% block content %} -
-
-

Kundenverwaltung

-
- - -
-
-
-
- - -
-
-
- - -
-
- -
- {% if search %} -
- Suchergebnisse für: {{ search }} - ✖ Suche zurücksetzen -
- {% endif %} -
-
- -
-
-
- - - - {{ 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) }} - - - - - {% for customer in customers %} - - - - - - - - - {% endfor %} - -
Aktionen
{{ customer.id }} - {{ customer.name }} - {% if customer.is_test %} - 🧪 - {% endif %} - {{ customer.email or '-' }}{{ customer.created_at.strftime('%d.%m.%Y %H:%M') }} - {{ customer.active_licenses }}/{{ customer.license_count }} - -
- ✏️ Bearbeiten - {% if customer.license_count == 0 %} -
- -
- {% else %} - - {% endif %} -
-
- - {% if not customers %} -
- {% if search %} -

Keine Kunden gefunden für: {{ search }}

- Alle Kunden anzeigen - {% else %} -

Noch keine Kunden vorhanden.

- Erste Lizenz erstellen - {% endif %} -
- {% endif %} -
- - - {% if total_pages > 1 %} - - {% endif %} -
-
-
-{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/customers_licenses.html b/v2_adminpanel/templates/customers_licenses.html deleted file mode 100644 index a9fd5e7..0000000 --- a/v2_adminpanel/templates/customers_licenses.html +++ /dev/null @@ -1,1153 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Kunden & Lizenzen - AccountForger Admin{% endblock %} - - - -{% block content %} -
- - -
- -
-
-
-
- Kunden - {{ customers|length if customers else 0 }} -
-
-
- -
- -
- - -
-
- - -
- {% if customers %} - {% for customer in customers %} -
-
-
-
{{ customer.name }}
- {{ customer.email }} -
-
- {{ customer.license_count }} - {% if customer.active_licenses > 0 %} - {{ customer.active_licenses }} - {% endif %} - {% if customer.test_licenses > 0 %} - {{ customer.test_licenses }} - {% endif %} -
-
-
- {% endfor %} - {% else %} -
- -

Keine Kunden vorhanden

- Erstellen Sie eine neue Lizenz, um automatisch einen Kunden anzulegen. - - Neue Lizenz erstellen - -
- {% endif %} -
-
-
-
- - -
-
-
-
Wählen Sie einen Kunden aus
-
-
-
-
- -

Wählen Sie einen Kunden aus der Liste aus

-
-
-
-
-
-
-
- - - - - - - - - - - -{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/dashboard.html b/v2_adminpanel/templates/dashboard.html deleted file mode 100644 index 98787b1..0000000 --- a/v2_adminpanel/templates/dashboard.html +++ /dev/null @@ -1,477 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Dashboard{% endblock %} - - - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-

Dashboard

- - - - - -
-
-
-
-
Lizenztypen
-
-
-

{{ stats.full_licenses }}

-

Vollversionen

-
-
-

{{ stats.fake_licenses }}

-

Testversionen

-
-
- {% if stats.fake_data_count > 0 or stats.fake_customers_count > 0 or stats.fake_resources_count > 0 %} -
- - Fake-Daten: - {{ stats.fake_data_count }} Lizenzen, - {{ stats.fake_customers_count }} Kunden, - {{ stats.fake_resources_count }} Ressourcen - -
- {% endif %} -
-
-
-
-
-
-
Lizenzstatus
-
-
-

{{ stats.active_licenses }}

-

Aktiv

-
-
-

{{ stats.expired_licenses }}

-

Abgelaufen

-
-
-

{{ stats.inactive_licenses }}

-

Deaktiviert

-
-
-
-
-
-
- - -
-
-
-
-
- Service Status - {% if service_health.overall_status == 'healthy' %} - Alle Systeme betriebsbereit - {% elif service_health.overall_status == 'partial' %} - Teilweise Störungen - {% else %} - Kritische Störungen - {% endif %} -
-
-
-
- {% for service in service_health.services %} -
-
-
{{ service.icon }}
-
-
{{ service.name }}
-
- - {% if service.status == 'healthy' %}Betriebsbereit{% elif service.status == 'unhealthy' %}Eingeschränkt{% else %}Ausgefallen{% endif %} - - {% if service.response_time %} - {{ service.response_time }}ms - {% endif %} -
- {% if service.details %} - {{ service.details }} - {% endif %} -
-
-
- {% endfor %} -
-
-
-
-
- - - - - - {% if stats.recent_security_events %} -
-
-
-
-
🚨 Letzte Sicherheitsereignisse
-
-
-
- - - - - - - - - - - - {% for event in stats.recent_security_events %} - - - - - - - - {% endfor %} - -
ZeitIP-AdresseVersucheFehlermeldungStatus
{{ event.last_attempt }}{{ event.ip_address }}{{ event.attempt_count }}{{ event.error_message }} - {% if event.blocked_until %} - Gesperrt bis {{ event.blocked_until }} - {% else %} - Aktiv - {% endif %} -
-
-
-
-
-
- {% endif %} - - -
-
-
-
-
- Resource Pool Status -
-
-
-
- {% if resource_stats %} - {% for type, data in resource_stats.items() %} -
-
-
- - - -
-
-
- {{ type|upper }} -
-
- - {{ data.available }} / {{ data.total }} verfügbar - - - {{ data.available_percent }}% - -
-
-
-
-
- - - {{ data.allocated }} zugeteilt - - - {% if data.quarantine > 0 %} - - - {{ data.quarantine }} in Quarantäne - - - {% endif %} -
-
-
-
- {% endfor %} - {% else %} -
- -

Keine Ressourcen im Pool vorhanden.

- - Ressourcen hinzufügen - -
- {% endif %} -
- {% if resource_warning %} - - {% endif %} -
-
-
-
- -
- -
-
-
-
⏰ Bald ablaufende Lizenzen
-
-
- {% if stats.expiring_licenses %} -
- - - - - - - - - - {% for license in stats.expiring_licenses %} - - - - - - {% endfor %} - -
KundeLizenzTage
{{ license[2] }}{{ license[1][:8] }}...{{ license[4] }} Tage
-
- {% else %} -

Keine Lizenzen laufen in den nächsten 30 Tagen ab.

- {% endif %} -
-
-
- - -
-
-
-
🆕 Zuletzt erstellte Lizenzen
-
-
- {% if stats.recent_licenses %} -
- - - - - - - - - - {% for license in stats.recent_licenses %} - - - - - - {% endfor %} - -
KundeLizenzStatus
{{ license[2] }}{{ license[1][:8] }}... - {% if license[4] == 'deaktiviert' %} - 🚫 Deaktiviert - {% elif license[4] == 'abgelaufen' %} - ⚠️ Abgelaufen - {% elif license[4] == 'läuft bald ab' %} - ⏰ Läuft bald ab - {% else %} - ✅ Aktiv - {% endif %} -
-
- {% else %} -

Noch keine Lizenzen erstellt.

- {% endif %} -
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/edit_customer.html b/v2_adminpanel/templates/edit_customer.html deleted file mode 100644 index e5db27d..0000000 --- a/v2_adminpanel/templates/edit_customer.html +++ /dev/null @@ -1,103 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Kunde bearbeiten{% endblock %} - -{% block content %} -
-
-

Kunde bearbeiten

- -
- -
-
-
- {% if request.args.get('show_fake') == 'true' %} - - {% endif %} -
-
- - -
-
- - -
-
- -

{{ customer.created_at.strftime('%d.%m.%Y %H:%M') }}

-
-
- -
- - -
- -
- - Abbrechen -
-
-
-
- -
-
-
Lizenzen des Kunden
-
-
- {% if licenses %} -
- - - - - - - - - - - - - {% for license in licenses %} - - - - - - - - - {% endfor %} - -
LizenzschlüsselTypGültig vonGültig bisAktivAktionen
{{ license[1] }} - {% if license[2] == 'full' %} - Vollversion - {% else %} - Testversion - {% endif %} - {{ license[3].strftime('%d.%m.%Y') }}{{ license[4].strftime('%d.%m.%Y') }} - {% if license[5] %} - - {% else %} - - {% endif %} - - Bearbeiten -
-
- {% else %} -

Dieser Kunde hat noch keine Lizenzen.

- {% endif %} -
-
-
-{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/edit_license.html b/v2_adminpanel/templates/edit_license.html deleted file mode 100644 index 4fcef2c..0000000 --- a/v2_adminpanel/templates/edit_license.html +++ /dev/null @@ -1,88 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Lizenz bearbeiten{% endblock %} - -{% block content %} -
-
-

Lizenz bearbeiten

- -
- -
-
-
- {% if request.args.get('show_fake') == 'true' %} - - {% endif %} -
-
- - - Kunde kann nicht geändert werden -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- - - Maximale Anzahl gleichzeitig aktiver Geräte -
-
- - - -
- - Abbrechen -
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/error.html b/v2_adminpanel/templates/error.html deleted file mode 100644 index f37ab0e..0000000 --- a/v2_adminpanel/templates/error.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Fehler{% endblock %} - -{% block content %} -
-
-
-
-
-

- - Fehler -

-
-
- - -
-
Was können Sie tun?
-
    -
  • Versuchen Sie die Aktion erneut
  • -
  • Überprüfen Sie Ihre Eingaben
  • -
  • Kontaktieren Sie den Support mit der Anfrage-ID
  • -
-
- -
- - Zum Dashboard - - - -
-
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/index.html b/v2_adminpanel/templates/index.html deleted file mode 100644 index faa7c21..0000000 --- a/v2_adminpanel/templates/index.html +++ /dev/null @@ -1,578 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Admin Panel{% endblock %} - -{% block content %} -
-
-

Neue Lizenz erstellen

- ← Zurück zur Übersicht -
- - - - -
-
-
- - -
- - -
- -
- - -
-
Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- Ressourcen-Zuweisung - -
-
-
-
-
- - - - Verfügbar: - - -
-
- - - - Verfügbar: - - -
-
- - - - Verfügbar: - - -
-
- -
-
- - -
-
-
- Gerätelimit -
-
-
-
-
- - - - Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann. - -
-
-
-
- - -
- -
-
-
- - -{% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} - - {% endfor %} -
- {% endif %} -{% endwith %} - - -{% endblock %} diff --git a/v2_adminpanel/templates/license_analytics.html b/v2_adminpanel/templates/license_analytics.html deleted file mode 100644 index d938a73..0000000 --- a/v2_adminpanel/templates/license_analytics.html +++ /dev/null @@ -1,445 +0,0 @@ -{% extends "base.html" %} - -{% block title %}License Analytics{% endblock %} - -{% block content %} -
-
-

License Analytics

- - - -
-
- - -
-
-
-
-
Aktive Lizenzen
-

-

- In den letzten {{ days }} Tagen -
-
-
-
-
-
-
Aktive Geräte
-

-

- Unique Hardware IDs -
-
-
-
-
-
-
Validierungen
-

-

- Gesamt in {{ days }} Tagen -
-
-
-
-
-
-
Churn Risk
-

-

- Kunden mit hohem Risiko -
-
-
-
- - -
-
-
-
-
Nutzungstrends
-
-
- -
-
-
-
- - -
-
-
-
-
Performance Metriken
-
-
- -
-
-
-
-
-
-
Lizenzverteilung
-
-
- -
-
-
-
- - -
-
-
-
-
Revenue Analysis
-
-
-
- - - - - - - - - - - - - - -
LizenztypAnzahl LizenzenAktive LizenzenGesamtumsatzAktiver UmsatzInaktiver Umsatz
-
-
-
-
-
- - -
-
-
-
-
Top 10 Aktive Lizenzen
-
-
-
- - - - - - - - - - - - -
LizenzKundeGeräteValidierungen
-
-
-
-
-
-
-
-
Churn Risk Kunden
-
-
-
- - - - - - - - - - - - -
KundeLizenzenLetzte AktivitätRisk Level
-
-
-
-
-
- - -
-
-
-
-
Nutzungsmuster (Heatmap)
-
-
- -
-
-
-
- -{% endblock %} - -{% block scripts %} - - - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/license_anomalies.html b/v2_adminpanel/templates/license_anomalies.html deleted file mode 100644 index dfa2741..0000000 --- a/v2_adminpanel/templates/license_anomalies.html +++ /dev/null @@ -1,241 +0,0 @@ -{% extends "base.html" %} - -{% block title %}License Anomalien{% endblock %} - -{% block content %} -
-
-

Anomalie-Erkennung

- - -
-
-
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
- - -
-
-
-
-
Kritisch
-

- {% 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 }} -

-
-
-
-
-
-
-
Hoch
-

- {% 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 }} -

-
-
-
-
-
-
-
Mittel
-

- {% 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 }} -

-
-
-
-
-
-
-
Niedrig
-

- {% 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 }} -

-
-
-
-
- - -
-
-
Anomalien
-
-
-
- - - - - - - - - - - - - - {% for anomaly in anomalies %} - - - - - - - - - - {% else %} - - - - {% endfor %} - -
ZeitpunktLizenzTypSchweregradDetailsStatusAktionen
{{ anomaly[3].strftime('%d.%m.%Y %H:%M') if anomaly[3] else '-' }} - {% if anomaly[8] %} - {{ anomaly[8][:8] }}...
- {{ anomaly[9] }} - {% else %} - Unbekannt - {% endif %} -
- {{ anomaly[5] }} - - - {{ anomaly[6] }} - - - - - {% if anomaly[2] %} - Gelöst - {% else %} - Ungelöst - {% endif %} - - {% if not anomaly[2] %} - - {% else %} - {{ anomaly[4].strftime('%d.%m %H:%M') if anomaly[4] else '' }} - {% endif %} -
Keine Anomalien gefunden
-
-
-
-
-
- - - - - - -{% endblock %} - -{% block scripts %} - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/license_config.html b/v2_adminpanel/templates/license_config.html deleted file mode 100644 index 82ce1c3..0000000 --- a/v2_adminpanel/templates/license_config.html +++ /dev/null @@ -1,373 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Administration{% endblock %} - -{% block content %} -
-
-

Administration

-
-
- - -
-
-
-
-
Account Forger Konfiguration
-
-
-
-
- - -
-
- - -
-
- -
-
-
-
-
- -
-
-
-
Aktive Sitzungen
-
- {{ active_sessions|length if active_sessions else 0 }} - Alle anzeigen -
-
-
-
- - - - - - - - - - - {% if active_sessions %} - {% for session in active_sessions[:5] %} - - - - - - - {% endfor %} - {% else %} - - - - {% endif %} - -
KundeVersionLetztes HeartbeatStatus
{{ session[3] or 'Unbekannt' }}{{ session[6] }}{{ session[8].strftime('%H:%M:%S') }} - {% if session[9] < 90 %} - Aktiv - {% else %} - Timeout - {% endif %} -
Keine aktiven Sitzungen
-
-
-
-
-
- - -
-
-
-
-
API Key für Account Forger
-
-
- {% if system_api_key %} -
- Dies ist der einzige API Key, den Account Forger benötigt. - Verwenden Sie diesen Key im Header X-API-Key für alle API-Anfragen. -
-
-
- -
- - -
-
-
- -
-
-
Key Informationen:
-
    -
  • Erstellt: - {% if system_api_key.created_at %} - {{ system_api_key.created_at.strftime('%d.%m.%Y %H:%M') }} - {% else %} - N/A - {% endif %} -
  • -
  • Erstellt von: {{ system_api_key.created_by or 'System' }}
  • - {% if system_api_key.regenerated_at %} -
  • Zuletzt regeneriert: - {{ system_api_key.regenerated_at.strftime('%d.%m.%Y %H:%M') }} -
  • -
  • Regeneriert von: {{ system_api_key.regenerated_by }}
  • - {% else %} -
  • Zuletzt regeneriert: Nie
  • - {% endif %} -
-
-
-
Nutzungsstatistiken:
-
    -
  • Letzte Nutzung: - {% if system_api_key.last_used_at %} - {{ system_api_key.last_used_at.strftime('%d.%m.%Y %H:%M') }} - {% else %} - Noch nie genutzt - {% endif %} -
  • -
  • Gesamte Anfragen: {{ system_api_key.usage_count or 0 }}
  • -
-
-
- -
- -
-
-
- - - - Dies wird den aktuellen Key ungültig machen! - -
-
-
- -
-
- Verwendungsbeispiel anzeigen -
-
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"}
-)
-
-
-
- {% else %} -
- Kein System API Key gefunden! - Bitte kontaktieren Sie den Administrator. -
- {% endif %} -
-
-
-
- - -
- -
-

- -

-
-
-
- - - - - - - - - - - {% for flag in feature_flags %} - - - - - - - {% else %} - - - - {% endfor %} - -
FeatureBeschreibungStatusAktion
{{ flag[1] }}{{ flag[2] }} - {% if flag[3] %} - Aktiv - {% else %} - Inaktiv - {% endif %} - -
- -
-
Keine Feature Flags konfiguriert
-
-
-
-
- - -
-

- -

-
-
-
- - - - - - - - - - - - {% for limit in rate_limits %} - - - - - - - - {% else %} - - - - {% endfor %} - -
API KeyRequests/MinuteRequests/StundeRequests/TagBurst Size
{{ limit[1][:12] }}...{{ limit[2] }}{{ limit[3] }}{{ limit[4] }}{{ limit[5] }}
Keine Rate Limits konfiguriert
-
-
-
-
-
-{% endblock %} - -{% block scripts %} - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/license_sessions.html b/v2_adminpanel/templates/license_sessions.html deleted file mode 100644 index 1a9b311..0000000 --- a/v2_adminpanel/templates/license_sessions.html +++ /dev/null @@ -1,151 +0,0 @@ -{% extends "base.html" %} -{% block title %}Aktive Lizenzsitzungen{% endblock %} - -{% block content %} -
-

Lizenzsitzungen

- -
-
-
-
-
Aktive Sitzungen
-
-
- {% if active_sessions %} -
- - - - - - - - - - - - - - - - {% for session in active_sessions %} - - - - - - - - - - - - {% endfor %} - -
LizenzschlüsselKundeHardware IDIP-AdresseVersionGestartetLetztes HeartbeatStatusAktion
{{ session[2][:8] }}...{{ session[3] or 'Unbekannt' }}{{ session[4][:12] }}...{{ session[5] or 'Unbekannt' }}{{ session[6] }}{{ session[7].strftime('%H:%M:%S') }}{{ session[8].strftime('%H:%M:%S') }} - {% if session[9] < 90 %} - Aktiv - {% elif session[9] < 120 %} - Timeout bald - {% else %} - Timeout - {% endif %} - - {% if session.get('username') in ['rac00n', 'w@rh@mm3r'] %} -
- -
- {% endif %} -
-
- {% else %} -

Keine aktiven Sitzungen vorhanden.

- {% endif %} -
-
-
-
- -
-
-
-
-
Sitzungsverlauf (letzte 24 Stunden)
-
-
- {% if session_history %} -
- - - - - - - - - - - - - - - - {% for hist in session_history %} - - - - - - - - - - - - {% endfor %} - -
LizenzschlüsselKundeHardware IDIP-AdresseVersionGestartetBeendetDauerGrund
{{ hist[1][:8] }}...{{ hist[2] or 'Unbekannt' }}{{ hist[3][:12] }}...{{ hist[4] or 'Unbekannt' }}{{ hist[5] }}{{ hist[6].strftime('%d.%m %H:%M') }}{{ hist[7].strftime('%d.%m %H:%M') }} - {% set duration = hist[9] %} - {% if duration < 60 %} - {{ duration|int }}s - {% elif duration < 3600 %} - {{ (duration / 60)|int }}m - {% else %} - {{ (duration / 3600)|round(1) }}h - {% endif %} - - {% if hist[8] == 'normal' %} - Normal - {% elif hist[8] == 'timeout' %} - Timeout - {% elif hist[8] == 'forced' %} - Erzwungen - {% elif hist[8] == 'replaced' %} - Ersetzt - {% else %} - {{ hist[8] }} - {% endif %} -
-
- {% else %} -

Keine Sitzungen in den letzten 24 Stunden.

- {% endif %} -
-
-
-
- - -
- - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/licenses.html b/v2_adminpanel/templates/licenses.html deleted file mode 100644 index cd54f5c..0000000 --- a/v2_adminpanel/templates/licenses.html +++ /dev/null @@ -1,455 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Lizenzübersicht{% endblock %} - -{% macro sortable_header(label, field, current_sort, current_order) %} - - {% 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') %} - - - {{ label }} - - {% if current_sort == field %} - {% if current_order == 'asc' %}↑{% else %}↓{% endif %} - {% else %} - ↕ - {% endif %} - - - -{% endmacro %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

Lizenzübersicht

-
- - -
-
-
- -
-
- - -
-
- - -
-
- - -
- -
- - -
-
- - -
-
- -
-
- - - - - -
- {% if search or request.args.get('data_source') != 'real' or request.args.get('license_type') or request.args.get('license_status') %} -
-
- - Gefiltert: {{ total }} Ergebnisse - -
- {% if search %} - - {{ 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) %} - × - - {% endif %} -
-
-
- {% endif %} -
-
- -
-
-
- - - - - {{ 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) }} - - - - - {% for license in licenses %} - - - - - - - - - - - - - - {% endfor %} - -
- - Aktionen
- - {{ license.id }} -
- {{ license.license_key }} - -
-
- {{ license.customer_name }} - {% if license.is_fake %} - 🧪 - {% endif %} - - - {% if license.license_type == 'full' %} - Vollversion - {% else %} - Testversion - {% endif %} - {{ license.valid_from.strftime('%d.%m.%Y') }}{{ license.valid_until.strftime('%d.%m.%Y') }} - {% if not license.is_active %} - ❌ Deaktiviert - {% elif license.valid_until < now().date() %} - ⚠️ Abgelaufen - {% else %} - ✅ Aktiv - {% endif %} - -
- -
-
-
- ✏️ Bearbeiten -
- -
-
-
- - {% if not licenses %} -
- {% if search %} -

Keine Lizenzen gefunden für: {{ search }}

- Alle Lizenzen anzeigen - {% else %} -

Noch keine Lizenzen vorhanden.

- Erste Lizenz erstellen - {% endif %} -
- {% endif %} -
- - - {% if total_pages > 1 %} - - {% endif %} -
-
-
- - -
-
- 0 Lizenzen ausgewählt -
-
- - - -
-
-{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/login.html b/v2_adminpanel/templates/login.html deleted file mode 100644 index f98cd96..0000000 --- a/v2_adminpanel/templates/login.html +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - Admin Login - Lizenzverwaltung - - - - -
-
-
-
-
-

🔐 Admin Login

- - {% if error %} -
- {{ error }} -
- {% endif %} - - {% if attempts_left is defined and attempts_left > 0 and attempts_left < 5 %} -
- ⚠️ Noch {{ attempts_left }} Versuch(e) bis zur IP-Sperre! -
- {% endif %} - -
-
- - -
-
- - -
- - {% if show_captcha and recaptcha_site_key %} -
-
-
- {% endif %} - - -
- -
- 🛡️ Geschützt durch Rate-Limiting und IP-Sperre -
-
-
-
-
-
- - {% if show_captcha and recaptcha_site_key %} - - {% endif %} - - \ No newline at end of file diff --git a/v2_adminpanel/templates/monitoring/alerts.html b/v2_adminpanel/templates/monitoring/alerts.html deleted file mode 100644 index 197185a..0000000 --- a/v2_adminpanel/templates/monitoring/alerts.html +++ /dev/null @@ -1,322 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Alerts{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

Alerts

-

Aktive Warnungen und Anomalien

-
-
- -
-
- - -
-
-
-
{{ alerts|selectattr('severity', 'equalto', 'critical')|list|length }}
-
Kritisch
-
-
-
-
-
{{ alerts|selectattr('severity', 'equalto', 'high')|list|length }}
-
Hoch
-
-
-
-
-
{{ alerts|selectattr('severity', 'equalto', 'medium')|list|length }}
-
Mittel
-
-
-
-
-
{{ alerts|selectattr('severity', 'equalto', 'low')|list|length }}
-
Niedrig
-
-
-
- - -
- - Alle ({{ alerts|length }}) - - - Kritisch - - - Hoch - - - Mittel - - - Niedrig - -
- - -
- {% for alert in alerts %} -
-
-
-
-
- {% if alert.anomaly_type == 'multiple_ips' %} - Mehrere IP-Adressen erkannt - {% elif alert.anomaly_type == 'rapid_hardware_change' %} - Schneller Hardware-Wechsel - {% elif alert.anomaly_type == 'suspicious_pattern' %} - Verdächtiges Muster - {% else %} - {{ alert.anomaly_type }} - {% endif %} -
- - {{ alert.severity }} - -
- - {% if alert.company_name %} -
- Kunde: {{ alert.company_name }} - {% if alert.license_key %} - ({{ alert.license_key[:8] }}...) - {% endif %} -
- {% endif %} - -
- {{ alert.detected_at|default(alert.startsAt) }} -
- - {% if alert.details %} -
- Details:
- {{ alert.details }} -
- {% endif %} -
- -
-
- {% if not alert.resolved %} - - - {% if alert.severity in ['critical', 'high'] %} - - {% endif %} - {% else %} -
- Gelöst - {% if alert.resolved_at %} -
{{ alert.resolved_at }}
- {% endif %} -
- {% endif %} -
-
-
-
- {% else %} -
- -

Keine aktiven Alerts

-

Alle Systeme laufen normal

-
- {% endfor %} -
-{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/monitoring/analytics.html b/v2_adminpanel/templates/monitoring/analytics.html deleted file mode 100644 index ae6ad47..0000000 --- a/v2_adminpanel/templates/monitoring/analytics.html +++ /dev/null @@ -1,453 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Analytics & Lizenzserver Status{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-

Analytics & Lizenzserver Status

-
- - Live-Daten - -
-
- - -
-
-
- - -
-
- -
-
-
- - -
-
-
-
- {{ live_stats[0] if live_stats else 0 }} -
-
Aktive Lizenzen
-
-
-
-
-
- {{ live_stats[1] if live_stats else 0 }} -
-
Validierungen (5 Min)
-
-
-
-
-
- {{ live_stats[2] if live_stats else 0 }} -
-
Aktive Geräte
-
-
-
-
-
- {{ live_stats[3] if live_stats else 0 }} -
-
Unique IPs
-
-
-
- -
- -
-
-
-
Validierungen pro Minute
-
-
- -
-
-
- - -
-
-
-
Aktuelle Anomalien
- - Alle anzeigen - -
-
- {% if recent_anomalies %} - {% for anomaly in recent_anomalies %} -
-
- - {{ anomaly['severity'].upper() }} - - {{ anomaly['detected_at'].strftime('%H:%M') }} -
-
- {{ anomaly['anomaly_type'].replace('_', ' ').title() }}
- Lizenz: {{ anomaly['license_key'][:8] }}... -
-
- {% endfor %} - {% else %} -

Keine aktiven Anomalien

- {% endif %} -
-
-
-
- - -
-
-
Top Aktive Lizenzen (letzte 15 Min)
-
-
-
- - - - - - - - - - - - - {% for license in top_licenses %} - - - - - - - - - {% endfor %} - -
LizenzschlüsselKundeGeräteValidierungenZuletzt gesehenStatus
- {{ license['license_key'][:12] }}... - {{ license['customer_name'] }} - - {{ license['device_count'] }} - - {{ license['validation_count'] }}{{ license['last_seen'].strftime('%H:%M:%S') }} - Aktiv -
-
-
-
- - -
-
-
Letzte Validierungen (Live-Stream)
-
-
-
- -
-
-
- - -
-
Berichte exportieren
-
- - -
-
-{% endblock %} - -{% block extra_js %} - - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/monitoring/live_dashboard.html b/v2_adminpanel/templates/monitoring/live_dashboard.html deleted file mode 100644 index 66485b0..0000000 --- a/v2_adminpanel/templates/monitoring/live_dashboard.html +++ /dev/null @@ -1,698 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Live Dashboard & Analytics{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

Live Dashboard & Analytics

-

Echtzeit-Übersicht und Analyse der Lizenznutzung

-
-
- - Live-Daten - Auto-Refresh: 30s - -
-
- - -
-
-
- -
{{ live_stats[0] if live_stats else 0 }}
-
Aktive Lizenzen
-
-
-
-
- -
{{ live_stats[1] if live_stats else 0 }}
-
Validierungen (5 Min)
-
-
-
-
- -
{{ live_stats[2] if live_stats else 0 }}
-
Aktive Geräte
-
-
-
-
- -
{{ live_stats[3] if live_stats else 0 }}
-
Unique IPs
-
-
-
- - - - -
- -
-
- -
-
-
-
Aktivität (letzte 60 Minuten)
-
-
- -
-
-
- - -
-
-
-
Aktuelle Anomalien
- - Alle anzeigen - -
-
- {% if recent_anomalies %} - {% for anomaly in recent_anomalies %} -
-
- - {{ anomaly['severity'].upper() }} - - {{ anomaly['detected_at'].strftime('%H:%M') }} -
-
- {{ anomaly['anomaly_type'].replace('_', ' ').title() }}
- Lizenz: {{ anomaly['license_key'][:8] }}... -
-
- {% endfor %} - {% else %} -

Keine aktiven Anomalien

- {% endif %} -
-
-
-
- - -
-
-
Top Aktive Lizenzen (letzte 15 Min)
-
-
-
- - - - - - - - - - - - - {% for license in top_licenses %} - - - - - - - - - {% endfor %} - -
LizenzschlüsselKundeGeräteValidierungenZuletzt gesehenStatus
- {{ license['license_key'][:12] }}... - {{ license['customer_name'] }} - - {{ license['device_count'] }} - - {{ license['validation_count'] }}{{ license['last_seen'].strftime('%H:%M:%S') }} - Aktiv -
-
-
-
-
- - -
-
-
-
Aktive Kunden-Sessions (letzte 5 Minuten)
-
-
-
- {% for session in active_sessions %} -
-
-
-
- - {{ session.company_name }} -
- {{ session.contact_person }} -
-
-
- {{ session.license_key[:8] }}... -
-
- {{ session.active_devices }} Gerät(e) -
-
-
-
- {{ session.ip_address }} -
Hardware: {{ session.hardware_id[:12] }}...
-
-
-
-
- - - vor wenigen Sekunden - -
-
-
-
- {% else %} -
- -

Keine aktiven Sessions in den letzten 5 Minuten

-
- {% endfor %} -
-
-
- - -
-
-
Letzte Validierungen (Live-Stream)
-
-
-
- -
-
-
-
- - -
-
-
-
-
-
Validierungen pro Minute (30 Min)
-
-
- -
-
-
-
- - -
-
-
Berichte exportieren
-
-
-
-
- - -
-
- -
- - - -
-
-
-
-
-
-
- - - - -{% endblock %} - -{% block extra_js %} - - - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/monitoring/unified_monitoring.html b/v2_adminpanel/templates/monitoring/unified_monitoring.html deleted file mode 100644 index ac91889..0000000 --- a/v2_adminpanel/templates/monitoring/unified_monitoring.html +++ /dev/null @@ -1,609 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Monitoring{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
- -
-
-
- {% if system_status == 'normal' %} - 🟢 System Normal - {% elif system_status == 'warning' %} - 🟡 System Warning - {% else %} - 🔴 System Critical - {% endif %} - {{ active_alerts }} Aktive Alerts -
-
- Letzte Aktualisierung: jetzt -
- - -
-
-
-
- - -
-
-
📊 Executive Summary
- -
-
-
-
-
-
Aktive Lizenzen
-
{{ live_metrics.active_licenses or 0 }}
-
- 0% -
- {% if live_metrics.active_licenses > 100 %} -
!
- {% endif %} -
-
-
-
-
Validierungen (5 Min)
-
{{ live_metrics.total_validations or 0 }}
-
- 0% -
-
-
-
-
-
Aktive Geräte
-
{{ live_metrics.unique_devices or 0 }}
-
- 0% -
-
-
-
-
-
Response Zeit
-
{{ (live_metrics.avg_response_time or 0)|round(1) }}ms
-
- 0% -
-
-
-
-
-
- -
-
-
-
- - -
- -
-
-
-
🔄 Activity Stream
-
- - - - -
-
-
- {% for event in activity_stream %} -
-
-
-
- {% if event.event_type == 'validation' %} - - {% else %} - - {% endif %} - {{ event.customer_name or 'Unbekannt' }} - {{ event.license_key[:8] }}... -
-
- {% if event.event_type == 'anomaly' %} - - {{ event.anomaly_type }} - - {{ event.description }} - {% else %} - Validierung von {{ event.ip_address }} • Gerät: {{ event.hardware_id[:8] }}... - {% endif %} -
-
-
- {{ event.timestamp.strftime('%H:%M:%S') if event.timestamp else '-' }} - {% if event.event_type == 'anomaly' and event.severity == 'critical' %} -
- -
- {% endif %} -
-
-
- {% endfor %} - {% if not activity_stream %} -
- -

Keine Aktivitäten in den letzten 60 Minuten

-
- {% endif %} -
-
-
- - -
- -
-
🏆 Top Aktive Lizenzen
- {% for license in top_licenses %} -
-
-
{{ license.customer_name }}
- {{ license.device_count }} Geräte • {{ license.validation_count }} Validierungen -
- {% if license.anomaly_count > 0 %} - {{ license.anomaly_count }} ⚠️ - {% else %} - OK - {% endif %} -
- {% endfor %} - {% if not top_licenses %} -

Keine aktiven Lizenzen

- {% endif %} -
- - -
-
🎯 Anomalie-Verteilung
- - {% if not anomaly_distribution %} -

Keine Anomalien erkannt

- {% endif %} -
- - -
-
🌍 Geografische Verteilung
-
- {% for geo in geo_data[:5] %} -
- {{ geo.ip_address }} - {{ geo.request_count }} -
- {% endfor %} -
- {% if geo_data|length > 5 %} - +{{ geo_data|length - 5 }} weitere IPs - {% endif %} -
-
-
- - -
- -
-
-
-

Ungewöhnliche Verhaltensmuster werden hier angezeigt...

-
-
-
- -
-
-
-

Detaillierte Analyse spezifischer Lizenzen...

-
-
-

Vorhersagen und Kapazitätsplanung...

-
-
-
-
-
-{% endblock %} - -{% block extra_js %} - - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/profile.html b/v2_adminpanel/templates/profile.html deleted file mode 100644 index eb5282f..0000000 --- a/v2_adminpanel/templates/profile.html +++ /dev/null @@ -1,216 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Benutzerprofil{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

👤 Benutzerprofil

-
- - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - - {% endfor %} - {% endif %} - {% endwith %} - - -
-
-
-
-
👤
-
{{ user.username }}
-

{{ user.email or 'Keine E-Mail angegeben' }}

- Mitglied seit: {{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'Unbekannt' }} -
-
-
-
-
-
-
🔐
-
Sicherheitsstatus
- {% if user.totp_enabled %} - 2FA Aktiv - {% else %} - 2FA Inaktiv - {% endif %} -

- Letztes Passwort-Update:
{{ user.last_password_change.strftime('%d.%m.%Y') if user.last_password_change else 'Noch nie' }}
-

-
-
-
-
- - -
-
-
- 🔑 - Passwort ändern -
-
-
-
- - -
-
- - -
-
Mindestens 8 Zeichen
-
-
- - -
Passwörter stimmen nicht überein
-
- -
-
-
- - -
-
-
- 🔐 - Zwei-Faktor-Authentifizierung (2FA) -
-
- {% if user.totp_enabled %} -
-
-
Status: Aktiv
-

Ihr Account ist durch 2FA geschützt

-
-
-
-
-
- - -
- -
- {% else %} -
-
-
Status: Inaktiv
-

Aktivieren Sie 2FA für zusätzliche Sicherheit

-
-
⚠️
-
-

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

- ✨ 2FA einrichten - {% endif %} -
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/resource_history.html b/v2_adminpanel/templates/resource_history.html deleted file mode 100644 index 3ae1eee..0000000 --- a/v2_adminpanel/templates/resource_history.html +++ /dev/null @@ -1,365 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Resource Historie{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
- -
-
-

Resource Historie

-

Detaillierte Aktivitätshistorie

-
- - Zurück zur Übersicht - -
- - -
-
-
📋 Resource Details
-
-
- -
-
- {% if resource.resource_type == 'domain' %} - 🌐 - {% elif resource.resource_type == 'ipv4' %} - 🖥️ - {% else %} - 📱 - {% endif %} -
-
{{ resource.resource_value }}
-
- {% if resource.status == 'available' %} - - ✅ Verfügbar - - {% elif resource.status == 'allocated' %} - - 🔗 Zugeteilt - - {% else %} - - ⚠️ Quarantäne - - {% endif %} -
-
- - -
-
-
Ressourcentyp
-
{{ resource.resource_type|upper }}
-
- -
-
Erstellt am
-
- {{ resource.created_at.strftime('%d.%m.%Y %H:%M') if resource.created_at else '-' }} -
-
- -
-
Status geändert
-
- {{ resource.status_changed_at.strftime('%d.%m.%Y %H:%M') if resource.status_changed_at else '-' }} -
-
- - {% if resource.allocated_to_license %} - - {% endif %} - - {% if resource.quarantine_reason %} -
-
Quarantäne-Grund
-
- {{ resource.quarantine_reason }} -
-
- {% endif %} - - {% if resource.quarantine_until %} -
-
Quarantäne bis
-
- {{ resource.quarantine_until.strftime('%d.%m.%Y') }} -
-
- {% endif %} -
- - {% if resource.notes %} -
-
-
📝 Notizen
-

{{ resource.notes }}

-
-
- {% endif %} -
-
- - -
-
-
⏱️ Aktivitäts-Historie
-
-
- {% if history %} -
- {% for event in history %} -
-
-
-
-
-
-
- {% if event.action == 'created' %} - - - -
Ressource erstellt
- {% elif event.action == 'allocated' %} - - - -
An Lizenz zugeteilt
- {% elif event.action == 'deallocated' %} - - - -
Von Lizenz freigegeben
- {% elif event.action == 'quarantined' %} - - - -
In Quarantäne gesetzt
- {% elif event.action == 'released' %} - - - -
Aus Quarantäne entlassen
- {% elif event.action == 'deleted' %} - - - -
Ressource gelöscht
- {% else %} -
{{ event.action }}
- {% endif %} -
- -
- {{ event.action_by }} - {% if event.ip_address %} -  •  {{ event.ip_address }} - {% endif %} - {% if event.license_id %} -  •  - - - Lizenz #{{ event.license_id }} - - {% endif %} -
- - {% if event.details %} -
- Details: -
{{ event.details|tojson(indent=2) }}
-
- {% endif %} -
-
-
- {{ event.action_at.strftime('%d.%m.%Y') }} -
-
- {{ event.action_at.strftime('%H:%M:%S') }} -
-
-
-
-
- {% endfor %} -
- {% else %} -
- -

Keine Historie-Einträge vorhanden.

-
- {% endif %} -
-
-
-{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/resource_metrics.html b/v2_adminpanel/templates/resource_metrics.html deleted file mode 100644 index 5f0812f..0000000 --- a/v2_adminpanel/templates/resource_metrics.html +++ /dev/null @@ -1,559 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Resource Metriken{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
- -
-
-

Performance Dashboard

-

Resource Pool Metriken und Analysen

-
- -
- - -
-
-
-
-
- 📊 -
-
Ressourcen gesamt
-
{{ stats.total_resources or 0 }}
-
Aktive Ressourcen
-
-
-
- -
-
-
-
- 📈 -
-
Ø Performance
-
- {{ "%.1f"|format(stats.avg_performance or 0) }}% -
-
Letzte 30 Tage
- {% if stats.performance_trend %} -
- - {% if stats.performance_trend == 'up' %} - Steigend - {% elif stats.performance_trend == 'down' %} - Fallend - {% else %} - Stabil - {% endif %} - -
- {% endif %} -
-
-
- -
-
-
-
- 💰 -
-
ROI
-
- {{ "%.2f"|format(stats.roi) }}x -
-
Revenue / Cost
-
-
-
- -
-
-
-
- ⚠️ -
-
Probleme
-
- {{ stats.total_issues or 0 }} -
-
Letzte 30 Tage
-
-
-
-
- - -
-
-
-
-
📊 Performance nach Ressourcentyp
-
-
- -
-
-
-
-
-
-
🎯 Auslastung nach Typ
-
-
- -
-
-
-
- - -
-
-
-
-
🏆 Top Performer
-
-
-
- - - - - - - - - - - {% for resource in top_performers %} - - - - - - - {% endfor %} - {% if not top_performers %} - - - - {% endif %} - -
RessourceTypScoreROI
-
- {{ resource.resource_value }} - - - -
-
- - {% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %} - {{ resource.resource_type|upper }} - - -
-
- {{ "%.1f"|format(resource.avg_score) }}% -
-
-
- - {{ "%.2f"|format(resource.roi) }}x - -
- Keine Performance-Daten verfügbar -
-
-
-
-
- -
-
-
-
⚠️ Problematische Ressourcen
-
-
-
- - - - - - - - - - - {% for resource in problem_resources %} - - - - - - - {% endfor %} - {% if not problem_resources %} - - - - {% endif %} - -
RessourceTypProblemeStatus
-
- {{ resource.resource_value }} - - - -
-
- - {% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %} - {{ resource.resource_type|upper }} - - - - {{ resource.total_issues }} - - - {% if resource.status == 'quarantine' %} - - ⚠️ Quarantäne - - {% elif resource.status == 'allocated' %} - - 🔗 Zugeteilt - - {% else %} - - ✅ Verfügbar - - {% endif %} -
- Keine problematischen Ressourcen gefunden -
-
-
-
-
-
- - -
-
-
📈 30-Tage Performance Trend
-
-
- -
-
-
- - - - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/resource_report.html b/v2_adminpanel/templates/resource_report.html deleted file mode 100644 index 1704575..0000000 --- a/v2_adminpanel/templates/resource_report.html +++ /dev/null @@ -1,212 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Resource Report Generator{% endblock %} - -{% block content %} -
-
-

Resource Report Generator

- - Zurück - -
- -
-
-
-
-
Report-Einstellungen
-
-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
Report-Beschreibungen:
-
-
-
Auslastungsreport
-

Zeigt die Nutzung aller Ressourcen im gewählten Zeitraum. - Enthält Allokations-Historie, durchschnittliche Auslastung und Trends.

-
- - - -
-
- -
- - -
-
-
-
- - -
-
-
Letzte generierte Reports
-
-
-
-
-
-
Auslastungsreport_2025-06-01.xlsx
- vor 5 Tagen -
-

Zeitraum: 01.05.2025 - 01.06.2025

- Generiert von: {{ username }} -
-
-
-
-
-
-
- - - - - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/resources.html b/v2_adminpanel/templates/resources.html deleted file mode 100644 index 8253652..0000000 --- a/v2_adminpanel/templates/resources.html +++ /dev/null @@ -1,896 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Resource Pool{% endblock %} - - - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
- -
-

Ressourcen Pool

-

Verwalten Sie Domains, IPs und Telefonnummern

-
- - -
-
-
- - -
-
-
- - -
- {% for type, data in stats.items() %} -
-
-
-
- {% if type == 'domain' %} - 🌐 - {% elif type == 'ipv4' %} - 🖥️ - {% else %} - 📱 - {% endif %} -
-
{{ type|upper }}
-
{{ data.available }}
-
von {{ data.total }} verfügbar
- -
-
- {{ data.available_percent }}% -
-
- {% if data.allocated > 0 %}{{ data.allocated }}{% endif %} -
-
- {% if data.quarantined > 0 %}{{ data.quarantined }}{% endif %} -
-
- -
- {% if data.available_percent < 20 %} - ⚠️ Niedriger Bestand - {% elif data.available_percent < 50 %} - ⚡ Bestand prüfen - {% else %} - ✅ Gut gefüllt - {% endif %} -
-
-
-
- {% endfor %} -
- - -
-
-
- -
-
- - -
-
- - -
-
- - -
- -
-
-
-
- - -
-
-
-
📋 Ressourcen-Liste
-
- - {{ total }} Einträge -
-
-
-
- {% if resources %} -
- - - - - - - - - - - - - - {% for resource in resources %} - - - - - - - - - - {% endfor %} - -
- - ID - {% if sort_by == 'id' %} - - {% else %} - - {% endif %} - - - - Typ - {% if sort_by == 'type' %} - - {% else %} - - {% endif %} - - - - Ressource - {% if sort_by == 'resource' %} - - {% else %} - - {% endif %} - - - - Status - {% if sort_by == 'status' %} - - {% else %} - - {% endif %} - - - - Zugewiesen an - {% if sort_by == 'assigned' %} - - {% else %} - - {% endif %} - - - - Letzte Änderung - {% if sort_by == 'changed' %} - - {% else %} - - {% endif %} - - Aktionen
- #{{ resource.id }} - -
- {% if resource.resource_type == 'domain' %} - 🌐 - {% elif resource.resource_type == 'ipv4' %} - 🖥️ - {% else %} - 📱 - {% endif %} -
-
-
- {{ resource.resource_value }} - -
-
- {% if resource.status == 'available' %} - - ✅ Verfügbar - - {% elif resource.status == 'allocated' %} - - 🔗 Zugeteilt - - {% else %} - - ⚠️ Quarantäne - - {% if resource.status_changed_by %} -
{{ resource.status_changed_by }}
- {% endif %} - {% endif %} -
- {% if resource.customer_name %} - - - {% else %} - - - {% endif %} - - {% if resource.status_changed_at %} -
-
{{ resource.status_changed_at.strftime('%d.%m.%Y') }}
-
{{ resource.status_changed_at.strftime('%H:%M Uhr') }}
-
- {% else %} - - - {% endif %} -
- {% if resource.status == 'quarantine' %} - -
- - - -
- {% endif %} - - - -
-
- {% else %} -
- -

Keine Ressourcen gefunden

-

Ändern Sie Ihre Filterkriterien oder fügen Sie neue Ressourcen hinzu.

-
- {% endif %} -
-
- - - {% if total_pages > 1 %} - - {% endif %} - - - {% if recent_activities %} -
-
-
⏰ Kürzliche Aktivitäten
-
-
-
- {% for activity in recent_activities %} -
-
- {% if activity[0] == 'created' %} - - {% elif activity[0] == 'allocated' %} - 🔗 - {% elif activity[0] == 'deallocated' %} - 🔓 - {% elif activity[0] == 'quarantined' %} - ⚠️ - {% else %} - ℹ️ - {% endif %} -
-
-
-
- {{ activity[4] }} ({{ activity[3] }}) - {{ activity[0] }} - {% if activity[1] %} - von {{ activity[1] }} - {% endif %} -
- - {{ activity[2].strftime('%d.%m.%Y %H:%M') if activity[2] else '' }} - -
-
-
- {% endfor %} -
-
-
- {% endif %} -
- - - - - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/sessions.html b/v2_adminpanel/templates/sessions.html deleted file mode 100644 index 2a524a6..0000000 --- a/v2_adminpanel/templates/sessions.html +++ /dev/null @@ -1,183 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Session-Tracking{% endblock %} - -{% macro active_sortable_header(label, field, current_sort, current_order) %} - - {% if current_sort == field %} - - {% else %} - - {% endif %} - {{ label }} - - {% if current_sort == field %} - {% if current_order == 'asc' %}↑{% else %}↓{% endif %} - {% else %} - ↕ - {% endif %} - - - -{% endmacro %} - -{% macro ended_sortable_header(label, field, current_sort, current_order) %} - - {% if current_sort == field %} - - {% else %} - - {% endif %} - {{ label }} - - {% if current_sort == field %} - {% if current_order == 'asc' %}↑{% else %}↓{% endif %} - {% else %} - ↕ - {% endif %} - - - -{% endmacro %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
- - - -
-
-
🟢 Aktive Sessions ({{ active_sessions|length }})
-
-
- {% if active_sessions %} -
- - - - - - - - - - - - - - {% for session in active_sessions %} - - - - - - - - - - {% endfor %} - -
KundeLizenzIP-AdresseGestartetLetzter HeartbeatInaktiv seitAktion
{{ session[3] }}{{ session[2][:12] }}...{{ session[4] or '-' }}{{ session[6].strftime('%d.%m %H:%M') }}{{ session[7].strftime('%d.%m %H:%M') }} - {% if session[8] < 1 %} - Aktiv - {% elif session[8] < 5 %} - {{ session[8]|round|int }} Min. - {% else %} - {{ session[8]|round|int }} Min. - {% endif %} - -
- -
-
-
- - Sessions gelten als inaktiv nach 5 Minuten ohne Heartbeat - - {% else %} -

Keine aktiven Sessions vorhanden.

- {% endif %} -
-
- - -
-
-
⏸️ Beendete Sessions (letzte 24 Stunden)
-
-
- {% if recent_sessions %} -
- - - - - - - - - - - - - {% for session in recent_sessions %} - - - - - - - - - {% endfor %} - -
KundeLizenzIP-AdresseGestartetBeendetDauer
{{ session[3] }}{{ session[2][:12] }}...{{ session[4] or '-' }}{{ session[5].strftime('%d.%m %H:%M') }}{{ session[6].strftime('%d.%m %H:%M') }} - {% if session[7] < 60 %} - {{ session[7]|round|int }} Min. - {% else %} - {{ (session[7]/60)|round(1) }} Std. - {% endif %} -
-
- {% else %} -

Keine beendeten Sessions in den letzten 24 Stunden.

- {% endif %} -
-
-
-{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/setup_2fa.html b/v2_adminpanel/templates/setup_2fa.html deleted file mode 100644 index a7ccf35..0000000 --- a/v2_adminpanel/templates/setup_2fa.html +++ /dev/null @@ -1,210 +0,0 @@ -{% extends "base.html" %} - -{% block title %}2FA Einrichten{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
-
-

🔐 2FA einrichten

- ← Zurück zum Profil -
- - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - - {% endfor %} - {% endif %} - {% endwith %} - - -
-
-
- 1 - Authenticator-App installieren -
-

Wählen Sie eine der folgenden Apps für Ihr Smartphone:

-
-
-
- 📱 -
- Google Authenticator
- Android / iOS -
-
-
-
-
- 🔷 -
- Microsoft Authenticator
- Android / iOS -
-
-
-
-
- 🔴 -
- Authy
- Android / iOS / Desktop -
-
-
-
-
-
- - -
-
-
- 2 - QR-Code scannen oder Code eingeben -
-
-
-

Option A: QR-Code scannen

-
- 2FA QR Code -
-

- Öffnen Sie Ihre Authenticator-App und scannen Sie diesen Code -

-
-
-

Option B: Code manuell eingeben

-
- -
- V2 Admin Panel -
-
-
- -
{{ totp_secret }}
- -
-
- - ⚠️ Wichtiger Hinweis:
- Speichern Sie diesen Code sicher. Er ist Ihre einzige Möglichkeit, - 2FA auf einem neuen Gerät einzurichten. -
-
-
-
-
-
- - -
-
-
- 3 - Code verifizieren -
-

Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein:

- -
-
-
- -
Der Code ändert sich alle 30 Sekunden
-
-
- -
-
-
-
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/verify_2fa.html b/v2_adminpanel/templates/verify_2fa.html deleted file mode 100644 index 24d4ce5..0000000 --- a/v2_adminpanel/templates/verify_2fa.html +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - 2FA Verifizierung - Admin Panel - - - - - - - - - - \ No newline at end of file diff --git a/v2_adminpanel/tests/__init__.py b/v2_adminpanel/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/v2_adminpanel/tests/test_error_handling.py b/v2_adminpanel/tests/test_error_handling.py deleted file mode 100644 index 2489f70..0000000 --- a/v2_adminpanel/tests/test_error_handling.py +++ /dev/null @@ -1,350 +0,0 @@ -import pytest -import json -from datetime import datetime -from flask import Flask, request -from werkzeug.exceptions import NotFound - -from ..core.exceptions import ( - ValidationException, InputValidationError, AuthenticationException, - InvalidCredentialsError, DatabaseException, QueryError, - ResourceNotFoundError, BusinessRuleViolation -) -from ..core.error_handlers import init_error_handlers, handle_errors, validate_request -from ..core.validators import Validators, validate -from ..core.monitoring import error_metrics, alert_manager -from ..middleware.error_middleware import ErrorHandlingMiddleware - - -@pytest.fixture -def app(): - app = Flask(__name__) - app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'test-secret-key' - - init_error_handlers(app) - ErrorHandlingMiddleware(app) - - @app.route('/test-validation-error') - def test_validation_error(): - raise InputValidationError( - field='email', - message='Invalid email format', - value='not-an-email' - ) - - @app.route('/test-auth-error') - def test_auth_error(): - raise InvalidCredentialsError('testuser') - - @app.route('/test-db-error') - def test_db_error(): - raise QueryError( - message='Column not found', - query='SELECT * FROM users', - error_code='42703' - ) - - @app.route('/test-not-found') - def test_not_found(): - raise ResourceNotFoundError('User', 123) - - @app.route('/test-generic-error') - def test_generic_error(): - raise Exception('Something went wrong') - - @app.route('/test-validate', methods=['POST']) - @validate({ - 'email': {'type': 'email', 'required': True}, - 'age': {'type': 'integer', 'required': True, 'min_value': 18}, - 'username': {'type': 'username', 'required': False} - }) - def test_validate(): - return {'success': True, 'data': request.validated_data} - - @app.route('/test-handle-errors') - @handle_errors( - catch=(ValueError,), - message='Value error occurred', - user_message='Ungültiger Wert' - ) - def test_handle_errors(): - raise ValueError('Invalid value') - - return app - - -@pytest.fixture -def client(app): - return app.test_client() - - -class TestExceptions: - def test_validation_exception(self): - exc = ValidationException( - message='Test validation error', - field='test_field', - value='test_value', - user_message='Test user message' - ) - - assert exc.code == 'VALIDATION_ERROR' - assert exc.status_code == 400 - assert exc.details['field'] == 'test_field' - assert exc.details['value'] == 'test_value' - assert exc.user_message == 'Test user message' - - def test_input_validation_error(self): - exc = InputValidationError( - field='email', - message='Invalid format', - value='not-email', - expected_type='email' - ) - - assert exc.code == 'VALIDATION_ERROR' - assert exc.status_code == 400 - assert exc.details['field'] == 'email' - assert exc.details['expected_type'] == 'email' - - def test_business_rule_violation(self): - exc = BusinessRuleViolation( - rule='max_licenses', - message='License limit exceeded', - context={'current': 10, 'max': 5} - ) - - assert exc.details['rule'] == 'max_licenses' - assert exc.details['context']['current'] == 10 - - def test_authentication_exceptions(self): - exc = InvalidCredentialsError('testuser') - assert exc.status_code == 401 - assert exc.details['username'] == 'testuser' - - def test_database_exceptions(self): - exc = QueryError( - message='Syntax error', - query='SELECT * FORM users', - error_code='42601' - ) - - assert exc.code == 'DATABASE_ERROR' - assert exc.status_code == 500 - assert 'query_hash' in exc.details - assert exc.details['error_code'] == '42601' - - def test_resource_not_found(self): - exc = ResourceNotFoundError( - resource_type='License', - resource_id='ABC123', - search_criteria={'customer_id': 1} - ) - - assert exc.status_code == 404 - assert exc.details['resource_type'] == 'License' - assert exc.details['resource_id'] == 'ABC123' - assert exc.details['search_criteria']['customer_id'] == 1 - - -class TestErrorHandlers: - def test_validation_error_json_response(self, client): - response = client.get( - '/test-validation-error', - headers={'Accept': 'application/json'} - ) - - assert response.status_code == 400 - data = json.loads(response.data) - assert 'error' in data - assert data['error']['code'] == 'VALIDATION_ERROR' - assert 'request_id' in data['error'] - - def test_validation_error_html_response(self, client): - response = client.get('/test-validation-error') - - assert response.status_code == 400 - assert b'Ungültiger Wert für Feld' in response.data - - def test_auth_error_response(self, client): - response = client.get( - '/test-auth-error', - headers={'Accept': 'application/json'} - ) - - assert response.status_code == 401 - data = json.loads(response.data) - assert data['error']['code'] == 'AUTHENTICATION_ERROR' - - def test_db_error_response(self, client): - response = client.get( - '/test-db-error', - headers={'Accept': 'application/json'} - ) - - assert response.status_code == 500 - data = json.loads(response.data) - assert data['error']['code'] == 'DATABASE_ERROR' - - def test_not_found_response(self, client): - response = client.get('/test-not-found') - - assert response.status_code == 404 - assert b'User nicht gefunden' in response.data - - def test_generic_error_response(self, client): - response = client.get( - '/test-generic-error', - headers={'Accept': 'application/json'} - ) - - assert response.status_code == 500 - data = json.loads(response.data) - assert data['error']['code'] == 'INTERNAL_ERROR' - - def test_validate_decorator_success(self, client): - response = client.post( - '/test-validate', - json={'email': 'test@example.com', 'age': 25, 'username': 'testuser'} - ) - - assert response.status_code == 200 - data = json.loads(response.data) - assert data['success'] is True - assert data['data']['email'] == 'test@example.com' - assert data['data']['age'] == 25 - - def test_validate_decorator_failure(self, client): - response = client.post( - '/test-validate', - json={'email': 'invalid-email', 'age': 15} - ) - - assert response.status_code == 400 - data = json.loads(response.data) - assert data['error']['code'] == 'VALIDATION_ERROR' - - def test_handle_errors_decorator(self, client): - response = client.get('/test-handle-errors') - - assert response.status_code == 500 - assert b'Ungültiger Wert' in response.data - - -class TestValidators: - def test_email_validator(self): - assert Validators.email('test@example.com') == 'test@example.com' - assert Validators.email('TEST@EXAMPLE.COM') == 'test@example.com' - - with pytest.raises(InputValidationError): - Validators.email('invalid-email') - - with pytest.raises(InputValidationError): - Validators.email('') - - def test_phone_validator(self): - assert Validators.phone('+1-234-567-8900') == '+12345678900' - assert Validators.phone('(123) 456-7890') == '1234567890' - - with pytest.raises(InputValidationError): - Validators.phone('123') - - def test_license_key_validator(self): - assert Validators.license_key('abcd-1234-efgh-5678') == 'ABCD-1234-EFGH-5678' - - with pytest.raises(InputValidationError): - Validators.license_key('invalid-key') - - def test_integer_validator(self): - assert Validators.integer('123') == 123 - assert Validators.integer(456) == 456 - assert Validators.integer('10', min_value=5, max_value=15) == 10 - - with pytest.raises(InputValidationError): - Validators.integer('abc') - - with pytest.raises(InputValidationError): - Validators.integer('3', min_value=5) - - with pytest.raises(InputValidationError): - Validators.integer('20', max_value=15) - - def test_string_validator(self): - assert Validators.string('test', min_length=2, max_length=10) == 'test' - - with pytest.raises(InputValidationError): - Validators.string('a', min_length=2) - - with pytest.raises(InputValidationError): - Validators.string('a' * 20, max_length=10) - - with pytest.raises(InputValidationError): - Validators.string('test