commit 0d7d888502e7f139f9222bf6dba6c97978e30833 Author: Claude Project Manager Date: Sat Jul 5 17:51:16 2025 +0200 Initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5fe3bfb --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,86 @@ +{ + "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/API_REFERENCE.md b/API_REFERENCE.md new file mode 100644 index 0000000..fc65d85 --- /dev/null +++ b/API_REFERENCE.md @@ -0,0 +1,714 @@ +⎿ # 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.intelsight.de` + +### 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.intelsight.de` + +### 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 + } +} +``` + + +### 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" + } + ] +} +``` + + +### 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 + +#### 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" +} +``` + + +## 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.intelsight.de/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.intelsight.de/` +- License Server API: `https://api-software-undso.intelsight.de/` \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b1a1c0f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,154 @@ +# 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/CLAUDE_PROJECT_README.md b/CLAUDE_PROJECT_README.md new file mode 100644 index 0000000..499c397 --- /dev/null +++ b/CLAUDE_PROJECT_README.md @@ -0,0 +1,405 @@ +# v2-Docker + +*This README was automatically generated by Claude Project Manager* + +## Project Overview + +- **Path**: `A:/GiTea/v2-Docker` +- **Files**: 1571 files +- **Size**: 54.8 MB +- **Last Modified**: 2025-07-01 16:22 + +## Technology Stack + +### Languages +- Batch +- C# +- PowerShell +- Python +- Shell + +## Project Structure + +``` +API_REFERENCE.md +backup_before_cleanup.sh +CLAUDE.md +cloud-init.yaml +generate-secrets.py +JOURNAL.md +OPERATIONS_GUIDE.md +PRODUCTION_DEPLOYMENT.md +Start.bat +SYSTEM_DOCUMENTATION.md +backups/ +│ ├── backup_v2docker_20250607_174645_encrypted.sql.gz.enc +│ ├── backup_v2docker_20250607_232845_encrypted.sql.gz.enc +│ ├── backup_v2docker_20250608_075834_encrypted.sql.gz.enc +│ ├── backup_v2docker_20250608_174930_encrypted.sql.gz.enc +│ ├── backup_v2docker_20250608_200224_encrypted.sql.gz.enc +│ ├── backup_v2docker_20250616_211330_encrypted.sql.gz.enc +│ ├── backup_v2docker_20250618_020559_encrypted.sql.gz.enc +│ ├── backup_v2docker_20250618_021107_encrypted.sql.gz.enc +│ ├── backup_v2docker_20250618_024414_encrypted.sql.gz.enc +│ └── refactoring_20250616_223724/ +│ ├── app.py.backup_20250616_223724 +│ ├── blueprint_overview.txt +│ ├── commented_routes.txt +│ ├── git_diff.txt +│ ├── git_log.txt +│ ├── git_status.txt +│ └── v2_adminpanel_backup/ +│ ├── app.py +│ ├── app.py.backup +│ ├── app.py.backup_before_blueprint_migration +│ ├── app.py.old +│ ├── app_before_blueprint.py +│ ├── app_new.py +│ ├── app_with_duplicates.py +│ ├── config.py +│ ├── cookies.txt +│ └── create_users_table.sql +lizenzserver/ +│ ├── API_DOCUMENTATION.md +│ ├── config.py +│ ├── docker-compose.yaml +│ ├── docker-compose.yml +│ ├── Dockerfile.admin +│ ├── Dockerfile.analytics +│ ├── Dockerfile.auth +│ ├── Dockerfile.license +│ ├── init.sql +│ ├── api/ +│ │ └── v1 +│ ├── events/ +│ │ ├── event_bus.py +│ │ └── __init__.py +│ ├── middleware/ +│ │ ├── rate_limiter.py +│ │ └── __init__.py +│ ├── models/ +│ │ └── __init__.py +│ ├── repositories/ +│ │ ├── base.py +│ │ ├── cache_repo.py +│ │ └── license_repo.py +│ ├── services/ +│ │ ├── admin_api/ +│ │ │ ├── app.py +│ │ │ └── __init__.py +│ │ ├── analytics/ +│ │ │ ├── app.py +│ │ │ └── __init__.py +│ │ ├── auth/ +│ │ │ ├── app.py +│ │ │ ├── config.py +│ │ │ ├── Dockerfile +│ │ │ └── requirements.txt +│ │ └── license_api/ +│ │ ├── app.py +│ │ ├── Dockerfile +│ │ └── requirements.txt +│ ├── tests +│ └── utils +scripts/ +│ ├── reset-to-dhcp.ps1 +│ ├── set-static-ip.ps1 +│ └── setup-firewall.ps1 +SSL/ +│ ├── cert.pem +│ ├── chain.pem +│ ├── fullchain.pem +│ ├── privkey.pem +│ └── SSL_Wichtig.md +v2/ +│ ├── backup_before_timezone_change.sql +│ ├── cookies.txt +│ ├── docker-compose.yaml +│ └── postgres_data +v2_adminpanel/ +│ ├── app.py +│ ├── apply_lead_migration.py +│ ├── apply_license_heartbeats_migration.py +│ ├── apply_partition_migration.py +│ ├── config.py +│ ├── db.py +│ ├── Dockerfile +│ ├── ERROR_HANDLING_GUIDE.md +│ ├── init.sql +│ ├── models.py +│ ├── auth/ +│ │ ├── decorators.py +│ │ ├── password.py +│ │ ├── rate_limiting.py +│ │ ├── two_factor.py +│ │ └── __init__.py +│ ├── core/ +│ │ ├── error_handlers.py +│ │ ├── exceptions.py +│ │ ├── logging_config.py +│ │ ├── monitoring.py +│ │ ├── validators.py +│ │ └── __init__.py +│ ├── docs +│ ├── leads/ +│ │ ├── models.py +│ │ ├── repositories.py +│ │ ├── routes.py +│ │ ├── services.py +│ │ ├── __init__.py +│ │ └── templates +│ ├── middleware/ +│ │ ├── error_middleware.py +│ │ └── __init__.py +│ ├── migrations/ +│ │ ├── add_device_type.sql +│ │ ├── add_fake_constraint.sql +│ │ ├── add_june_2025_partition.sql +│ │ ├── cleanup_orphaned_api_tables.sql +│ │ ├── create_lead_tables.sql +│ │ ├── create_license_heartbeats_table.sql +│ │ ├── remove_duplicate_api_key.sql +│ │ └── rename_test_to_fake.sql +│ ├── routes/ +│ │ ├── admin_routes.py +│ │ ├── api_routes.py +│ │ ├── auth_routes.py +│ │ ├── batch_routes.py +│ │ ├── customer_routes.py +│ │ ├── export_routes.py +│ │ ├── license_routes.py +│ │ ├── monitoring_routes.py +│ │ ├── resource_routes.py +│ │ └── session_routes.py +│ ├── services +│ ├── templates/ +│ │ ├── 404.html +│ │ ├── 500.html +│ │ ├── add_resources.html +│ │ ├── audit_log.html +│ │ ├── backups.html +│ │ ├── backup_codes.html +│ │ ├── base.html +│ │ ├── batch_form.html +│ │ ├── batch_result.html +│ │ ├── blocked_ips.html +│ │ ├── api_keys +│ │ ├── devices +│ │ ├── macros +│ │ └── monitoring/ +│ │ ├── alerts.html +│ │ ├── analytics.html +│ │ ├── live_dashboard.html +│ │ └── unified_monitoring.html +│ ├── tests/ +│ │ ├── test_error_handling.py +│ │ └── __init__.py +│ └── utils/ +│ ├── audit.py +│ ├── backup.py +│ ├── export.py +│ ├── license.py +│ ├── network.py +│ ├── partition_helper.py +│ ├── recaptcha.py +│ └── __init__.py +v2_lizenzserver/ +│ ├── docker-compose.yml +│ ├── Dockerfile +│ ├── init_db.py +│ ├── requirements.txt +│ ├── test_api.py +│ ├── app/ +│ │ ├── main.py +│ │ ├── api/ +│ │ │ ├── license.py +│ │ │ ├── version.py +│ │ │ └── __init__.py +│ │ ├── core/ +│ │ │ ├── api_key_auth.py +│ │ │ ├── config.py +│ │ │ ├── metrics.py +│ │ │ └── security.py +│ │ ├── db/ +│ │ │ └── database.py +│ │ ├── models/ +│ │ │ ├── models.py +│ │ │ └── __init__.py +│ │ ├── schemas/ +│ │ │ ├── license.py +│ │ │ └── __init__.py +│ │ └── services +│ ├── client_examples/ +│ │ ├── csharp_client.cs +│ │ └── python_client.py +│ └── services/ +│ ├── admin/ +│ │ ├── app.py +│ │ ├── Dockerfile +│ │ ├── requirements.txt +│ │ └── __init__.py +│ └── analytics/ +│ ├── app.py +│ ├── Dockerfile +│ ├── requirements.txt +│ └── __init__.py +v2_nginx/ +│ ├── Dockerfile +│ ├── Dockerfile.letsencrypt +│ ├── entrypoint-letsencrypt.sh +│ ├── entrypoint.sh +│ ├── nginx.conf +│ └── ssl/ +│ ├── dhparam.pem +│ ├── fullchain.pem +│ ├── privkey.pem +│ └── README.md +v2_postgres/ +│ └── Dockerfile +v2_postgreSQL/ +│ ├── pg_hba.conf +│ ├── pg_ident.conf +│ ├── PG_VERSION +│ ├── postgresql.auto.conf +│ ├── postgresql.conf +│ ├── postmaster.opts +│ ├── postmaster.pid +│ ├── base/ +│ │ ├── 1/ +│ │ │ ├── 112 +│ │ │ ├── 113 +│ │ │ ├── 1247 +│ │ │ ├── 1247_fsm +│ │ │ ├── 1247_vm +│ │ │ ├── 1249 +│ │ │ ├── 1249_fsm +│ │ │ ├── 1249_vm +│ │ │ ├── 1255 +│ │ │ └── 1255_fsm +│ │ ├── 13779/ +│ │ │ ├── 112 +│ │ │ ├── 113 +│ │ │ ├── 1247 +│ │ │ ├── 1247_fsm +│ │ │ ├── 1247_vm +│ │ │ ├── 1249 +│ │ │ ├── 1249_fsm +│ │ │ ├── 1249_vm +│ │ │ ├── 1255 +│ │ │ └── 1255_fsm +│ │ ├── 13780/ +│ │ │ ├── 112 +│ │ │ ├── 113 +│ │ │ ├── 1247 +│ │ │ ├── 1247_fsm +│ │ │ ├── 1247_vm +│ │ │ ├── 1249 +│ │ │ ├── 1249_fsm +│ │ │ ├── 1249_vm +│ │ │ ├── 1255 +│ │ │ └── 1255_fsm +│ │ └── 16384/ +│ │ ├── 112 +│ │ ├── 113 +│ │ ├── 1247 +│ │ ├── 1247_fsm +│ │ ├── 1247_vm +│ │ ├── 1249 +│ │ ├── 1249_fsm +│ │ ├── 1249_vm +│ │ ├── 1255 +│ │ └── 1255_fsm +│ ├── global/ +│ │ ├── 1213 +│ │ ├── 1213_fsm +│ │ ├── 1213_vm +│ │ ├── 1214 +│ │ ├── 1214_fsm +│ │ ├── 1214_vm +│ │ ├── 1232 +│ │ ├── 1233 +│ │ ├── 1260 +│ │ └── 1260_fsm +│ ├── pg_commit_ts +│ ├── pg_dynshmem +│ ├── pg_logical/ +│ │ ├── replorigin_checkpoint +│ │ ├── mappings +│ │ └── snapshots +│ ├── pg_multixact/ +│ │ ├── members/ +│ │ │ └── 0000 +│ │ └── offsets/ +│ │ └── 0000 +│ ├── pg_notify +│ ├── pg_replslot +│ ├── pg_serial +│ ├── pg_snapshots +│ ├── pg_stat +│ ├── pg_stat_tmp/ +│ │ ├── db_0.stat +│ │ ├── db_13780.stat +│ │ ├── db_16384.stat +│ │ └── global.stat +│ ├── pg_subtrans/ +│ │ └── 0000 +│ ├── pg_tblspc +│ ├── pg_twophase +│ ├── pg_wal/ +│ │ ├── 000000010000000000000001 +│ │ └── archive_status +│ └── pg_xact/ +│ └── 0000 +v2_testing/ + ├── test_admin_login.py + ├── test_audit_json.py + ├── test_audit_log.py + ├── test_audit_raw.py + ├── test_audit_simple.py + ├── test_audit_timezone.py + ├── test_customer_management.py + ├── test_dashboard.py + ├── test_dashboard_detail.py + └── test_export.py +``` + +## Key Files + +- `Dockerfile` +- `requirements.txt` +- `Makefile` +- `README.md` +- `requirements.txt` +- `Dockerfile` +- `requirements.txt` +- `Dockerfile` +- `requirements.txt` +- `Dockerfile` +- `requirements.txt` +- `Dockerfile` +- `requirements.txt` +- `Dockerfile` +- `requirements.txt` +- `Dockerfile` +- `requirements.txt` +- `Dockerfile` +- `README.md` +- `Dockerfile` + +## Claude Integration + +This project is managed with Claude Project Manager. To work with this project: + +1. Open Claude Project Manager +2. Click on this project's tile +3. Claude will open in the project directory + +## Notes + +*Add your project-specific notes here* + +--- + +## Development Log + +- README generated on 2025-07-05 17:50:23 diff --git a/JOURNAL.md b/JOURNAL.md new file mode 100644 index 0000000..36d3091 --- /dev/null +++ b/JOURNAL.md @@ -0,0 +1,3217 @@ +# 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 new file mode 100644 index 0000000..5c11f05 --- /dev/null +++ b/OPERATIONS_GUIDE.md @@ -0,0 +1,376 @@ +# 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 new file mode 100644 index 0000000..00eeb15 --- /dev/null +++ b/PRODUCTION_DEPLOYMENT.md @@ -0,0 +1,121 @@ +# 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 new file mode 100644 index 0000000..5affa95 --- /dev/null +++ b/SSL/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "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 new file mode 100644 index 0000000..4b13fe5 --- /dev/null +++ b/SSL/SSL_Wichtig.md @@ -0,0 +1,130 @@ +# 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 new file mode 100644 index 0000000..bfb08d2 --- /dev/null +++ b/SSL/cert.pem @@ -0,0 +1,23 @@ +-----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 new file mode 100644 index 0000000..65797c8 --- /dev/null +++ b/SSL/chain.pem @@ -0,0 +1,26 @@ +-----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 new file mode 100644 index 0000000..0317cae --- /dev/null +++ b/SSL/fullchain.pem @@ -0,0 +1,49 @@ +-----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 new file mode 100644 index 0000000..a31c25b --- /dev/null +++ b/SSL/privkey.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgi8/a6iwFCHSbBe/I +2Zo6exFpcLL4icRgotOF605ZrY6hRANCAATEQD6vfDoXM7YziT75OmB/kvxoEebM +FRBCzpTOdUZpThlFmLijjCsYnxc8DeWDn8/eLltrBWhuM4YxgX8tseO0 +-----END PRIVATE KEY----- diff --git a/SYSTEM_DOCUMENTATION.md b/SYSTEM_DOCUMENTATION.md new file mode 100644 index 0000000..d402da7 --- /dev/null +++ b/SYSTEM_DOCUMENTATION.md @@ -0,0 +1,263 @@ +# 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/Start.bat b/Start.bat new file mode 100644 index 0000000..5580dac --- /dev/null +++ b/Start.bat @@ -0,0 +1,36 @@ +@echo off +echo Starting v2-Docker System... +echo. + +cd /d "%~dp0" + +echo Checking Docker status... +docker version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ERROR: Docker is not running or not installed! + echo Please start Docker Desktop first. + pause + exit /b 1 +) + +echo Starting services... +docker-compose -f v2/docker-compose.yaml up -d + +echo. +echo Waiting for services to start... +timeout /t 10 /nobreak >nul + +echo. +echo Checking service status... +docker-compose -f v2/docker-compose.yaml ps + +echo. +echo ======================================== +echo Services started successfully! +echo. +echo Admin Panel: http://localhost +echo License API: http://localhost/api +echo ======================================== +echo. +echo Press any key to exit... +pause >nul \ No newline at end of file diff --git a/backup_before_cleanup.sh b/backup_before_cleanup.sh new file mode 100644 index 0000000..f6fce93 --- /dev/null +++ b/backup_before_cleanup.sh @@ -0,0 +1,69 @@ +#!/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 new file mode 100644 index 0000000..0ad5272 --- /dev/null +++ b/backups/.backup_key @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..8ae4252 --- /dev/null +++ b/backups/backup_v2docker_20250607_174645_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..5bd7742 --- /dev/null +++ b/backups/backup_v2docker_20250607_232845_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..4c0d6bd --- /dev/null +++ b/backups/backup_v2docker_20250608_075834_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..797c966 --- /dev/null +++ b/backups/backup_v2docker_20250608_174930_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..35de0e3 --- /dev/null +++ b/backups/backup_v2docker_20250608_200224_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..744d470 --- /dev/null +++ b/backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..ea84505 --- /dev/null +++ b/backups/backup_v2docker_20250618_020559_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..0bf332f --- /dev/null +++ b/backups/backup_v2docker_20250618_021107_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..84313e0 --- /dev/null +++ b/backups/backup_v2docker_20250618_024414_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..3e26d0a --- /dev/null +++ b/backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..a00a8b7 --- /dev/null +++ b/backups/backup_v2docker_20250618_195853_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..6e646b6 --- /dev/null +++ b/backups/backup_v2docker_20250619_154355_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..a16f5ae --- /dev/null +++ b/backups/backup_v2docker_20250621_030000_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..d1a7764 --- /dev/null +++ b/backups/backup_v2docker_20250621_174144_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..d6affc5 --- /dev/null +++ b/backups/backup_v2docker_20250622_141119_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..790eb50 --- /dev/null +++ b/backups/backup_v2docker_20250622_172034_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..1a277f3 --- /dev/null +++ b/backups/backup_v2docker_20250623_030000_encrypted.sql.gz.enc @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..4e6204a --- /dev/null +++ b/backups/refactoring_20250616_223724/app.py.backup_20250616_223724 @@ -0,0 +1,4475 @@ +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 new file mode 100644 index 0000000..14bad8b --- /dev/null +++ b/backups/refactoring_20250616_223724/blueprint_overview.txt @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..4bf1107 --- /dev/null +++ b/backups/refactoring_20250616_223724/commented_routes.txt @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..375ad25 --- /dev/null +++ b/backups/refactoring_20250616_223724/git_diff.txt @@ -0,0 +1,29251 @@ +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 new file mode 100644 index 0000000..527415a --- /dev/null +++ b/backups/refactoring_20250616_223724/git_log.txt @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..55dd675 --- /dev/null +++ b/backups/refactoring_20250616_223724/git_status.txt @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000..cee53bf --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.11-slim + +# Locale für deutsche Sprache und UTF-8 setzen +ENV LANG=de_DE.UTF-8 +ENV LC_ALL=de_DE.UTF-8 +ENV PYTHONIOENCODING=utf-8 + +# Zeitzone auf Europe/Berlin setzen +ENV TZ=Europe/Berlin + +WORKDIR /app + +# System-Dependencies inkl. PostgreSQL-Tools installieren +RUN apt-get update && apt-get install -y \ + locales \ + postgresql-client \ + tzdata \ + && sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \ + && locale-gen \ + && update-locale LANG=de_DE.UTF-8 \ + && ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \ + && echo "Europe/Berlin" > /etc/timezone \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["python", "app.py"] 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 new file mode 100644 index 0000000..99427c4 Binary files /dev/null and b/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app.cpython-312.pyc 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 new file mode 100644 index 0000000..d47d7f3 Binary files /dev/null and b/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app_no_duplicates.cpython-312.pyc 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 new file mode 100644 index 0000000..9c9153c Binary files /dev/null and b/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/app_refactored.cpython-312.pyc 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 new file mode 100644 index 0000000..09aafeb Binary files /dev/null and b/backups/refactoring_20250616_223724/v2_adminpanel_backup/__pycache__/config.cpython-312.pyc differ diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py new file mode 100644 index 0000000..4e6204a --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py @@ -0,0 +1,4475 @@ +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 new file mode 100644 index 0000000..96c85f1 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup @@ -0,0 +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) 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 new file mode 100644 index 0000000..0622714 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup_before_blueprint_migration @@ -0,0 +1,4461 @@ +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 new file mode 100644 index 0000000..3849500 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.old @@ -0,0 +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) 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 new file mode 100644 index 0000000..f4a9bf2 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_before_blueprint.py @@ -0,0 +1,4460 @@ +import os +import sys +import time +import json +import logging +import requests +import re +import random +import base64 +from io import BytesIO +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path + +# Add current directory to Python path to ensure modules can be imported +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash +from flask_session import Session +from werkzeug.middleware.proxy_fix import ProxyFix +from apscheduler.schedulers.background import BackgroundScheduler +import pandas as pd +from psycopg2.extras import Json + +# Import our new modules +import config +from db import get_connection, get_db_connection, get_db_cursor, execute_query +from auth.decorators import login_required +from auth.password import hash_password, verify_password +from auth.two_factor import ( + generate_totp_secret, generate_qr_code, verify_totp, + generate_backup_codes, hash_backup_code, verify_backup_code +) +from auth.rate_limiting import ( + check_ip_blocked, record_failed_attempt, + reset_login_attempts, get_login_attempts +) +from utils.network import get_client_ip +from utils.audit import log_audit +from utils.license import generate_license_key, validate_license_key +from utils.backup import create_backup, restore_backup, get_or_create_encryption_key +from utils.export import ( + create_excel_export, format_datetime_for_export, + prepare_license_export_data, prepare_customer_export_data, + prepare_session_export_data, prepare_audit_export_data +) +from models import get_user_by_username + +app = Flask(__name__) +# Load configuration from config module +app.config['SECRET_KEY'] = config.SECRET_KEY +app.config['SESSION_TYPE'] = config.SESSION_TYPE +app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII +app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE +app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME +app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY +app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE +app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE +app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME +app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST +Session(app) + +# ProxyFix für korrekte IP-Adressen hinter Nginx +app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 +) + +# Configuration is now loaded from config module + +# Scheduler für automatische Backups +scheduler = BackgroundScheduler() +scheduler.start() + +# Logging konfigurieren +logging.basicConfig(level=logging.INFO) + +# Import and register blueprints +from routes.auth_routes import auth_bp +from routes.admin_routes import admin_bp + +app.register_blueprint(auth_bp) +app.register_blueprint(admin_bp) + + +# Scheduled Backup Job +def scheduled_backup(): + """Führt ein geplantes Backup aus""" + logging.info("Starte geplantes Backup...") + create_backup(backup_type="scheduled", created_by="scheduler") + +# Scheduler konfigurieren - täglich um 3:00 Uhr +scheduler.add_job( + scheduled_backup, + 'cron', + hour=config.SCHEDULER_CONFIG['backup_hour'], + minute=config.SCHEDULER_CONFIG['backup_minute'], + id='daily_backup', + replace_existing=True +) + + +def verify_recaptcha(response): + """Verifiziert die reCAPTCHA v2 Response mit Google""" + secret_key = config.RECAPTCHA_SECRET_KEY + + # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) + if not secret_key: + logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") + return True + + # Verifizierung bei Google + try: + verify_url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': secret_key, + 'response': response + } + + # Timeout für Request setzen + r = requests.post(verify_url, data=data, timeout=5) + result = r.json() + + # Log für Debugging + if not result.get('success'): + logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") + + return result.get('success', False) + + except requests.exceptions.RequestException as e: + logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") + # Bei Netzwerkfehlern CAPTCHA als bestanden werten + return True + except Exception as e: + logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") + return False + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + # Timing-Attack Schutz - Start Zeit merken + start_time = time.time() + + # IP-Adresse ermitteln + ip_address = get_client_ip() + + # Prüfen ob IP gesperrt ist + is_blocked, blocked_until = check_ip_blocked(ip_address) + if is_blocked: + time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + return render_template("login.html", error=error_msg, error_type="blocked") + + # Anzahl bisheriger Versuche + attempt_count = get_login_attempts(ip_address) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + captcha_response = request.form.get("g-recaptcha-response") + + # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind + recaptcha_site_key = config.RECAPTCHA_SITE_KEY + if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + if not captcha_response: + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA ERFORDERLICH!", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # CAPTCHA validieren + if not verify_recaptcha(captcha_response): + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # Check user in database first, fallback to env vars + user = get_user_by_username(username) + login_success = False + needs_2fa = False + + if user: + # Database user authentication + if verify_password(password, user['password_hash']): + login_success = True + needs_2fa = user['totp_enabled'] + else: + # Fallback to environment variables for backward compatibility + if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: + login_success = True + + # Timing-Attack Schutz - Mindestens 1 Sekunde warten + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + if login_success: + # Erfolgreicher Login + if needs_2fa: + # Store temporary session for 2FA verification + session['temp_username'] = username + session['temp_user_id'] = user['id'] + session['awaiting_2fa'] = True + return redirect(url_for('verify_2fa')) + else: + # Complete login without 2FA + session.permanent = True # Aktiviert das Timeout + session['logged_in'] = True + session['username'] = username + session['user_id'] = user['id'] if user else None + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + reset_login_attempts(ip_address) + log_audit('LOGIN_SUCCESS', 'user', + additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + return redirect(url_for('dashboard')) + else: + # Fehlgeschlagener Login + error_message = record_failed_attempt(ip_address, username) + new_attempt_count = get_login_attempts(ip_address) + + # Prüfen ob jetzt gesperrt + is_now_blocked, _ = check_ip_blocked(ip_address) + if is_now_blocked: + log_audit('LOGIN_BLOCKED', 'security', + additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + + return render_template("login.html", + error=error_message, + show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + error_type="failed", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + + # GET Request + return render_template("login.html", + show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + +@app.route("/logout") +def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + session.pop('logged_in', None) + session.pop('username', None) + session.pop('user_id', None) + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + return redirect(url_for('login')) + +@app.route("/verify-2fa", methods=["GET", "POST"]) +def verify_2fa(): + if not session.get('awaiting_2fa'): + return redirect(url_for('login')) + + if request.method == "POST": + token = request.form.get('token', '').replace(' ', '') + username = session.get('temp_username') + user_id = session.get('temp_user_id') + + if not username or not user_id: + flash('Session expired. Please login again.', 'error') + return redirect(url_for('login')) + + user = get_user_by_username(username) + if not user: + flash('User not found.', 'error') + return redirect(url_for('login')) + + # Check if it's a backup code + if len(token) == 8 and token.isupper(): + # Try backup code + backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + if verify_backup_code(token, backup_codes): + # Remove used backup code + code_hash = hash_backup_code(token) + backup_codes.remove(code_hash) + + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + (json.dumps(backup_codes), user_id)) + conn.commit() + cur.close() + conn.close() + + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + flash('Login successful using backup code. Please generate new backup codes.', 'warning') + log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + return redirect(url_for('dashboard')) + else: + # Try TOTP token + if verify_totp(user['totp_secret'], token): + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + return redirect(url_for('dashboard')) + + # Failed verification + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + (datetime.now(), user_id)) + conn.commit() + cur.close() + conn.close() + + flash('Invalid authentication code. Please try again.', 'error') + log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + return render_template('verify_2fa.html') + +@app.route("/profile") +@login_required +def profile(): + user = get_user_by_username(session['username']) + if not user: + # For environment-based users, redirect with message + flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') + return redirect(url_for('dashboard')) + return render_template('profile.html', user=user) + +@app.route("/profile/change-password", methods=["POST"]) +@login_required +def change_password(): + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + user = get_user_by_username(session['username']) + + # Verify current password + if not verify_password(current_password, user['password_hash']): + flash('Current password is incorrect.', 'error') + return redirect(url_for('profile')) + + # Check new password + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return redirect(url_for('profile')) + + if len(new_password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return redirect(url_for('profile')) + + # Update password + new_hash = hash_password(new_password) + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", + (new_hash, datetime.now(), user['id'])) + conn.commit() + cur.close() + conn.close() + + log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], + additional_info="Password changed successfully") + flash('Password changed successfully.', 'success') + return redirect(url_for('profile')) + +@app.route("/profile/setup-2fa") +@login_required +def setup_2fa(): + user = get_user_by_username(session['username']) + + if user['totp_enabled']: + flash('2FA is already enabled for your account.', 'info') + return redirect(url_for('profile')) + + # Generate new TOTP secret + totp_secret = generate_totp_secret() + session['temp_totp_secret'] = totp_secret + + # Generate QR code + qr_code = generate_qr_code(user['username'], totp_secret) + + return render_template('setup_2fa.html', + totp_secret=totp_secret, + qr_code=qr_code) + +@app.route("/profile/enable-2fa", methods=["POST"]) +@login_required +def enable_2fa(): + token = request.form.get('token', '').replace(' ', '') + totp_secret = session.get('temp_totp_secret') + + if not totp_secret: + flash('2FA setup session expired. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Verify the token + if not verify_totp(totp_secret, token): + flash('Invalid authentication code. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Generate backup codes + backup_codes = generate_backup_codes() + hashed_codes = [hash_backup_code(code) for code in backup_codes] + + # Enable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s + WHERE username = %s + """, (totp_secret, json.dumps(hashed_codes), session['username'])) + conn.commit() + cur.close() + conn.close() + + session.pop('temp_totp_secret', None) + + log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") + + # Show backup codes + return render_template('backup_codes.html', backup_codes=backup_codes) + +@app.route("/profile/disable-2fa", methods=["POST"]) +@login_required +def disable_2fa(): + password = request.form.get('password') + user = get_user_by_username(session['username']) + + # Verify password + if not verify_password(password, user['password_hash']): + flash('Incorrect password.', 'error') + return redirect(url_for('profile')) + + # Disable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL + WHERE username = %s + """, (session['username'],)) + conn.commit() + cur.close() + conn.close() + + log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") + flash('2FA has been disabled for your account.', 'success') + return redirect(url_for('profile')) + +@app.route("/heartbeat", methods=['POST']) +@login_required +def heartbeat(): + """Endpoint für Session Keep-Alive - aktualisiert last_activity""" + # Aktualisiere last_activity nur wenn explizit angefordert + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # Force session save + session.modified = True + + return jsonify({ + 'status': 'ok', + 'last_activity': session['last_activity'], + 'username': session.get('username') + }) + +@app.route("/api/generate-license-key", methods=['POST']) +@login_required +def api_generate_key(): + """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" + try: + # Lizenztyp aus Request holen (default: full) + data = request.get_json() or {} + license_type = data.get('type', 'full') + + # Key generieren + key = generate_license_key(license_type) + + # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) + conn = get_connection() + cur = conn.cursor() + + # Wiederhole bis eindeutiger Key gefunden + attempts = 0 + while attempts < 10: # Max 10 Versuche + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) + if not cur.fetchone(): + break # Key ist eindeutig + key = generate_license_key(license_type) + attempts += 1 + + cur.close() + conn.close() + + # Log für Audit + log_audit('GENERATE_KEY', 'license', + additional_info={'type': license_type, 'key': key}) + + return jsonify({ + 'success': True, + 'key': key, + 'type': license_type + }) + + except Exception as e: + logging.error(f"Fehler bei Key-Generierung: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Fehler bei der Key-Generierung' + }), 500 + +@app.route("/api/customers", methods=['GET']) +@login_required +def api_customers(): + """API Endpoint für die Kundensuche mit Select2""" + try: + # Suchparameter + search = request.args.get('q', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 20 + customer_id = request.args.get('id', type=int) + + conn = get_connection() + cur = conn.cursor() + + # Einzelnen Kunden per ID abrufen + if customer_id: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.id = %s + GROUP BY c.id, c.name, c.email + """, (customer_id,)) + + customer = cur.fetchone() + results = [] + if customer: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} ({customer[2]})", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + cur.close() + conn.close() + + return jsonify({ + 'results': results, + 'pagination': {'more': False} + }) + + # SQL Query mit optionaler Suche + elif search: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE LOWER(c.name) LIKE LOWER(%s) + OR LOWER(c.email) LIKE LOWER(%s) + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) + else: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (per_page, (page - 1) * per_page)) + + customers = cur.fetchall() + + # Format für Select2 + results = [] + for customer in customers: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + # Gesamtanzahl für Pagination + if search: + cur.execute(""" + SELECT COUNT(*) FROM customers + WHERE LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + """, (f'%{search}%', f'%{search}%')) + else: + cur.execute("SELECT COUNT(*) FROM customers") + + total_count = cur.fetchone()[0] + + cur.close() + conn.close() + + # Select2 Response Format + return jsonify({ + 'results': results, + 'pagination': { + 'more': (page * per_page) < total_count + } + }) + + except Exception as e: + logging.error(f"Fehler bei Kundensuche: {str(e)}") + return jsonify({ + 'results': [], + 'error': 'Fehler bei der Kundensuche' + }), 500 + +@app.route("/") +@login_required +def dashboard(): + conn = get_connection() + cur = conn.cursor() + + # Statistiken abrufen + # Gesamtanzahl Kunden (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") + total_customers = cur.fetchone()[0] + + # Gesamtanzahl Lizenzen (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") + total_licenses = cur.fetchone()[0] + + # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE + """) + active_licenses = cur.fetchone()[0] + + # Aktive Sessions + cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") + active_sessions_count = cur.fetchone()[0] + + # Abgelaufene Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until < CURRENT_DATE AND is_test = FALSE + """) + expired_licenses = cur.fetchone()[0] + + # Deaktivierte Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE is_active = FALSE AND is_test = FALSE + """) + inactive_licenses = cur.fetchone()[0] + + # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE + AND valid_until < CURRENT_DATE + INTERVAL '30 days' + AND is_active = TRUE + AND is_test = FALSE + """) + expiring_soon = cur.fetchone()[0] + + # Testlizenzen vs Vollversionen (ohne Testdaten) + cur.execute(""" + SELECT license_type, COUNT(*) + FROM licenses + WHERE is_test = FALSE + GROUP BY license_type + """) + license_types = dict(cur.fetchall()) + + # Anzahl Testdaten + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") + test_data_count = cur.fetchone()[0] + + # Anzahl Test-Kunden + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") + test_customers_count = cur.fetchone()[0] + + # Anzahl Test-Ressourcen + cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") + test_resources_count = cur.fetchone()[0] + + # Letzte 5 erstellten Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.is_test = FALSE + ORDER BY l.id DESC + LIMIT 5 + """) + recent_licenses = cur.fetchall() + + # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + l.valid_until - CURRENT_DATE as days_left + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.valid_until >= CURRENT_DATE + AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' + AND l.is_active = TRUE + AND l.is_test = FALSE + ORDER BY l.valid_until + LIMIT 10 + """) + expiring_licenses = cur.fetchall() + + # Letztes Backup + cur.execute(""" + SELECT created_at, filesize, duration_seconds, backup_type, status + FROM backup_history + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup_info = cur.fetchone() + + # Sicherheitsstatistiken + # Gesperrte IPs + cur.execute(""" + SELECT COUNT(*) FROM login_attempts + WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP + """) + blocked_ips_count = cur.fetchone()[0] + + # Fehlversuche heute + cur.execute(""" + SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts + WHERE last_attempt::date = CURRENT_DATE + """) + failed_attempts_today = cur.fetchone()[0] + + # Letzte 5 Sicherheitsereignisse + cur.execute(""" + SELECT + la.ip_address, + la.attempt_count, + la.last_attempt, + la.blocked_until, + la.last_username_tried, + la.last_error_message + FROM login_attempts la + ORDER BY la.last_attempt DESC + LIMIT 5 + """) + recent_security_events = [] + for event in cur.fetchall(): + recent_security_events.append({ + 'ip_address': event[0], + 'attempt_count': event[1], + 'last_attempt': event[2].strftime('%d.%m %H:%M'), + 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, + 'username_tried': event[4], + 'error_message': event[5] + }) + + # Sicherheitslevel berechnen + if blocked_ips_count > 5 or failed_attempts_today > 50: + security_level = 'danger' + security_level_text = 'KRITISCH' + elif blocked_ips_count > 2 or failed_attempts_today > 20: + security_level = 'warning' + security_level_text = 'ERHÖHT' + else: + security_level = 'success' + security_level_text = 'NORMAL' + + # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = FALSE + GROUP BY resource_type + """) + + resource_stats = {} + resource_warning = None + + for row in cur.fetchall(): + available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + resource_stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': available_percent, + 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' + } + + # Warnung bei niedrigem Bestand + if row[1] < 50: + if not resource_warning: + resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" + else: + resource_warning += f" | {row[0].upper()}: {row[1]}" + + cur.close() + conn.close() + + stats = { + 'total_customers': total_customers, + 'total_licenses': total_licenses, + 'active_licenses': active_licenses, + 'expired_licenses': expired_licenses, + 'inactive_licenses': inactive_licenses, + 'expiring_soon': expiring_soon, + 'full_licenses': license_types.get('full', 0), + 'test_licenses': license_types.get('test', 0), + 'test_data_count': test_data_count, + 'test_customers_count': test_customers_count, + 'test_resources_count': test_resources_count, + 'recent_licenses': recent_licenses, + 'expiring_licenses': expiring_licenses, + 'active_sessions': active_sessions_count, + 'last_backup': last_backup_info, + # Sicherheitsstatistiken + 'blocked_ips_count': blocked_ips_count, + 'failed_attempts_today': failed_attempts_today, + 'recent_security_events': recent_security_events, + 'security_level': security_level, + 'security_level_text': security_level_text, + 'resource_stats': resource_stats + } + + return render_template("dashboard.html", + stats=stats, + resource_stats=resource_stats, + resource_warning=resource_warning, + username=session.get('username')) + +@app.route("/create", methods=["GET", "POST"]) +@login_required +def create_license(): + if request.method == "POST": + customer_id = request.form.get("customer_id") + license_key = request.form["license_key"].upper() # Immer Großbuchstaben + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Validiere License Key Format + if not validate_license_key(license_key): + flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') + return redirect(url_for('create_license')) + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('create_license')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('create_license')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + customer_info = {'name': name, 'email': email, 'is_test': is_test} + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos für Audit-Log + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('create_license')) + customer_info = {'name': customer_data[0], 'email': customer_data[1]} + + # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Lizenz hinzufügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit, is_test) + VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit, is_test)) + license_id = cur.fetchone()[0] + + # Ressourcen zuweisen + try: + # Prüfe Verfügbarkeit + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") + if available[1] < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") + if available[2] < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") + + # Domains zuweisen + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s zuweisen + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern zuweisen + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + except ValueError as e: + conn.rollback() + flash(str(e), 'error') + return redirect(url_for('create_license')) + + conn.commit() + + # Audit-Log + log_audit('CREATE', 'license', license_id, + new_values={ + 'license_key': license_key, + 'customer_name': customer_info['name'], + 'customer_email': customer_info['email'], + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'device_limit': device_limit, + 'is_test': is_test + }) + + flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') + + except Exception as e: + conn.rollback() + logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") + flash('Fehler beim Erstellen der Lizenz!', 'error') + finally: + cur.close() + conn.close() + + # Preserve show_test parameter if present + redirect_url = "/create" + if request.args.get('show_test') == 'true': + redirect_url += "?show_test=true" + return redirect(redirect_url) + + # Unterstützung für vorausgewählten Kunden + preselected_customer_id = request.args.get('customer_id', type=int) + return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) + +@app.route("/batch", methods=["GET", "POST"]) +@login_required +def batch_licenses(): + """Batch-Generierung mehrerer Lizenzen für einen Kunden""" + if request.method == "POST": + # Formulardaten + customer_id = request.form.get("customer_id") + license_type = request.form["license_type"] + quantity = int(request.form["quantity"]) + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + # Sicherheitslimit + if quantity < 1 or quantity > 100: + flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') + return redirect(url_for('batch_licenses')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('batch_licenses')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('batch_licenses')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('batch_licenses')) + name = customer_data[0] + email = customer_data[1] + + # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch + total_domains_needed = domain_count * quantity + total_ipv4s_needed = ipv4_count * quantity + total_phones_needed = phone_count * quantity + + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < total_domains_needed: + flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') + return redirect(url_for('batch_licenses')) + if available[1] < total_ipv4s_needed: + flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') + return redirect(url_for('batch_licenses')) + if available[2] < total_phones_needed: + flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') + return redirect(url_for('batch_licenses')) + + # Lizenzen generieren und speichern + generated_licenses = [] + for i in range(quantity): + # Eindeutigen Key generieren + attempts = 0 + while attempts < 10: + license_key = generate_license_key(license_type) + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) + if not cur.fetchone(): + break + attempts += 1 + + # Lizenz einfügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, is_test, + valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit) + VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit)) + license_id = cur.fetchone()[0] + + # Ressourcen für diese Lizenz zuweisen + # Domains + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + generated_licenses.append({ + 'id': license_id, + 'key': license_key, + 'type': license_type + }) + + conn.commit() + + # Audit-Log + log_audit('CREATE_BATCH', 'license', + new_values={'customer': name, 'quantity': quantity, 'type': license_type}, + additional_info=f"Batch-Generierung von {quantity} Lizenzen") + + # Session für Export speichern + session['batch_export'] = { + 'customer': name, + 'email': email, + 'licenses': generated_licenses, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + } + + flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') + return render_template("batch_result.html", + customer=name, + email=email, + licenses=generated_licenses, + valid_from=valid_from, + valid_until=valid_until) + + except Exception as e: + conn.rollback() + logging.error(f"Fehler bei Batch-Generierung: {str(e)}") + flash('Fehler bei der Batch-Generierung!', 'error') + return redirect(url_for('batch_licenses')) + finally: + cur.close() + conn.close() + + # GET Request + return render_template("batch_form.html") + +@app.route("/batch/export") +@login_required +def export_batch(): + """Exportiert die zuletzt generierten Batch-Lizenzen""" + batch_data = session.get('batch_export') + if not batch_data: + flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') + return redirect(url_for('batch_licenses')) + + # CSV generieren + output = io.StringIO() + output.write('\ufeff') # UTF-8 BOM für Excel + + # Header + output.write(f"Kunde: {batch_data['customer']}\n") + output.write(f"E-Mail: {batch_data['email']}\n") + output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") + output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") + output.write("\n") + output.write("Nr;Lizenzschlüssel;Typ\n") + + # Lizenzen + for i, license in enumerate(batch_data['licenses'], 1): + typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" + output.write(f"{i};{license['key']};{typ_text}\n") + + output.seek(0) + + # Audit-Log + log_audit('EXPORT', 'batch_licenses', + additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" + ) + +@app.route("/licenses") +@login_required +def licenses(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/license/edit/", methods=["GET", "POST"]) +@login_required +def edit_license(license_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute(""" + SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit + FROM licenses WHERE id = %s + """, (license_id,)) + old_license = cur.fetchone() + + # Update license + license_key = request.form["license_key"] + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + valid_until = request.form["valid_until"] + is_active = request.form.get("is_active") == "on" + is_test = request.form.get("is_test") == "on" + device_limit = int(request.form.get("device_limit", 3)) + + cur.execute(""" + UPDATE licenses + SET license_key = %s, license_type = %s, valid_from = %s, + valid_until = %s, is_active = %s, is_test = %s, device_limit = %s + WHERE id = %s + """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'license_key': old_license[0], + 'license_type': old_license[1], + 'valid_from': str(old_license[2]), + 'valid_until': str(old_license[3]), + 'is_active': old_license[4], + 'is_test': old_license[5], + 'device_limit': old_license[6] + }, + new_values={ + 'license_key': license_key, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'is_active': is_active, + 'is_test': is_test, + 'device_limit': device_limit + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei wenn vorhanden + if request.referrer and 'customer_id=' in request.referrer: + import re + match = re.search(r'customer_id=(\d+)', request.referrer) + if match: + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={match.group(1)}" + + return redirect(redirect_url) + + # Get license data + cur.execute(""" + SELECT l.id, l.license_key, c.name, c.email, l.license_type, + l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + + license = cur.fetchone() + cur.close() + conn.close() + + if not license: + return redirect("/licenses") + + return render_template("edit_license.html", license=license, username=session.get('username')) + +@app.route("/license/delete/", methods=["POST"]) +@login_required +def delete_license(license_id): + conn = get_connection() + cur = conn.cursor() + + # Lizenzdetails für Audit-Log abrufen + cur.execute(""" + SELECT l.license_key, c.name, l.license_type + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + license_info = cur.fetchone() + + cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) + + conn.commit() + + # Audit-Log + if license_info: + log_audit('DELETE', 'license', license_id, + old_values={ + 'license_key': license_info[0], + 'customer_name': license_info[1], + 'license_type': license_info[2] + }) + + cur.close() + conn.close() + + return redirect("/licenses") + +@app.route("/customers") +@login_required +def customers(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/customer/edit/", methods=["GET", "POST"]) +@login_required +def edit_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + old_customer = cur.fetchone() + + # Update customer + name = request.form["name"] + email = request.form["email"] + is_test = request.form.get("is_test") == "on" + + cur.execute(""" + UPDATE customers + SET name = %s, email = %s, is_test = %s + WHERE id = %s + """, (name, email, is_test, customer_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'customer', customer_id, + old_values={ + 'name': old_customer[0], + 'email': old_customer[1], + 'is_test': old_customer[2] + }, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei (immer der aktuelle Kunde) + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={customer_id}" + + return redirect(redirect_url) + + # Get customer data with licenses + cur.execute(""" + SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s + """, (customer_id,)) + + customer = cur.fetchone() + if not customer: + cur.close() + conn.close() + return "Kunde nicht gefunden", 404 + + + # Get customer's licenses + cur.execute(""" + SELECT id, license_key, license_type, valid_from, valid_until, is_active + FROM licenses + WHERE customer_id = %s + ORDER BY valid_until DESC + """, (customer_id,)) + + licenses = cur.fetchall() + + cur.close() + conn.close() + + if not customer: + return redirect("/customers-licenses") + + return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) + +@app.route("/customer/create", methods=["GET", "POST"]) +@login_required +def create_customer(): + """Erstellt einen neuen Kunden ohne Lizenz""" + if request.method == "POST": + name = request.form.get('name') + email = request.form.get('email') + is_test = request.form.get('is_test') == 'on' + + if not name or not email: + flash("Name und E-Mail sind Pflichtfelder!", "error") + return render_template("create_customer.html", username=session.get('username')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfen ob E-Mail bereits existiert + cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) + existing = cur.fetchone() + if existing: + flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") + return render_template("create_customer.html", username=session.get('username')) + + # Kunde erstellen + cur.execute(""" + INSERT INTO customers (name, email, created_at, is_test) + VALUES (%s, %s, %s, %s) RETURNING id + """, (name, email, datetime.now(), is_test)) + + customer_id = cur.fetchone()[0] + conn.commit() + + # Audit-Log + log_audit('CREATE', 'customer', customer_id, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") + return redirect(f"/customer/edit/{customer_id}") + + except Exception as e: + conn.rollback() + flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") + return render_template("create_customer.html", username=session.get('username')) + finally: + cur.close() + conn.close() + + # GET Request - Formular anzeigen + return render_template("create_customer.html", username=session.get('username')) + +@app.route("/customer/delete/", methods=["POST"]) +@login_required +def delete_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + # Prüfen ob Kunde Lizenzen hat + cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) + license_count = cur.fetchone()[0] + + if license_count > 0: + # Kunde hat Lizenzen - nicht löschen + cur.close() + conn.close() + return redirect("/customers") + + # Kundendetails für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + customer_info = cur.fetchone() + + # Kunde löschen wenn keine Lizenzen vorhanden + cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) + + conn.commit() + + # Audit-Log + if customer_info: + log_audit('DELETE', 'customer', customer_id, + old_values={ + 'name': customer_info[0], + 'email': customer_info[1] + }) + + cur.close() + conn.close() + + return redirect("/customers") + +@app.route("/customers-licenses") +@login_required +def customers_licenses(): + """Kombinierte Ansicht für Kunden und deren Lizenzen""" + conn = get_connection() + cur = conn.cursor() + + # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + query = """ + SELECT + c.id, + c.name, + c.email, + c.created_at, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + """ + + if not show_test: + query += " WHERE c.is_test = FALSE" + + query += """ + GROUP BY c.id, c.name, c.email, c.created_at + ORDER BY c.name + """ + + cur.execute(query) + customers = cur.fetchall() + + # Hole ausgewählten Kunden nur wenn explizit in URL angegeben + selected_customer_id = request.args.get('customer_id', type=int) + licenses = [] + selected_customer = None + + if customers and selected_customer_id: + # Hole Daten des ausgewählten Kunden + for customer in customers: + if customer[0] == selected_customer_id: + selected_customer = customer + break + + # Hole Lizenzen des ausgewählten Kunden + if selected_customer: + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (selected_customer_id,)) + licenses = cur.fetchall() + + cur.close() + conn.close() + + return render_template("customers_licenses.html", + customers=customers, + selected_customer=selected_customer, + selected_customer_id=selected_customer_id, + licenses=licenses, + show_test=show_test) + +@app.route("/api/customer//licenses") +@login_required +def api_customer_licenses(customer_id): + """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Lizenzen des Kunden + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (customer_id,)) + + licenses = [] + for row in cur.fetchall(): + license_id = row[0] + + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for res_row in cur.fetchall(): + resource_info = { + 'id': res_row[0], + 'value': res_row[2], + 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' + } + + if res_row[1] == 'domain': + resources['domains'].append(resource_info) + elif res_row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif res_row[1] == 'phone': + resources['phones'].append(resource_info) + + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'license_type': row[2], + 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', + 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', + 'is_active': row[5], + 'status': row[6], + 'domain_count': row[7], # limit + 'ipv4_count': row[8], # limit + 'phone_count': row[9], # limit + 'device_limit': row[10], + 'active_devices': row[11], + 'actual_domain_count': row[12], # actual count + 'actual_ipv4_count': row[13], # actual count + 'actual_phone_count': row[14], # actual count + 'resources': resources + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'licenses': licenses, + 'count': len(licenses) + }) + +@app.route("/api/customer//quick-stats") +@login_required +def api_customer_quick_stats(customer_id): + """API-Endpoint für Schnellstatistiken eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Kundenstatistiken + cur.execute(""" + SELECT + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon + FROM licenses l + WHERE l.customer_id = %s + """, (customer_id,)) + + stats = cur.fetchone() + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'stats': { + 'total': stats[0], + 'active': stats[1], + 'expired': stats[2], + 'expiring_soon': stats[3] + } + }) + +@app.route("/api/license//quick-edit", methods=['POST']) +@login_required +def api_license_quick_edit(license_id): + """API-Endpoint für schnelle Lizenz-Bearbeitung""" + conn = get_connection() + cur = conn.cursor() + + try: + data = request.get_json() + + # Hole alte Werte für Audit-Log + cur.execute(""" + SELECT is_active, valid_until, license_type + FROM licenses WHERE id = %s + """, (license_id,)) + old_values = cur.fetchone() + + if not old_values: + return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 + + # Update-Felder vorbereiten + updates = [] + params = [] + new_values = {} + + if 'is_active' in data: + updates.append("is_active = %s") + params.append(data['is_active']) + new_values['is_active'] = data['is_active'] + + if 'valid_until' in data: + updates.append("valid_until = %s") + params.append(data['valid_until']) + new_values['valid_until'] = data['valid_until'] + + if 'license_type' in data: + updates.append("license_type = %s") + params.append(data['license_type']) + new_values['license_type'] = data['license_type'] + + if updates: + params.append(license_id) + cur.execute(f""" + UPDATE licenses + SET {', '.join(updates)} + WHERE id = %s + """, params) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'is_active': old_values[0], + 'valid_until': old_values[1].isoformat() if old_values[1] else None, + 'license_type': old_values[2] + }, + new_values=new_values) + + cur.close() + conn.close() + + return jsonify({'success': True}) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/api/license//resources") +@login_required +def api_license_resources(license_id): + """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for row in cur.fetchall(): + resource_info = { + 'id': row[0], + 'value': row[2], + 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' + } + + if row[1] == 'domain': + resources['domains'].append(resource_info) + elif row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif row[1] == 'phone': + resources['phones'].append(resource_info) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'resources': resources + }) + + except Exception as e: + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/sessions") +@login_required +def sessions(): + conn = get_connection() + cur = conn.cursor() + + # Sortierparameter + active_sort = request.args.get('active_sort', 'last_heartbeat') + active_order = request.args.get('active_order', 'desc') + ended_sort = request.args.get('ended_sort', 'ended_at') + ended_order = request.args.get('ended_order', 'desc') + + # Whitelist für erlaubte Sortierfelder - Aktive Sessions + active_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'last_heartbeat': 's.last_heartbeat', + 'inactive': 'minutes_inactive' + } + + # Whitelist für erlaubte Sortierfelder - Beendete Sessions + ended_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'ended_at': 's.ended_at', + 'duration': 'duration_minutes' + } + + # Validierung + if active_sort not in active_sort_fields: + active_sort = 'last_heartbeat' + if ended_sort not in ended_sort_fields: + ended_sort = 'ended_at' + if active_order not in ['asc', 'desc']: + active_order = 'desc' + if ended_order not in ['asc', 'desc']: + ended_order = 'desc' + + # Aktive Sessions abrufen + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.user_agent, s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = TRUE + ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} + """) + active_sessions = cur.fetchall() + + # Inaktive Sessions der letzten 24 Stunden + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = FALSE + AND s.ended_at > NOW() - INTERVAL '24 hours' + ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} + LIMIT 50 + """) + recent_sessions = cur.fetchall() + + cur.close() + conn.close() + + return render_template("sessions.html", + active_sessions=active_sessions, + recent_sessions=recent_sessions, + active_sort=active_sort, + active_order=active_order, + ended_sort=ended_sort, + ended_order=ended_order, + username=session.get('username')) + +@app.route("/session/end/", methods=["POST"]) +@login_required +def end_session(session_id): + conn = get_connection() + cur = conn.cursor() + + # Session beenden + cur.execute(""" + UPDATE sessions + SET is_active = FALSE, ended_at = NOW() + WHERE id = %s AND is_active = TRUE + """, (session_id,)) + + conn.commit() + cur.close() + conn.close() + + return redirect("/sessions") + +@app.route("/export/licenses") +@login_required +def export_licenses(): + conn = get_connection() + cur = conn.cursor() + + # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) + include_test = request.args.get('include_test', 'false').lower() == 'true' + customer_id = request.args.get('customer_id', type=int) + + query = """ + SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, + CASE + WHEN l.is_active = FALSE THEN 'Deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' + ELSE 'Aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + """ + + # Build WHERE clause + where_conditions = [] + params = [] + + if not include_test: + where_conditions.append("l.is_test = FALSE") + + if customer_id: + where_conditions.append("l.customer_id = %s") + params.append(customer_id) + + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + query += " ORDER BY l.id" + + cur.execute(query, params) + + # Spaltennamen + columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', + 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') + df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') + + # Typ und Aktiv Status anpassen + df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) + df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'license', + additional_info=f"Export aller Lizenzen als {export_format.upper()}") + filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Lizenzen', index=False) + + # Formatierung + worksheet = writer.sheets['Lizenzen'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/audit") +@login_required +def export_audit(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_user = request.args.get('user', '') + filter_action = request.args.get('action', '') + filter_entity = request.args.get('entity', '') + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + params = [] + + if filter_user: + query += " AND username ILIKE %s" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + query += " ORDER BY timestamp DESC" + + cur.execute(query, params) + audit_logs = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for log in audit_logs: + action_text = { + 'CREATE': 'Erstellt', + 'UPDATE': 'Bearbeitet', + 'DELETE': 'Gelöscht', + 'LOGIN': 'Anmeldung', + 'LOGOUT': 'Abmeldung', + 'AUTO_LOGOUT': 'Auto-Logout', + 'EXPORT': 'Export', + 'GENERATE_KEY': 'Key generiert', + 'CREATE_BATCH': 'Batch erstellt', + 'BACKUP': 'Backup erstellt', + 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', + 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', + 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', + 'LOGIN_BLOCKED': 'Login-Blockiert', + 'RESTORE': 'Wiederhergestellt', + 'PASSWORD_CHANGE': 'Passwort geändert', + '2FA_ENABLED': '2FA aktiviert', + '2FA_DISABLED': '2FA deaktiviert' + }.get(log[3], log[3]) + + data.append({ + 'ID': log[0], + 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), + 'Benutzer': log[2], + 'Aktion': action_text, + 'Entität': log[4], + 'Entität-ID': log[5] or '', + 'IP-Adresse': log[8] or '', + 'Zusatzinfo': log[10] or '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'audit_log_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'audit_log', + additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Audit Log') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Audit Log'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/customers") +@login_required +def export_customers(): + conn = get_connection() + cur = conn.cursor() + + # Check if test data should be included + include_test = request.args.get('include_test', 'false').lower() == 'true' + + # Build query based on test data filter + if include_test: + # Include all customers + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + else: + # Exclude test customers and test licenses + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.is_test = FALSE + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + + cur.execute(query) + + # Spaltennamen + columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', + 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') + + # Testdaten formatting + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'customer', + additional_info=f"Export aller Kunden als {export_format.upper()}") + filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Kunden', index=False) + + # Formatierung + worksheet = writer.sheets['Kunden'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/sessions") +@login_required +def export_sessions(): + conn = get_connection() + cur = conn.cursor() + + # Holen des Session-Typs (active oder ended) + session_type = request.args.get('type', 'active') + export_format = request.args.get('format', 'excel') + + # Daten je nach Typ abrufen + if session_type == 'active': + # Aktive Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = true + ORDER BY s.last_heartbeat DESC + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Aktive Sessions' + filename_prefix = 'aktive_sessions' + else: + # Beendete Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = false AND s.ended_at IS NOT NULL + ORDER BY s.ended_at DESC + LIMIT 1000 + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] if sess[6] else 0 + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Beendete Sessions' + filename_prefix = 'beendete_sessions' + + cur.close() + conn.close() + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'{filename_prefix}_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'sessions', + additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name=sheet_name) + + # Spaltenbreiten anpassen + worksheet = writer.sheets[sheet_name] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/resources") +@login_required +def export_resources(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_type = request.args.get('type', '') + filter_status = request.args.get('status', '') + search_query = request.args.get('search', '') + show_test = request.args.get('show_test', 'false').lower() == 'true' + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, + r.created_at, r.status_changed_at, + l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type + FROM resource_pools r + LEFT JOIN licenses l ON r.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE 1=1 + """ + params = [] + + # Filter für Testdaten + if not show_test: + query += " AND (r.is_test = false OR r.is_test IS NULL)" + + # Filter für Ressourcentyp + if filter_type: + query += " AND r.resource_type = %s" + params.append(filter_type) + + # Filter für Status + if filter_status: + query += " AND r.status = %s" + params.append(filter_status) + + # Suchfilter + if search_query: + query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" + params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) + + query += " ORDER BY r.id DESC" + + cur.execute(query, params) + resources = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for res in resources: + status_text = { + 'available': 'Verfügbar', + 'allocated': 'Zugewiesen', + 'quarantine': 'Quarantäne' + }.get(res[3], res[3]) + + type_text = { + 'domain': 'Domain', + 'ipv4': 'IPv4', + 'phone': 'Telefon' + }.get(res[1], res[1]) + + data.append({ + 'ID': res[0], + 'Typ': type_text, + 'Ressource': res[2], + 'Status': status_text, + 'Lizenzschlüssel': res[7] or '', + 'Kunde': res[8] or '', + 'Kunden-Email': res[9] or '', + 'Lizenztyp': res[10] or '', + 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', + 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'resources_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'resources', + additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Resources') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Resources'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/audit") +@login_required +def audit_log(): + conn = get_connection() + cur = conn.cursor() + + # Parameter + filter_user = request.args.get('user', '').strip() + filter_action = request.args.get('action', '').strip() + filter_entity = request.args.get('entity', '').strip() + page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'timestamp') + order = request.args.get('order', 'desc') + per_page = 50 + + # Whitelist für erlaubte Sortierfelder + allowed_sort_fields = { + 'timestamp': 'timestamp', + 'username': 'username', + 'action': 'action', + 'entity': 'entity_type', + 'ip': 'ip_address' + } + + # Validierung + if sort not in allowed_sort_fields: + sort = 'timestamp' + if order not in ['asc', 'desc']: + order = 'desc' + + sort_field = allowed_sort_fields[sort] + + # SQL Query mit optionalen Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + + params = [] + + # Filter + if filter_user: + query += " AND LOWER(username) LIKE LOWER(%s)" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + # Gesamtanzahl für Pagination + count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" + cur.execute(count_query, params) + total = cur.fetchone()[0] + + # Pagination + offset = (page - 1) * per_page + query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + logs = cur.fetchall() + + # JSON-Werte parsen + parsed_logs = [] + for log in logs: + parsed_log = list(log) + # old_values und new_values sind bereits Dictionaries (JSONB) + # Keine Konvertierung nötig + parsed_logs.append(parsed_log) + + # Pagination Info + total_pages = (total + per_page - 1) // per_page + + cur.close() + conn.close() + + return render_template("audit_log.html", + logs=parsed_logs, + filter_user=filter_user, + filter_action=filter_action, + filter_entity=filter_entity, + page=page, + total_pages=total_pages, + total=total, + sort=sort, + order=order, + username=session.get('username')) + +@app.route("/backups") +@login_required +def backups(): + """Zeigt die Backup-Historie an""" + conn = get_connection() + cur = conn.cursor() + + # Letztes erfolgreiches Backup für Dashboard + cur.execute(""" + SELECT created_at, filesize, duration_seconds + FROM backup_history + WHERE status = 'success' + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup = cur.fetchone() + + # Alle Backups abrufen + cur.execute(""" + SELECT id, filename, filesize, backup_type, status, error_message, + created_at, created_by, tables_count, records_count, + duration_seconds, is_encrypted + FROM backup_history + ORDER BY created_at DESC + """) + backups = cur.fetchall() + + cur.close() + conn.close() + + return render_template("backups.html", + backups=backups, + last_backup=last_backup, + username=session.get('username')) + +@app.route("/backup/create", methods=["POST"]) +@login_required +def create_backup_route(): + """Erstellt ein manuelles Backup""" + username = session.get('username') + success, result = create_backup(backup_type="manual", created_by=username) + + if success: + return jsonify({ + 'success': True, + 'message': f'Backup erfolgreich erstellt: {result}' + }) + else: + return jsonify({ + 'success': False, + 'message': f'Backup fehlgeschlagen: {result}' + }), 500 + +@app.route("/backup/restore/", methods=["POST"]) +@login_required +def restore_backup_route(backup_id): + """Stellt ein Backup wieder her""" + encryption_key = request.form.get('encryption_key') + + success, message = restore_backup(backup_id, encryption_key) + + if success: + return jsonify({ + 'success': True, + 'message': message + }) + else: + return jsonify({ + 'success': False, + 'message': message + }), 500 + +@app.route("/backup/download/") +@login_required +def download_backup(backup_id): + """Lädt eine Backup-Datei herunter""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + cur.close() + conn.close() + + if not backup_info: + return "Backup nicht gefunden", 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + if not filepath.exists(): + return "Backup-Datei nicht gefunden", 404 + + # Audit-Log + log_audit('DOWNLOAD', 'backup', backup_id, + additional_info=f"Backup heruntergeladen: {filename}") + + return send_file(filepath, as_attachment=True, download_name=filename) + +@app.route("/backup/delete/", methods=["DELETE"]) +@login_required +def delete_backup(backup_id): + """Löscht ein Backup""" + conn = get_connection() + cur = conn.cursor() + + try: + # Backup-Informationen abrufen + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + if not backup_info: + return jsonify({ + 'success': False, + 'message': 'Backup nicht gefunden' + }), 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + # Datei löschen, wenn sie existiert + if filepath.exists(): + filepath.unlink() + + # Aus Datenbank löschen + cur.execute(""" + DELETE FROM backup_history + WHERE id = %s + """, (backup_id,)) + + conn.commit() + + # Audit-Log + log_audit('DELETE', 'backup', backup_id, + additional_info=f"Backup gelöscht: {filename}") + + return jsonify({ + 'success': True, + 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' + }) + + except Exception as e: + conn.rollback() + return jsonify({ + 'success': False, + 'message': f'Fehler beim Löschen des Backups: {str(e)}' + }), 500 + finally: + cur.close() + conn.close() + +@app.route("/security/blocked-ips") +@login_required +def blocked_ips(): + """Zeigt alle gesperrten IPs an""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT + ip_address, + attempt_count, + first_attempt, + last_attempt, + blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + WHERE blocked_until IS NOT NULL + ORDER BY blocked_until DESC + """) + + blocked_ips_list = [] + for ip in cur.fetchall(): + blocked_ips_list.append({ + 'ip_address': ip[0], + 'attempt_count': ip[1], + 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), + 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), + 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), + 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), + 'last_username': ip[5], + 'last_error': ip[6] + }) + + cur.close() + conn.close() + + return render_template("blocked_ips.html", + blocked_ips=blocked_ips_list, + username=session.get('username')) + +@app.route("/security/unblock-ip", methods=["POST"]) +@login_required +def unblock_ip(): + """Entsperrt eine IP-Adresse""" + ip_address = request.form.get('ip_address') + + if ip_address: + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + UPDATE login_attempts + SET blocked_until = NULL + WHERE ip_address = %s + """, (ip_address,)) + + conn.commit() + cur.close() + conn.close() + + # Audit-Log + log_audit('UNBLOCK_IP', 'security', + additional_info=f"IP {ip_address} manuell entsperrt") + + return redirect(url_for('blocked_ips')) + +@app.route("/security/clear-attempts", methods=["POST"]) +@login_required +def clear_attempts(): + """Löscht alle Login-Versuche für eine IP""" + ip_address = request.form.get('ip_address') + + if ip_address: + reset_login_attempts(ip_address) + + # Audit-Log + log_audit('CLEAR_ATTEMPTS', 'security', + additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") + + return redirect(url_for('blocked_ips')) + +# API Endpoints for License Management +@app.route("/api/license//toggle", methods=["POST"]) +@login_required +def toggle_license_api(license_id): + """Toggle license active status via API""" + try: + data = request.get_json() + is_active = data.get('is_active', False) + + conn = get_connection() + cur = conn.cursor() + + # Update license status + cur.execute(""" + UPDATE licenses + SET is_active = %s + WHERE id = %s + """, (is_active, license_id)) + + conn.commit() + + # Log the action + log_audit('UPDATE', 'license', license_id, + new_values={'is_active': is_active}, + additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-activate", methods=["POST"]) +@login_required +def bulk_activate_licenses(): + """Activate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = TRUE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': True, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen aktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) +@login_required +def bulk_deactivate_licenses(): + """Deactivate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = FALSE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': False, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen deaktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/license//devices") +@login_required +def get_license_devices(license_id): + """Hole alle registrierten Geräte einer Lizenz""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und hole device_limit + cur.execute(""" + SELECT device_limit FROM licenses WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit = license_data[0] + + # Hole alle Geräte für diese Lizenz + cur.execute(""" + SELECT id, hardware_id, device_name, operating_system, + first_seen, last_seen, is_active, ip_address + FROM device_registrations + WHERE license_id = %s + ORDER BY is_active DESC, last_seen DESC + """, (license_id,)) + + devices = [] + for row in cur.fetchall(): + devices.append({ + 'id': row[0], + 'hardware_id': row[1], + 'device_name': row[2] or 'Unbekanntes Gerät', + 'operating_system': row[3] or 'Unbekannt', + 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', + 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', + 'is_active': row[6], + 'ip_address': row[7] or '-' + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'devices': devices, + 'device_limit': device_limit, + 'active_count': sum(1 for d in devices if d['is_active']) + }) + + except Exception as e: + logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 + +@app.route("/api/license//register-device", methods=["POST"]) +def register_device(license_id): + """Registriere ein neues Gerät für eine Lizenz""" + try: + data = request.get_json() + hardware_id = data.get('hardware_id') + device_name = data.get('device_name', '') + operating_system = data.get('operating_system', '') + + if not hardware_id: + return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und aktiv ist + cur.execute(""" + SELECT device_limit, is_active, valid_until + FROM licenses + WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit, is_active, valid_until = license_data + + # Prüfe ob Lizenz aktiv und gültig ist + if not is_active: + return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 + + if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): + return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 + + # Prüfe ob Gerät bereits registriert ist + cur.execute(""" + SELECT id, is_active FROM device_registrations + WHERE license_id = %s AND hardware_id = %s + """, (license_id, hardware_id)) + existing_device = cur.fetchone() + + if existing_device: + device_id, is_device_active = existing_device + if is_device_active: + # Gerät ist bereits aktiv, update last_seen + cur.execute(""" + UPDATE device_registrations + SET last_seen = CURRENT_TIMESTAMP, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) + else: + # Gerät war deaktiviert, prüfe ob wir es reaktivieren können + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Reaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = TRUE, + last_seen = CURRENT_TIMESTAMP, + deactivated_at = NULL, + deactivated_by = NULL, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) + + # Neues Gerät - prüfe Gerätelimit + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Registriere neues Gerät + cur.execute(""" + INSERT INTO device_registrations + (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, (license_id, hardware_id, device_name, operating_system, + get_client_ip(), request.headers.get('User-Agent', ''))) + device_id = cur.fetchone()[0] + + conn.commit() + + # Audit Log + log_audit('DEVICE_REGISTER', 'device', device_id, + new_values={'license_id': license_id, 'hardware_id': hardware_id}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) + + except Exception as e: + logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 + +@app.route("/api/license//deactivate-device/", methods=["POST"]) +@login_required +def deactivate_device(license_id, device_id): + """Deaktiviere ein registriertes Gerät""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob das Gerät zu dieser Lizenz gehört + cur.execute(""" + SELECT id FROM device_registrations + WHERE id = %s AND license_id = %s AND is_active = TRUE + """, (device_id, license_id)) + + if not cur.fetchone(): + return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 + + # Deaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = FALSE, + deactivated_at = CURRENT_TIMESTAMP, + deactivated_by = %s + WHERE id = %s + """, (session['username'], device_id)) + + conn.commit() + + # Audit Log + log_audit('DEVICE_DEACTIVATE', 'device', device_id, + old_values={'is_active': True}, + new_values={'is_active': False}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) + + except Exception as e: + logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 + +@app.route("/api/licenses/bulk-delete", methods=["POST"]) +@login_required +def bulk_delete_licenses(): + """Delete multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Get license info for audit log (nur Live-Daten) + cur.execute(""" + SELECT license_key + FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + license_keys = [row[0] for row in cur.fetchall()] + + # Delete all selected licenses (nur Live-Daten) + cur.execute(""" + DELETE FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_DELETE', 'licenses', None, + old_values={'license_keys': license_keys, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen gelöscht") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +# ===================== RESOURCE POOL MANAGEMENT ===================== + +@app.route('/resources') +@login_required +def resources(): + """Resource Pool Hauptübersicht""" + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + # Statistiken abrufen + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = %s + GROUP BY resource_type + """, (show_test,)) + + stats = {} + for row in cur.fetchall(): + stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + } + + # Letzte Aktivitäten (gefiltert nach Test/Live) + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rp.resource_type, + rp.resource_value, + rh.details + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + WHERE rp.is_test = %s + ORDER BY rh.action_at DESC + LIMIT 10 + """, (show_test,)) + recent_activities = cur.fetchall() + + # Ressourcen-Liste mit Pagination + page = request.args.get('page', 1, type=int) + per_page = 50 + offset = (page - 1) * per_page + + resource_type = request.args.get('type', '') + status_filter = request.args.get('status', '') + search = request.args.get('search', '') + + # Sortierung + sort_by = request.args.get('sort', 'id') + sort_order = request.args.get('order', 'desc') + + # Base Query + query = """ + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + rp.allocated_to_license, + l.license_key, + c.name as customer_name, + rp.status_changed_at, + rp.quarantine_reason, + rp.quarantine_until, + c.id as customer_id + FROM resource_pools rp + LEFT JOIN licenses l ON rp.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rp.is_test = %s + """ + params = [show_test] + + if resource_type: + query += " AND rp.resource_type = %s" + params.append(resource_type) + + if status_filter: + query += " AND rp.status = %s" + params.append(status_filter) + + if search: + query += " AND rp.resource_value ILIKE %s" + params.append(f'%{search}%') + + # Count total + count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" + cur.execute(count_query, params) + total = cur.fetchone()[0] + total_pages = (total + per_page - 1) // per_page + + # Get paginated results with dynamic sorting + sort_column_map = { + 'id': 'rp.id', + 'type': 'rp.resource_type', + 'resource': 'rp.resource_value', + 'status': 'rp.status', + 'assigned': 'c.name', + 'changed': 'rp.status_changed_at' + } + + sort_column = sort_column_map.get(sort_by, 'rp.id') + sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' + + query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + resources = cur.fetchall() + + cur.close() + conn.close() + + return render_template('resources.html', + stats=stats, + resources=resources, + recent_activities=recent_activities, + page=page, + total_pages=total_pages, + total=total, + resource_type=resource_type, + status_filter=status_filter, + search=search, + show_test=show_test, + sort_by=sort_by, + sort_order=sort_order, + datetime=datetime, + timedelta=timedelta) + +@app.route('/resources/add', methods=['GET', 'POST']) +@login_required +def add_resources(): + """Ressourcen zum Pool hinzufügen""" + # Hole show_test Parameter für die Anzeige + show_test = request.args.get('show_test', 'false').lower() == 'true' + + if request.method == 'POST': + resource_type = request.form.get('resource_type') + resources_text = request.form.get('resources_text', '') + is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten + + # Parse resources (one per line) + resources = [r.strip() for r in resources_text.split('\n') if r.strip()] + + if not resources: + flash('Keine Ressourcen angegeben', 'error') + return redirect(url_for('add_resources', show_test=show_test)) + + conn = get_connection() + cur = conn.cursor() + + added = 0 + duplicates = 0 + + for resource_value in resources: + try: + cur.execute(""" + INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) + VALUES (%s, %s, %s, %s) + ON CONFLICT (resource_type, resource_value) DO NOTHING + """, (resource_type, resource_value, session['username'], is_test)) + + if cur.rowcount > 0: + added += 1 + # Get the inserted ID + cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", + (resource_type, resource_value)) + resource_id = cur.fetchone()[0] + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'created', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + else: + duplicates += 1 + + except Exception as e: + app.logger.error(f"Error adding resource {resource_value}: {e}") + + conn.commit() + cur.close() + conn.close() + + log_audit('CREATE', 'resource_pool', None, + new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, + additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") + + flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') + return redirect(url_for('resources', show_test=show_test)) + + return render_template('add_resources.html', show_test=show_test) + +@app.route('/resources/quarantine/', methods=['POST']) +@login_required +def quarantine_resource(resource_id): + """Ressource in Quarantäne setzen""" + reason = request.form.get('reason', 'review') + until_date = request.form.get('until_date') + notes = request.form.get('notes', '') + + conn = get_connection() + cur = conn.cursor() + + # Get current resource info + cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) + resource = cur.fetchone() + + if not resource: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + old_status = resource[2] + + # Update resource + cur.execute(""" + UPDATE resource_pools + SET status = 'quarantine', + quarantine_reason = %s, + quarantine_until = %s, + notes = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) + VALUES (%s, 'quarantined', %s, %s, %s) + """, (resource_id, session['username'], get_client_ip(), + Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource', resource_id, + old_values={'status': old_status}, + new_values={'status': 'quarantine', 'reason': reason}, + additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") + + flash('Ressource in Quarantäne gesetzt', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/resources/release', methods=['POST']) +@login_required +def release_resources(): + """Ressourcen aus Quarantäne freigeben""" + resource_ids = request.form.getlist('resource_ids') + + if not resource_ids: + flash('Keine Ressourcen ausgewählt', 'error') + return redirect(url_for('resources')) + + conn = get_connection() + cur = conn.cursor() + + released = 0 + for resource_id in resource_ids: + cur.execute(""" + UPDATE resource_pools + SET status = 'available', + quarantine_reason = NULL, + quarantine_until = NULL, + allocated_to_license = NULL, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s AND status = 'quarantine' + """, (session['username'], resource_id)) + + if cur.rowcount > 0: + released += 1 + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'released', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource_pool', None, + new_values={'released': released}, + additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") + + flash(f'{released} Ressourcen freigegeben', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/api/resources/allocate', methods=['POST']) +@login_required +def allocate_resources_api(): + """API für Ressourcen-Zuweisung bei Lizenzerstellung""" + data = request.json + license_id = data.get('license_id') + domain_count = data.get('domain_count', 1) + ipv4_count = data.get('ipv4_count', 1) + phone_count = data.get('phone_count', 1) + + conn = get_connection() + cur = conn.cursor() + + try: + allocated = {'domains': [], 'ipv4s': [], 'phones': []} + + # Allocate domains + if domain_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' + LIMIT %s FOR UPDATE + """, (domain_count,)) + domains = cur.fetchall() + + if len(domains) < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") + + for domain_id, domain_value in domains: + # Update resource status + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], domain_id)) + + # Create assignment + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, domain_id, session['username'])) + + # Log history + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (domain_id, license_id, session['username'], get_client_ip())) + + allocated['domains'].append(domain_value) + + # Allocate IPv4s (similar logic) + if ipv4_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' + LIMIT %s FOR UPDATE + """, (ipv4_count,)) + ipv4s = cur.fetchall() + + if len(ipv4s) < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") + + for ipv4_id, ipv4_value in ipv4s: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], ipv4_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, ipv4_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (ipv4_id, license_id, session['username'], get_client_ip())) + + allocated['ipv4s'].append(ipv4_value) + + # Allocate phones (similar logic) + if phone_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' + LIMIT %s FOR UPDATE + """, (phone_count,)) + phones = cur.fetchall() + + if len(phones) < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar") + + for phone_id, phone_value in phones: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], phone_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, phone_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (phone_id, license_id, session['username'], get_client_ip())) + + allocated['phones'].append(phone_value) + + # Update license resource counts + cur.execute(""" + UPDATE licenses + SET domain_count = %s, + ipv4_count = %s, + phone_count = %s + WHERE id = %s + """, (domain_count, ipv4_count, phone_count, license_id)) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'allocated': allocated + }) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({ + 'success': False, + 'error': str(e) + }), 400 + +@app.route('/api/resources/check-availability', methods=['GET']) +@login_required +def check_resource_availability(): + """Prüft verfügbare Ressourcen""" + resource_type = request.args.get('type', '') + count = request.args.get('count', 10, type=int) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + conn = get_connection() + cur = conn.cursor() + + if resource_type: + # Spezifische Ressourcen für einen Typ + cur.execute(""" + SELECT id, resource_value + FROM resource_pools + WHERE status = 'available' + AND resource_type = %s + AND is_test = %s + ORDER BY resource_value + LIMIT %s + """, (resource_type, show_test, count)) + + resources = [] + for row in cur.fetchall(): + resources.append({ + 'id': row[0], + 'value': row[1] + }) + + cur.close() + conn.close() + + return jsonify({ + 'available': resources, + 'type': resource_type, + 'count': len(resources) + }) + else: + # Zusammenfassung aller Typen + cur.execute(""" + SELECT + resource_type, + COUNT(*) as available + FROM resource_pools + WHERE status = 'available' + AND is_test = %s + GROUP BY resource_type + """, (show_test,)) + + availability = {} + for row in cur.fetchall(): + availability[row[0]] = row[1] + + cur.close() + conn.close() + + return jsonify(availability) + +@app.route('/api/global-search', methods=['GET']) +@login_required +def global_search(): + """Global search API endpoint for searching customers and licenses""" + query = request.args.get('q', '').strip() + + if not query or len(query) < 2: + return jsonify({'customers': [], 'licenses': []}) + + conn = get_connection() + cur = conn.cursor() + + # Search pattern with wildcards + search_pattern = f'%{query}%' + + # Search customers + cur.execute(""" + SELECT id, name, email, company_name + FROM customers + WHERE (LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + OR LOWER(company_name) LIKE LOWER(%s)) + AND is_test = FALSE + ORDER BY name + LIMIT 5 + """, (search_pattern, search_pattern, search_pattern)) + + customers = [] + for row in cur.fetchall(): + customers.append({ + 'id': row[0], + 'name': row[1], + 'email': row[2], + 'company_name': row[3] + }) + + # Search licenses + cur.execute(""" + SELECT l.id, l.license_key, c.name as customer_name + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE LOWER(l.license_key) LIKE LOWER(%s) + AND l.is_test = FALSE + ORDER BY l.created_at DESC + LIMIT 5 + """, (search_pattern,)) + + licenses = [] + for row in cur.fetchall(): + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'customer_name': row[2] + }) + + cur.close() + conn.close() + + return jsonify({ + 'customers': customers, + 'licenses': licenses + }) + +@app.route('/resources/history/') +@login_required +def resource_history(resource_id): + """Zeigt die komplette Historie einer Ressource""" + conn = get_connection() + cur = conn.cursor() + + # Get complete resource info using named columns + cur.execute(""" + SELECT id, resource_type, resource_value, status, allocated_to_license, + status_changed_at, status_changed_by, quarantine_reason, + quarantine_until, created_at, notes + FROM resource_pools + WHERE id = %s + """, (resource_id,)) + row = cur.fetchone() + + if not row: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + # Create resource object with named attributes + resource = { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'allocated_to_license': row[4], + 'status_changed_at': row[5], + 'status_changed_by': row[6], + 'quarantine_reason': row[7], + 'quarantine_until': row[8], + 'created_at': row[9], + 'notes': row[10] + } + + # Get license info if allocated + license_info = None + if resource['allocated_to_license']: + cur.execute("SELECT license_key FROM licenses WHERE id = %s", + (resource['allocated_to_license'],)) + lic = cur.fetchone() + if lic: + license_info = {'license_key': lic[0]} + + # Get history with named columns + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rh.details, + rh.license_id, + rh.ip_address + FROM resource_history rh + WHERE rh.resource_id = %s + ORDER BY rh.action_at DESC + """, (resource_id,)) + + history = [] + for row in cur.fetchall(): + history.append({ + 'action': row[0], + 'action_by': row[1], + 'action_at': row[2], + 'details': row[3], + 'license_id': row[4], + 'ip_address': row[5] + }) + + cur.close() + conn.close() + + # Convert to object-like for template + class ResourceObj: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + + resource_obj = ResourceObj(resource) + history_objs = [ResourceObj(h) for h in history] + + return render_template('resource_history.html', + resource=resource_obj, + license_info=license_info, + history=history_objs) + +@app.route('/resources/metrics') +@login_required +def resources_metrics(): + """Dashboard für Resource Metrics und Reports""" + conn = get_connection() + cur = conn.cursor() + + # Overall stats with fallback values + cur.execute(""" + SELECT + COUNT(DISTINCT resource_id) as total_resources, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(revenue), 0) as total_revenue, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + """) + row = cur.fetchone() + + # Calculate ROI + roi = 0 + if row[2] > 0: # if total_cost > 0 + roi = row[3] / row[2] # revenue / cost + + stats = { + 'total_resources': row[0] or 0, + 'avg_performance': row[1] or 0, + 'total_cost': row[2] or 0, + 'total_revenue': row[3] or 0, + 'total_issues': row[4] or 0, + 'roi': roi + } + + # Performance by type + cur.execute(""" + SELECT + rp.resource_type, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COUNT(DISTINCT rp.id) as resource_count + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY rp.resource_type + ORDER BY rp.resource_type + """) + performance_by_type = cur.fetchall() + + # Utilization data + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) as total, + ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent + FROM resource_pools + GROUP BY resource_type + """) + utilization_rows = cur.fetchall() + utilization_data = [ + { + 'type': row[0].upper(), + 'allocated': row[1], + 'total': row[2], + 'allocated_percent': row[3] + } + for row in utilization_rows + ] + + # Top performing resources + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COALESCE(SUM(rm.revenue), 0) as total_revenue, + COALESCE(SUM(rm.cost), 1) as total_cost, + CASE + WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 + ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) + END as roi + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rp.status != 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value + HAVING AVG(rm.performance_score) IS NOT NULL + ORDER BY avg_score DESC + LIMIT 10 + """) + top_rows = cur.fetchall() + top_performers = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'avg_score': row[3], + 'roi': row[6] + } + for row in top_rows + ] + + # Resources with issues + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + COALESCE(SUM(rm.issues_count), 0) as total_issues + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rm.issues_count > 0 OR rp.status = 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + HAVING SUM(rm.issues_count) > 0 + ORDER BY total_issues DESC + LIMIT 10 + """) + problem_rows = cur.fetchall() + problem_resources = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'total_issues': row[4] + } + for row in problem_rows + ] + + # Daily metrics for trend chart (last 30 days) + cur.execute(""" + SELECT + metric_date, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY metric_date + ORDER BY metric_date + """) + daily_rows = cur.fetchall() + daily_metrics = [ + { + 'date': row[0].strftime('%d.%m'), + 'performance': float(row[1]), + 'issues': int(row[2]) + } + for row in daily_rows + ] + + cur.close() + conn.close() + + return render_template('resource_metrics.html', + stats=stats, + performance_by_type=performance_by_type, + utilization_data=utilization_data, + top_performers=top_performers, + problem_resources=problem_resources, + daily_metrics=daily_metrics) + +@app.route('/resources/report', methods=['GET']) +@login_required +def resources_report(): + """Generiert Ressourcen-Reports oder zeigt Report-Formular""" + # Prüfe ob Download angefordert wurde + if request.args.get('download') == 'true': + report_type = request.args.get('type', 'usage') + format_type = request.args.get('format', 'excel') + date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) + date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) + + conn = get_connection() + cur = conn.cursor() + + if report_type == 'usage': + # Auslastungsreport + query = """ + SELECT + rp.resource_type, + rp.resource_value, + rp.status, + COUNT(DISTINCT rh.license_id) as unique_licenses, + COUNT(rh.id) as total_allocations, + MIN(rh.action_at) as first_used, + MAX(rh.action_at) as last_used + FROM resource_pools rp + LEFT JOIN resource_history rh ON rp.id = rh.resource_id + AND rh.action = 'allocated' + AND rh.action_at BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + ORDER BY rp.resource_type, total_allocations DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] + + elif report_type == 'performance': + # Performance-Report + query = """ + SELECT + rp.resource_type, + rp.resource_value, + AVG(rm.performance_score) as avg_performance, + SUM(rm.usage_count) as total_usage, + SUM(rm.revenue) as total_revenue, + SUM(rm.cost) as total_cost, + SUM(rm.revenue - rm.cost) as profit, + SUM(rm.issues_count) as total_issues + FROM resource_pools rp + JOIN resource_metrics rm ON rp.id = rm.resource_id + WHERE rm.metric_date BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value + ORDER BY profit DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] + + elif report_type == 'compliance': + # Compliance-Report + query = """ + SELECT + rh.action_at, + rh.action, + rh.action_by, + rp.resource_type, + rp.resource_value, + l.license_key, + c.name as customer_name, + rh.ip_address + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + LEFT JOIN licenses l ON rh.license_id = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rh.action_at BETWEEN %s AND %s + ORDER BY rh.action_at DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] + + else: # inventory report + # Inventar-Report + query = """ + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + GROUP BY resource_type + ORDER BY resource_type + """ + cur.execute(query) + columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] + + # Convert to DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + cur.close() + conn.close() + + # Generate file + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f"resource_report_{report_type}_{timestamp}" + + if format_type == 'excel': + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Report', index=False) + + # Auto-adjust columns width + worksheet = writer.sheets['Report'] + for column in worksheet.columns: + max_length = 0 + column = [cell for cell in column] + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = (max_length + 2) + worksheet.column_dimensions[column[0].column_letter].width = adjusted_width + + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx') + + else: # CSV + output = io.StringIO() + df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv') + + # Wenn kein Download, zeige Report-Formular + return render_template('resource_report.html', + datetime=datetime, + timedelta=timedelta, + username=session.get('username')) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_new.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_new.py new file mode 100644 index 0000000..c391073 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_new.py @@ -0,0 +1,124 @@ +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 new file mode 100644 index 0000000..5b203fe --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/app_with_duplicates.py @@ -0,0 +1,4462 @@ +import os +import sys +import time +import json +import logging +import requests +import re +import random +import base64 +from io import BytesIO +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path + +# Add current directory to Python path to ensure modules can be imported +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash +from flask_session import Session +from werkzeug.middleware.proxy_fix import ProxyFix +from apscheduler.schedulers.background import BackgroundScheduler +import pandas as pd +from psycopg2.extras import Json + +# Import our new modules +import config +from db import get_connection, get_db_connection, get_db_cursor, execute_query +from auth.decorators import login_required +from auth.password import hash_password, verify_password +from auth.two_factor import ( + generate_totp_secret, generate_qr_code, verify_totp, + generate_backup_codes, hash_backup_code, verify_backup_code +) +from auth.rate_limiting import ( + check_ip_blocked, record_failed_attempt, + reset_login_attempts, get_login_attempts +) +from utils.network import get_client_ip +from utils.audit import log_audit +from utils.license import generate_license_key, validate_license_key +from utils.backup import create_backup, restore_backup, get_or_create_encryption_key +from utils.export import ( + create_excel_export, format_datetime_for_export, + prepare_license_export_data, prepare_customer_export_data, + prepare_session_export_data, prepare_audit_export_data +) +from models import get_user_by_username + +app = Flask(__name__) +# Load configuration from config module +app.config['SECRET_KEY'] = config.SECRET_KEY +app.config['SESSION_TYPE'] = config.SESSION_TYPE +app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII +app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE +app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME +app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY +app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE +app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE +app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME +app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST +Session(app) + +# ProxyFix für korrekte IP-Adressen hinter Nginx +app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 +) + +# Configuration is now loaded from config module + +# Scheduler für automatische Backups +scheduler = BackgroundScheduler() +scheduler.start() + +# Logging konfigurieren +logging.basicConfig(level=logging.INFO) + +# Import and register blueprints +from routes.auth_routes import auth_bp +from routes.admin_routes import admin_bp + +app.register_blueprint(auth_bp) +app.register_blueprint(admin_bp) + + +# Scheduled Backup Job +def scheduled_backup(): + """Führt ein geplantes Backup aus""" + logging.info("Starte geplantes Backup...") + create_backup(backup_type="scheduled", created_by="scheduler") + +# Scheduler konfigurieren - täglich um 3:00 Uhr +scheduler.add_job( + scheduled_backup, + 'cron', + hour=config.SCHEDULER_CONFIG['backup_hour'], + minute=config.SCHEDULER_CONFIG['backup_minute'], + id='daily_backup', + replace_existing=True +) + + +def verify_recaptcha(response): + """Verifiziert die reCAPTCHA v2 Response mit Google""" + secret_key = config.RECAPTCHA_SECRET_KEY + + # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) + if not secret_key: + logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") + return True + + # Verifizierung bei Google + try: + verify_url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': secret_key, + 'response': response + } + + # Timeout für Request setzen + r = requests.post(verify_url, data=data, timeout=5) + result = r.json() + + # Log für Debugging + if not result.get('success'): + logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") + + return result.get('success', False) + + except requests.exceptions.RequestException as e: + logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") + # Bei Netzwerkfehlern CAPTCHA als bestanden werten + return True + except Exception as e: + logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") + return False + + +# MOVED TO AUTH BLUEPRINT +# @app.route("/login", methods=["GET", "POST"]) +# def login(): +# # Timing-Attack Schutz - Start Zeit merken +# start_time = time.time() + + # IP-Adresse ermitteln + ip_address = get_client_ip() + + # Prüfen ob IP gesperrt ist + is_blocked, blocked_until = check_ip_blocked(ip_address) + if is_blocked: + time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + return render_template("login.html", error=error_msg, error_type="blocked") + + # Anzahl bisheriger Versuche + attempt_count = get_login_attempts(ip_address) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + captcha_response = request.form.get("g-recaptcha-response") + + # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind + recaptcha_site_key = config.RECAPTCHA_SITE_KEY + if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + if not captcha_response: + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA ERFORDERLICH!", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # CAPTCHA validieren + if not verify_recaptcha(captcha_response): + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # Check user in database first, fallback to env vars + user = get_user_by_username(username) + login_success = False + needs_2fa = False + + if user: + # Database user authentication + if verify_password(password, user['password_hash']): + login_success = True + needs_2fa = user['totp_enabled'] + else: + # Fallback to environment variables for backward compatibility + if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: + login_success = True + + # Timing-Attack Schutz - Mindestens 1 Sekunde warten + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + if login_success: + # Erfolgreicher Login + if needs_2fa: + # Store temporary session for 2FA verification + session['temp_username'] = username + session['temp_user_id'] = user['id'] + session['awaiting_2fa'] = True + return redirect(url_for('verify_2fa')) + else: + # Complete login without 2FA + session.permanent = True # Aktiviert das Timeout + session['logged_in'] = True + session['username'] = username + session['user_id'] = user['id'] if user else None + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + reset_login_attempts(ip_address) + log_audit('LOGIN_SUCCESS', 'user', + additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + return redirect(url_for('dashboard')) + else: + # Fehlgeschlagener Login + error_message = record_failed_attempt(ip_address, username) + new_attempt_count = get_login_attempts(ip_address) + + # Prüfen ob jetzt gesperrt + is_now_blocked, _ = check_ip_blocked(ip_address) + if is_now_blocked: + log_audit('LOGIN_BLOCKED', 'security', + additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + + return render_template("login.html", + error=error_message, + show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + error_type="failed", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + + # GET Request + return render_template("login.html", + show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + +@app.route("/logout") +def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + session.pop('logged_in', None) + session.pop('username', None) + session.pop('user_id', None) + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + return redirect(url_for('login')) + +# MOVED TO AUTH BLUEPRINT +# @app.route("/verify-2fa", methods=["GET", "POST"]) +# def verify_2fa(): +# if not session.get('awaiting_2fa'): +# return redirect(url_for('login')) + + if request.method == "POST": + token = request.form.get('token', '').replace(' ', '') + username = session.get('temp_username') + user_id = session.get('temp_user_id') + + if not username or not user_id: + flash('Session expired. Please login again.', 'error') + return redirect(url_for('login')) + + user = get_user_by_username(username) + if not user: + flash('User not found.', 'error') + return redirect(url_for('login')) + + # Check if it's a backup code + if len(token) == 8 and token.isupper(): + # Try backup code + backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + if verify_backup_code(token, backup_codes): + # Remove used backup code + code_hash = hash_backup_code(token) + backup_codes.remove(code_hash) + + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + (json.dumps(backup_codes), user_id)) + conn.commit() + cur.close() + conn.close() + + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + flash('Login successful using backup code. Please generate new backup codes.', 'warning') + log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + return redirect(url_for('dashboard')) + else: + # Try TOTP token + if verify_totp(user['totp_secret'], token): + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + return redirect(url_for('dashboard')) + + # Failed verification + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + (datetime.now(), user_id)) + conn.commit() + cur.close() + conn.close() + + flash('Invalid authentication code. Please try again.', 'error') + log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + return render_template('verify_2fa.html') + +@app.route("/profile") +@login_required +def profile(): + user = get_user_by_username(session['username']) + if not user: + # For environment-based users, redirect with message + flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') + return redirect(url_for('dashboard')) + return render_template('profile.html', user=user) + +@app.route("/profile/change-password", methods=["POST"]) +@login_required +def change_password(): + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + user = get_user_by_username(session['username']) + + # Verify current password + if not verify_password(current_password, user['password_hash']): + flash('Current password is incorrect.', 'error') + return redirect(url_for('profile')) + + # Check new password + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return redirect(url_for('profile')) + + if len(new_password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return redirect(url_for('profile')) + + # Update password + new_hash = hash_password(new_password) + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", + (new_hash, datetime.now(), user['id'])) + conn.commit() + cur.close() + conn.close() + + log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], + additional_info="Password changed successfully") + flash('Password changed successfully.', 'success') + return redirect(url_for('profile')) + +@app.route("/profile/setup-2fa") +@login_required +def setup_2fa(): + user = get_user_by_username(session['username']) + + if user['totp_enabled']: + flash('2FA is already enabled for your account.', 'info') + return redirect(url_for('profile')) + + # Generate new TOTP secret + totp_secret = generate_totp_secret() + session['temp_totp_secret'] = totp_secret + + # Generate QR code + qr_code = generate_qr_code(user['username'], totp_secret) + + return render_template('setup_2fa.html', + totp_secret=totp_secret, + qr_code=qr_code) + +@app.route("/profile/enable-2fa", methods=["POST"]) +@login_required +def enable_2fa(): + token = request.form.get('token', '').replace(' ', '') + totp_secret = session.get('temp_totp_secret') + + if not totp_secret: + flash('2FA setup session expired. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Verify the token + if not verify_totp(totp_secret, token): + flash('Invalid authentication code. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # Generate backup codes + backup_codes = generate_backup_codes() + hashed_codes = [hash_backup_code(code) for code in backup_codes] + + # Enable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s + WHERE username = %s + """, (totp_secret, json.dumps(hashed_codes), session['username'])) + conn.commit() + cur.close() + conn.close() + + session.pop('temp_totp_secret', None) + + log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") + + # Show backup codes + return render_template('backup_codes.html', backup_codes=backup_codes) + +@app.route("/profile/disable-2fa", methods=["POST"]) +@login_required +def disable_2fa(): + password = request.form.get('password') + user = get_user_by_username(session['username']) + + # Verify password + if not verify_password(password, user['password_hash']): + flash('Incorrect password.', 'error') + return redirect(url_for('profile')) + + # Disable 2FA + conn = get_connection() + cur = conn.cursor() + cur.execute(""" + UPDATE users + SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL + WHERE username = %s + """, (session['username'],)) + conn.commit() + cur.close() + conn.close() + + log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") + flash('2FA has been disabled for your account.', 'success') + return redirect(url_for('profile')) + +@app.route("/heartbeat", methods=['POST']) +@login_required +def heartbeat(): + """Endpoint für Session Keep-Alive - aktualisiert last_activity""" + # Aktualisiere last_activity nur wenn explizit angefordert + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # Force session save + session.modified = True + + return jsonify({ + 'status': 'ok', + 'last_activity': session['last_activity'], + 'username': session.get('username') + }) + +@app.route("/api/generate-license-key", methods=['POST']) +@login_required +def api_generate_key(): + """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" + try: + # Lizenztyp aus Request holen (default: full) + data = request.get_json() or {} + license_type = data.get('type', 'full') + + # Key generieren + key = generate_license_key(license_type) + + # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) + conn = get_connection() + cur = conn.cursor() + + # Wiederhole bis eindeutiger Key gefunden + attempts = 0 + while attempts < 10: # Max 10 Versuche + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) + if not cur.fetchone(): + break # Key ist eindeutig + key = generate_license_key(license_type) + attempts += 1 + + cur.close() + conn.close() + + # Log für Audit + log_audit('GENERATE_KEY', 'license', + additional_info={'type': license_type, 'key': key}) + + return jsonify({ + 'success': True, + 'key': key, + 'type': license_type + }) + + except Exception as e: + logging.error(f"Fehler bei Key-Generierung: {str(e)}") + return jsonify({ + 'success': False, + 'error': 'Fehler bei der Key-Generierung' + }), 500 + +@app.route("/api/customers", methods=['GET']) +@login_required +def api_customers(): + """API Endpoint für die Kundensuche mit Select2""" + try: + # Suchparameter + search = request.args.get('q', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 20 + customer_id = request.args.get('id', type=int) + + conn = get_connection() + cur = conn.cursor() + + # Einzelnen Kunden per ID abrufen + if customer_id: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.id = %s + GROUP BY c.id, c.name, c.email + """, (customer_id,)) + + customer = cur.fetchone() + results = [] + if customer: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} ({customer[2]})", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + cur.close() + conn.close() + + return jsonify({ + 'results': results, + 'pagination': {'more': False} + }) + + # SQL Query mit optionaler Suche + elif search: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE LOWER(c.name) LIKE LOWER(%s) + OR LOWER(c.email) LIKE LOWER(%s) + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) + else: + cur.execute(""" + SELECT c.id, c.name, c.email, + COUNT(l.id) as license_count + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email + ORDER BY c.name + LIMIT %s OFFSET %s + """, (per_page, (page - 1) * per_page)) + + customers = cur.fetchall() + + # Format für Select2 + results = [] + for customer in customers: + results.append({ + 'id': customer[0], + 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", + 'name': customer[1], + 'email': customer[2], + 'license_count': customer[3] + }) + + # Gesamtanzahl für Pagination + if search: + cur.execute(""" + SELECT COUNT(*) FROM customers + WHERE LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + """, (f'%{search}%', f'%{search}%')) + else: + cur.execute("SELECT COUNT(*) FROM customers") + + total_count = cur.fetchone()[0] + + cur.close() + conn.close() + + # Select2 Response Format + return jsonify({ + 'results': results, + 'pagination': { + 'more': (page * per_page) < total_count + } + }) + + except Exception as e: + logging.error(f"Fehler bei Kundensuche: {str(e)}") + return jsonify({ + 'results': [], + 'error': 'Fehler bei der Kundensuche' + }), 500 + +@app.route("/") +@login_required +def dashboard(): + conn = get_connection() + cur = conn.cursor() + + # Statistiken abrufen + # Gesamtanzahl Kunden (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") + total_customers = cur.fetchone()[0] + + # Gesamtanzahl Lizenzen (ohne Testdaten) + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") + total_licenses = cur.fetchone()[0] + + # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE + """) + active_licenses = cur.fetchone()[0] + + # Aktive Sessions + cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") + active_sessions_count = cur.fetchone()[0] + + # Abgelaufene Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until < CURRENT_DATE AND is_test = FALSE + """) + expired_licenses = cur.fetchone()[0] + + # Deaktivierte Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE is_active = FALSE AND is_test = FALSE + """) + inactive_licenses = cur.fetchone()[0] + + # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) + cur.execute(""" + SELECT COUNT(*) FROM licenses + WHERE valid_until >= CURRENT_DATE + AND valid_until < CURRENT_DATE + INTERVAL '30 days' + AND is_active = TRUE + AND is_test = FALSE + """) + expiring_soon = cur.fetchone()[0] + + # Testlizenzen vs Vollversionen (ohne Testdaten) + cur.execute(""" + SELECT license_type, COUNT(*) + FROM licenses + WHERE is_test = FALSE + GROUP BY license_type + """) + license_types = dict(cur.fetchall()) + + # Anzahl Testdaten + cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") + test_data_count = cur.fetchone()[0] + + # Anzahl Test-Kunden + cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") + test_customers_count = cur.fetchone()[0] + + # Anzahl Test-Ressourcen + cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") + test_resources_count = cur.fetchone()[0] + + # Letzte 5 erstellten Lizenzen (ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.is_test = FALSE + ORDER BY l.id DESC + LIMIT 5 + """) + recent_licenses = cur.fetchall() + + # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) + cur.execute(""" + SELECT l.id, l.license_key, c.name, l.valid_until, + l.valid_until - CURRENT_DATE as days_left + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.valid_until >= CURRENT_DATE + AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' + AND l.is_active = TRUE + AND l.is_test = FALSE + ORDER BY l.valid_until + LIMIT 10 + """) + expiring_licenses = cur.fetchall() + + # Letztes Backup + cur.execute(""" + SELECT created_at, filesize, duration_seconds, backup_type, status + FROM backup_history + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup_info = cur.fetchone() + + # Sicherheitsstatistiken + # Gesperrte IPs + cur.execute(""" + SELECT COUNT(*) FROM login_attempts + WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP + """) + blocked_ips_count = cur.fetchone()[0] + + # Fehlversuche heute + cur.execute(""" + SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts + WHERE last_attempt::date = CURRENT_DATE + """) + failed_attempts_today = cur.fetchone()[0] + + # Letzte 5 Sicherheitsereignisse + cur.execute(""" + SELECT + la.ip_address, + la.attempt_count, + la.last_attempt, + la.blocked_until, + la.last_username_tried, + la.last_error_message + FROM login_attempts la + ORDER BY la.last_attempt DESC + LIMIT 5 + """) + recent_security_events = [] + for event in cur.fetchall(): + recent_security_events.append({ + 'ip_address': event[0], + 'attempt_count': event[1], + 'last_attempt': event[2].strftime('%d.%m %H:%M'), + 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, + 'username_tried': event[4], + 'error_message': event[5] + }) + + # Sicherheitslevel berechnen + if blocked_ips_count > 5 or failed_attempts_today > 50: + security_level = 'danger' + security_level_text = 'KRITISCH' + elif blocked_ips_count > 2 or failed_attempts_today > 20: + security_level = 'warning' + security_level_text = 'ERHÖHT' + else: + security_level = 'success' + security_level_text = 'NORMAL' + + # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = FALSE + GROUP BY resource_type + """) + + resource_stats = {} + resource_warning = None + + for row in cur.fetchall(): + available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + resource_stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': available_percent, + 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' + } + + # Warnung bei niedrigem Bestand + if row[1] < 50: + if not resource_warning: + resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" + else: + resource_warning += f" | {row[0].upper()}: {row[1]}" + + cur.close() + conn.close() + + stats = { + 'total_customers': total_customers, + 'total_licenses': total_licenses, + 'active_licenses': active_licenses, + 'expired_licenses': expired_licenses, + 'inactive_licenses': inactive_licenses, + 'expiring_soon': expiring_soon, + 'full_licenses': license_types.get('full', 0), + 'test_licenses': license_types.get('test', 0), + 'test_data_count': test_data_count, + 'test_customers_count': test_customers_count, + 'test_resources_count': test_resources_count, + 'recent_licenses': recent_licenses, + 'expiring_licenses': expiring_licenses, + 'active_sessions': active_sessions_count, + 'last_backup': last_backup_info, + # Sicherheitsstatistiken + 'blocked_ips_count': blocked_ips_count, + 'failed_attempts_today': failed_attempts_today, + 'recent_security_events': recent_security_events, + 'security_level': security_level, + 'security_level_text': security_level_text, + 'resource_stats': resource_stats + } + + return render_template("dashboard.html", + stats=stats, + resource_stats=resource_stats, + resource_warning=resource_warning, + username=session.get('username')) + +@app.route("/create", methods=["GET", "POST"]) +@login_required +def create_license(): + if request.method == "POST": + customer_id = request.form.get("customer_id") + license_key = request.form["license_key"].upper() # Immer Großbuchstaben + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Validiere License Key Format + if not validate_license_key(license_key): + flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') + return redirect(url_for('create_license')) + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('create_license')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('create_license')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + customer_info = {'name': name, 'email': email, 'is_test': is_test} + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos für Audit-Log + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('create_license')) + customer_info = {'name': customer_data[0], 'email': customer_data[1]} + + # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Lizenz hinzufügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit, is_test) + VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit, is_test)) + license_id = cur.fetchone()[0] + + # Ressourcen zuweisen + try: + # Prüfe Verfügbarkeit + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") + if available[1] < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") + if available[2] < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") + + # Domains zuweisen + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s zuweisen + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern zuweisen + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + except ValueError as e: + conn.rollback() + flash(str(e), 'error') + return redirect(url_for('create_license')) + + conn.commit() + + # Audit-Log + log_audit('CREATE', 'license', license_id, + new_values={ + 'license_key': license_key, + 'customer_name': customer_info['name'], + 'customer_email': customer_info['email'], + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'device_limit': device_limit, + 'is_test': is_test + }) + + flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') + + except Exception as e: + conn.rollback() + logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") + flash('Fehler beim Erstellen der Lizenz!', 'error') + finally: + cur.close() + conn.close() + + # Preserve show_test parameter if present + redirect_url = "/create" + if request.args.get('show_test') == 'true': + redirect_url += "?show_test=true" + return redirect(redirect_url) + + # Unterstützung für vorausgewählten Kunden + preselected_customer_id = request.args.get('customer_id', type=int) + return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) + +@app.route("/batch", methods=["GET", "POST"]) +@login_required +def batch_licenses(): + """Batch-Generierung mehrerer Lizenzen für einen Kunden""" + if request.method == "POST": + # Formulardaten + customer_id = request.form.get("customer_id") + license_type = request.form["license_type"] + quantity = int(request.form["quantity"]) + valid_from = request.form["valid_from"] + is_test = request.form.get("is_test") == "on" # Checkbox value + + # Berechne valid_until basierend auf Laufzeit + duration = int(request.form.get("duration", 1)) + duration_type = request.form.get("duration_type", "years") + + from datetime import datetime, timedelta + from dateutil.relativedelta import relativedelta + + start_date = datetime.strptime(valid_from, "%Y-%m-%d") + + if duration_type == "days": + end_date = start_date + timedelta(days=duration) + elif duration_type == "months": + end_date = start_date + relativedelta(months=duration) + else: # years + end_date = start_date + relativedelta(years=duration) + + # Ein Tag abziehen, da der Starttag mitgezählt wird + end_date = end_date - timedelta(days=1) + valid_until = end_date.strftime("%Y-%m-%d") + + # Resource counts + domain_count = int(request.form.get("domain_count", 1)) + ipv4_count = int(request.form.get("ipv4_count", 1)) + phone_count = int(request.form.get("phone_count", 1)) + device_limit = int(request.form.get("device_limit", 3)) + + # Sicherheitslimit + if quantity < 1 or quantity > 100: + flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') + return redirect(url_for('batch_licenses')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfe ob neuer Kunde oder bestehender + if customer_id == "new": + # Neuer Kunde + name = request.form.get("customer_name") + email = request.form.get("email") + + if not name: + flash('Kundenname ist erforderlich!', 'error') + return redirect(url_for('batch_licenses')) + + # Prüfe ob E-Mail bereits existiert + if email: + cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) + existing = cur.fetchone() + if existing: + flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') + return redirect(url_for('batch_licenses')) + + # Kunde einfügen (erbt Test-Status von Lizenz) + cur.execute(""" + INSERT INTO customers (name, email, is_test, created_at) + VALUES (%s, %s, %s, NOW()) + RETURNING id + """, (name, email, is_test)) + customer_id = cur.fetchone()[0] + + # Audit-Log für neuen Kunden + log_audit('CREATE', 'customer', customer_id, + new_values={'name': name, 'email': email, 'is_test': is_test}) + else: + # Bestehender Kunde - hole Infos + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + customer_data = cur.fetchone() + if not customer_data: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('batch_licenses')) + name = customer_data[0] + email = customer_data[1] + + # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren + if customer_data[2]: # is_test des Kunden + is_test = True + + # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch + total_domains_needed = domain_count * quantity + total_ipv4s_needed = ipv4_count * quantity + total_phones_needed = phone_count * quantity + + cur.execute(""" + SELECT + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, + (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones + """, (is_test, is_test, is_test)) + available = cur.fetchone() + + if available[0] < total_domains_needed: + flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') + return redirect(url_for('batch_licenses')) + if available[1] < total_ipv4s_needed: + flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') + return redirect(url_for('batch_licenses')) + if available[2] < total_phones_needed: + flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') + return redirect(url_for('batch_licenses')) + + # Lizenzen generieren und speichern + generated_licenses = [] + for i in range(quantity): + # Eindeutigen Key generieren + attempts = 0 + while attempts < 10: + license_key = generate_license_key(license_type) + cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) + if not cur.fetchone(): + break + attempts += 1 + + # Lizenz einfügen + cur.execute(""" + INSERT INTO licenses (license_key, customer_id, license_type, is_test, + valid_from, valid_until, is_active, + domain_count, ipv4_count, phone_count, device_limit) + VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) + RETURNING id + """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, + domain_count, ipv4_count, phone_count, device_limit)) + license_id = cur.fetchone()[0] + + # Ressourcen für diese Lizenz zuweisen + # Domains + if domain_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, domain_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # IPv4s + if ipv4_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, ipv4_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + # Telefonnummern + if phone_count > 0: + cur.execute(""" + SELECT id FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s + LIMIT %s FOR UPDATE + """, (is_test, phone_count)) + for (resource_id,) in cur.fetchall(): + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], resource_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, resource_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (resource_id, license_id, session['username'], get_client_ip())) + + generated_licenses.append({ + 'id': license_id, + 'key': license_key, + 'type': license_type + }) + + conn.commit() + + # Audit-Log + log_audit('CREATE_BATCH', 'license', + new_values={'customer': name, 'quantity': quantity, 'type': license_type}, + additional_info=f"Batch-Generierung von {quantity} Lizenzen") + + # Session für Export speichern + session['batch_export'] = { + 'customer': name, + 'email': email, + 'licenses': generated_licenses, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + } + + flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') + return render_template("batch_result.html", + customer=name, + email=email, + licenses=generated_licenses, + valid_from=valid_from, + valid_until=valid_until) + + except Exception as e: + conn.rollback() + logging.error(f"Fehler bei Batch-Generierung: {str(e)}") + flash('Fehler bei der Batch-Generierung!', 'error') + return redirect(url_for('batch_licenses')) + finally: + cur.close() + conn.close() + + # GET Request + return render_template("batch_form.html") + +@app.route("/batch/export") +@login_required +def export_batch(): + """Exportiert die zuletzt generierten Batch-Lizenzen""" + batch_data = session.get('batch_export') + if not batch_data: + flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') + return redirect(url_for('batch_licenses')) + + # CSV generieren + output = io.StringIO() + output.write('\ufeff') # UTF-8 BOM für Excel + + # Header + output.write(f"Kunde: {batch_data['customer']}\n") + output.write(f"E-Mail: {batch_data['email']}\n") + output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") + output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") + output.write("\n") + output.write("Nr;Lizenzschlüssel;Typ\n") + + # Lizenzen + for i, license in enumerate(batch_data['licenses'], 1): + typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" + output.write(f"{i};{license['key']};{typ_text}\n") + + output.seek(0) + + # Audit-Log + log_audit('EXPORT', 'batch_licenses', + additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" + ) + +@app.route("/licenses") +@login_required +def licenses(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/license/edit/", methods=["GET", "POST"]) +@login_required +def edit_license(license_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute(""" + SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit + FROM licenses WHERE id = %s + """, (license_id,)) + old_license = cur.fetchone() + + # Update license + license_key = request.form["license_key"] + license_type = request.form["license_type"] + valid_from = request.form["valid_from"] + valid_until = request.form["valid_until"] + is_active = request.form.get("is_active") == "on" + is_test = request.form.get("is_test") == "on" + device_limit = int(request.form.get("device_limit", 3)) + + cur.execute(""" + UPDATE licenses + SET license_key = %s, license_type = %s, valid_from = %s, + valid_until = %s, is_active = %s, is_test = %s, device_limit = %s + WHERE id = %s + """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'license_key': old_license[0], + 'license_type': old_license[1], + 'valid_from': str(old_license[2]), + 'valid_until': str(old_license[3]), + 'is_active': old_license[4], + 'is_test': old_license[5], + 'device_limit': old_license[6] + }, + new_values={ + 'license_key': license_key, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'is_active': is_active, + 'is_test': is_test, + 'device_limit': device_limit + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei wenn vorhanden + if request.referrer and 'customer_id=' in request.referrer: + import re + match = re.search(r'customer_id=(\d+)', request.referrer) + if match: + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={match.group(1)}" + + return redirect(redirect_url) + + # Get license data + cur.execute(""" + SELECT l.id, l.license_key, c.name, c.email, l.license_type, + l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + + license = cur.fetchone() + cur.close() + conn.close() + + if not license: + return redirect("/licenses") + + return render_template("edit_license.html", license=license, username=session.get('username')) + +@app.route("/license/delete/", methods=["POST"]) +@login_required +def delete_license(license_id): + conn = get_connection() + cur = conn.cursor() + + # Lizenzdetails für Audit-Log abrufen + cur.execute(""" + SELECT l.license_key, c.name, l.license_type + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + license_info = cur.fetchone() + + cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) + + conn.commit() + + # Audit-Log + if license_info: + log_audit('DELETE', 'license', license_id, + old_values={ + 'license_key': license_info[0], + 'customer_name': license_info[1], + 'license_type': license_info[2] + }) + + cur.close() + conn.close() + + return redirect("/licenses") + +@app.route("/customers") +@login_required +def customers(): + # Redirect zur kombinierten Ansicht + return redirect("/customers-licenses") + +@app.route("/customer/edit/", methods=["GET", "POST"]) +@login_required +def edit_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) + old_customer = cur.fetchone() + + # Update customer + name = request.form["name"] + email = request.form["email"] + is_test = request.form.get("is_test") == "on" + + cur.execute(""" + UPDATE customers + SET name = %s, email = %s, is_test = %s + WHERE id = %s + """, (name, email, is_test, customer_id)) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'customer', customer_id, + old_values={ + 'name': old_customer[0], + 'email': old_customer[1], + 'is_test': old_customer[2] + }, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + cur.close() + conn.close() + + # Redirect zurück zu customers-licenses mit beibehaltenen Parametern + redirect_url = "/customers-licenses" + + # Behalte show_test Parameter bei (aus Form oder GET-Parameter) + show_test = request.form.get('show_test') or request.args.get('show_test') + if show_test == 'true': + redirect_url += "?show_test=true" + + # Behalte customer_id bei (immer der aktuelle Kunde) + connector = "&" if "?" in redirect_url else "?" + redirect_url += f"{connector}customer_id={customer_id}" + + return redirect(redirect_url) + + # Get customer data with licenses + cur.execute(""" + SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s + """, (customer_id,)) + + customer = cur.fetchone() + if not customer: + cur.close() + conn.close() + return "Kunde nicht gefunden", 404 + + + # Get customer's licenses + cur.execute(""" + SELECT id, license_key, license_type, valid_from, valid_until, is_active + FROM licenses + WHERE customer_id = %s + ORDER BY valid_until DESC + """, (customer_id,)) + + licenses = cur.fetchall() + + cur.close() + conn.close() + + if not customer: + return redirect("/customers-licenses") + + return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) + +@app.route("/customer/create", methods=["GET", "POST"]) +@login_required +def create_customer(): + """Erstellt einen neuen Kunden ohne Lizenz""" + if request.method == "POST": + name = request.form.get('name') + email = request.form.get('email') + is_test = request.form.get('is_test') == 'on' + + if not name or not email: + flash("Name und E-Mail sind Pflichtfelder!", "error") + return render_template("create_customer.html", username=session.get('username')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Prüfen ob E-Mail bereits existiert + cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) + existing = cur.fetchone() + if existing: + flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") + return render_template("create_customer.html", username=session.get('username')) + + # Kunde erstellen + cur.execute(""" + INSERT INTO customers (name, email, created_at, is_test) + VALUES (%s, %s, %s, %s) RETURNING id + """, (name, email, datetime.now(), is_test)) + + customer_id = cur.fetchone()[0] + conn.commit() + + # Audit-Log + log_audit('CREATE', 'customer', customer_id, + new_values={ + 'name': name, + 'email': email, + 'is_test': is_test + }) + + flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") + return redirect(f"/customer/edit/{customer_id}") + + except Exception as e: + conn.rollback() + flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") + return render_template("create_customer.html", username=session.get('username')) + finally: + cur.close() + conn.close() + + # GET Request - Formular anzeigen + return render_template("create_customer.html", username=session.get('username')) + +@app.route("/customer/delete/", methods=["POST"]) +@login_required +def delete_customer(customer_id): + conn = get_connection() + cur = conn.cursor() + + # Prüfen ob Kunde Lizenzen hat + cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) + license_count = cur.fetchone()[0] + + if license_count > 0: + # Kunde hat Lizenzen - nicht löschen + cur.close() + conn.close() + return redirect("/customers") + + # Kundendetails für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + customer_info = cur.fetchone() + + # Kunde löschen wenn keine Lizenzen vorhanden + cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) + + conn.commit() + + # Audit-Log + if customer_info: + log_audit('DELETE', 'customer', customer_id, + old_values={ + 'name': customer_info[0], + 'email': customer_info[1] + }) + + cur.close() + conn.close() + + return redirect("/customers") + +@app.route("/customers-licenses") +@login_required +def customers_licenses(): + """Kombinierte Ansicht für Kunden und deren Lizenzen""" + conn = get_connection() + cur = conn.cursor() + + # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + query = """ + SELECT + c.id, + c.name, + c.email, + c.created_at, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + """ + + if not show_test: + query += " WHERE c.is_test = FALSE" + + query += """ + GROUP BY c.id, c.name, c.email, c.created_at + ORDER BY c.name + """ + + cur.execute(query) + customers = cur.fetchall() + + # Hole ausgewählten Kunden nur wenn explizit in URL angegeben + selected_customer_id = request.args.get('customer_id', type=int) + licenses = [] + selected_customer = None + + if customers and selected_customer_id: + # Hole Daten des ausgewählten Kunden + for customer in customers: + if customer[0] == selected_customer_id: + selected_customer = customer + break + + # Hole Lizenzen des ausgewählten Kunden + if selected_customer: + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (selected_customer_id,)) + licenses = cur.fetchall() + + cur.close() + conn.close() + + return render_template("customers_licenses.html", + customers=customers, + selected_customer=selected_customer, + selected_customer_id=selected_customer_id, + licenses=licenses, + show_test=show_test) + +@app.route("/api/customer//licenses") +@login_required +def api_customer_licenses(customer_id): + """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Lizenzen des Kunden + cur.execute(""" + SELECT + l.id, + l.license_key, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END as status, + l.domain_count, + l.ipv4_count, + l.phone_count, + l.device_limit, + (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, + -- Actual resource counts + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, + (SELECT COUNT(*) FROM license_resources lr + JOIN resource_pools rp ON lr.resource_id = rp.id + WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count + FROM licenses l + WHERE l.customer_id = %s + ORDER BY l.created_at DESC, l.id DESC + """, (customer_id,)) + + licenses = [] + for row in cur.fetchall(): + license_id = row[0] + + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for res_row in cur.fetchall(): + resource_info = { + 'id': res_row[0], + 'value': res_row[2], + 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' + } + + if res_row[1] == 'domain': + resources['domains'].append(resource_info) + elif res_row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif res_row[1] == 'phone': + resources['phones'].append(resource_info) + + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'license_type': row[2], + 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', + 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', + 'is_active': row[5], + 'status': row[6], + 'domain_count': row[7], # limit + 'ipv4_count': row[8], # limit + 'phone_count': row[9], # limit + 'device_limit': row[10], + 'active_devices': row[11], + 'actual_domain_count': row[12], # actual count + 'actual_ipv4_count': row[13], # actual count + 'actual_phone_count': row[14], # actual count + 'resources': resources + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'licenses': licenses, + 'count': len(licenses) + }) + +@app.route("/api/customer//quick-stats") +@login_required +def api_customer_quick_stats(customer_id): + """API-Endpoint für Schnellstatistiken eines Kunden""" + conn = get_connection() + cur = conn.cursor() + + # Hole Kundenstatistiken + cur.execute(""" + SELECT + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon + FROM licenses l + WHERE l.customer_id = %s + """, (customer_id,)) + + stats = cur.fetchone() + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'stats': { + 'total': stats[0], + 'active': stats[1], + 'expired': stats[2], + 'expiring_soon': stats[3] + } + }) + +@app.route("/api/license//quick-edit", methods=['POST']) +@login_required +def api_license_quick_edit(license_id): + """API-Endpoint für schnelle Lizenz-Bearbeitung""" + conn = get_connection() + cur = conn.cursor() + + try: + data = request.get_json() + + # Hole alte Werte für Audit-Log + cur.execute(""" + SELECT is_active, valid_until, license_type + FROM licenses WHERE id = %s + """, (license_id,)) + old_values = cur.fetchone() + + if not old_values: + return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 + + # Update-Felder vorbereiten + updates = [] + params = [] + new_values = {} + + if 'is_active' in data: + updates.append("is_active = %s") + params.append(data['is_active']) + new_values['is_active'] = data['is_active'] + + if 'valid_until' in data: + updates.append("valid_until = %s") + params.append(data['valid_until']) + new_values['valid_until'] = data['valid_until'] + + if 'license_type' in data: + updates.append("license_type = %s") + params.append(data['license_type']) + new_values['license_type'] = data['license_type'] + + if updates: + params.append(license_id) + cur.execute(f""" + UPDATE licenses + SET {', '.join(updates)} + WHERE id = %s + """, params) + + conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'is_active': old_values[0], + 'valid_until': old_values[1].isoformat() if old_values[1] else None, + 'license_type': old_values[2] + }, + new_values=new_values) + + cur.close() + conn.close() + + return jsonify({'success': True}) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/api/license//resources") +@login_required +def api_license_resources(license_id): + """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz + cur.execute(""" + SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at + FROM resource_pools rp + JOIN license_resources lr ON rp.id = lr.resource_id + WHERE lr.license_id = %s AND lr.is_active = true + ORDER BY rp.resource_type, rp.resource_value + """, (license_id,)) + + resources = { + 'domains': [], + 'ipv4s': [], + 'phones': [] + } + + for row in cur.fetchall(): + resource_info = { + 'id': row[0], + 'value': row[2], + 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' + } + + if row[1] == 'domain': + resources['domains'].append(resource_info) + elif row[1] == 'ipv4': + resources['ipv4s'].append(resource_info) + elif row[1] == 'phone': + resources['phones'].append(resource_info) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'resources': resources + }) + + except Exception as e: + cur.close() + conn.close() + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route("/sessions") +@login_required +def sessions(): + conn = get_connection() + cur = conn.cursor() + + # Sortierparameter + active_sort = request.args.get('active_sort', 'last_heartbeat') + active_order = request.args.get('active_order', 'desc') + ended_sort = request.args.get('ended_sort', 'ended_at') + ended_order = request.args.get('ended_order', 'desc') + + # Whitelist für erlaubte Sortierfelder - Aktive Sessions + active_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'last_heartbeat': 's.last_heartbeat', + 'inactive': 'minutes_inactive' + } + + # Whitelist für erlaubte Sortierfelder - Beendete Sessions + ended_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'ended_at': 's.ended_at', + 'duration': 'duration_minutes' + } + + # Validierung + if active_sort not in active_sort_fields: + active_sort = 'last_heartbeat' + if ended_sort not in ended_sort_fields: + ended_sort = 'ended_at' + if active_order not in ['asc', 'desc']: + active_order = 'desc' + if ended_order not in ['asc', 'desc']: + ended_order = 'desc' + + # Aktive Sessions abrufen + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.user_agent, s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = TRUE + ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} + """) + active_sessions = cur.fetchall() + + # Inaktive Sessions der letzten 24 Stunden + cur.execute(f""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = FALSE + AND s.ended_at > NOW() - INTERVAL '24 hours' + ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} + LIMIT 50 + """) + recent_sessions = cur.fetchall() + + cur.close() + conn.close() + + return render_template("sessions.html", + active_sessions=active_sessions, + recent_sessions=recent_sessions, + active_sort=active_sort, + active_order=active_order, + ended_sort=ended_sort, + ended_order=ended_order, + username=session.get('username')) + +@app.route("/session/end/", methods=["POST"]) +@login_required +def end_session(session_id): + conn = get_connection() + cur = conn.cursor() + + # Session beenden + cur.execute(""" + UPDATE sessions + SET is_active = FALSE, ended_at = NOW() + WHERE id = %s AND is_active = TRUE + """, (session_id,)) + + conn.commit() + cur.close() + conn.close() + + return redirect("/sessions") + +@app.route("/export/licenses") +@login_required +def export_licenses(): + conn = get_connection() + cur = conn.cursor() + + # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) + include_test = request.args.get('include_test', 'false').lower() == 'true' + customer_id = request.args.get('customer_id', type=int) + + query = """ + SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, + CASE + WHEN l.is_active = FALSE THEN 'Deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' + ELSE 'Aktiv' + END as status + FROM licenses l + JOIN customers c ON l.customer_id = c.id + """ + + # Build WHERE clause + where_conditions = [] + params = [] + + if not include_test: + where_conditions.append("l.is_test = FALSE") + + if customer_id: + where_conditions.append("l.customer_id = %s") + params.append(customer_id) + + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + + query += " ORDER BY l.id" + + cur.execute(query, params) + + # Spaltennamen + columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', + 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') + df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') + + # Typ und Aktiv Status anpassen + df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) + df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'license', + additional_info=f"Export aller Lizenzen als {export_format.upper()}") + filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Lizenzen', index=False) + + # Formatierung + worksheet = writer.sheets['Lizenzen'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/audit") +@login_required +def export_audit(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_user = request.args.get('user', '') + filter_action = request.args.get('action', '') + filter_entity = request.args.get('entity', '') + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + params = [] + + if filter_user: + query += " AND username ILIKE %s" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + query += " ORDER BY timestamp DESC" + + cur.execute(query, params) + audit_logs = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for log in audit_logs: + action_text = { + 'CREATE': 'Erstellt', + 'UPDATE': 'Bearbeitet', + 'DELETE': 'Gelöscht', + 'LOGIN': 'Anmeldung', + 'LOGOUT': 'Abmeldung', + 'AUTO_LOGOUT': 'Auto-Logout', + 'EXPORT': 'Export', + 'GENERATE_KEY': 'Key generiert', + 'CREATE_BATCH': 'Batch erstellt', + 'BACKUP': 'Backup erstellt', + 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', + 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', + 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', + 'LOGIN_BLOCKED': 'Login-Blockiert', + 'RESTORE': 'Wiederhergestellt', + 'PASSWORD_CHANGE': 'Passwort geändert', + '2FA_ENABLED': '2FA aktiviert', + '2FA_DISABLED': '2FA deaktiviert' + }.get(log[3], log[3]) + + data.append({ + 'ID': log[0], + 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), + 'Benutzer': log[2], + 'Aktion': action_text, + 'Entität': log[4], + 'Entität-ID': log[5] or '', + 'IP-Adresse': log[8] or '', + 'Zusatzinfo': log[10] or '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'audit_log_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'audit_log', + additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Audit Log') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Audit Log'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/customers") +@login_required +def export_customers(): + conn = get_connection() + cur = conn.cursor() + + # Check if test data should be included + include_test = request.args.get('include_test', 'false').lower() == 'true' + + # Build query based on test data filter + if include_test: + # Include all customers + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(l.id) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + else: + # Exclude test customers and test licenses + query = """ + SELECT c.id, c.name, c.email, c.created_at, c.is_test, + COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, + COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.is_test = FALSE + GROUP BY c.id, c.name, c.email, c.created_at, c.is_test + ORDER BY c.id + """ + + cur.execute(query) + + # Spaltennamen + columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', + 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] + + # Daten in DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + # Datumsformatierung + df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') + + # Testdaten formatting + df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) + + cur.close() + conn.close() + + # Export Format + export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'customer', + additional_info=f"Export aller Kunden als {export_format.upper()}") + filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Kunden', index=False) + + # Formatierung + worksheet = writer.sheets['Kunden'] + for column in worksheet.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = min(max_length + 2, 50) + worksheet.column_dimensions[column_letter].width = adjusted_width + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/sessions") +@login_required +def export_sessions(): + conn = get_connection() + cur = conn.cursor() + + # Holen des Session-Typs (active oder ended) + session_type = request.args.get('type', 'active') + export_format = request.args.get('format', 'excel') + + # Daten je nach Typ abrufen + if session_type == 'active': + # Aktive Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = true + ORDER BY s.last_heartbeat DESC + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Aktive Sessions' + filename_prefix = 'aktive_sessions' + else: + # Beendete Lizenz-Sessions + cur.execute(""" + SELECT s.id, l.license_key, c.name as customer_name, s.session_id, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, + s.ip_address, s.user_agent + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = false AND s.ended_at IS NOT NULL + ORDER BY s.ended_at DESC + LIMIT 1000 + """) + sessions = cur.fetchall() + + # Daten für Export vorbereiten + data = [] + for sess in sessions: + duration = sess[6] if sess[6] else 0 + hours = duration // 3600 + minutes = (duration % 3600) // 60 + seconds = duration % 60 + + data.append({ + 'Session-ID': sess[0], + 'Lizenzschlüssel': sess[1], + 'Kunde': sess[2], + 'Session-ID (Tech)': sess[3], + 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), + 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', + 'Dauer': f"{hours}h {minutes}m {seconds}s", + 'IP-Adresse': sess[7], + 'Browser': sess[8] + }) + + sheet_name = 'Beendete Sessions' + filename_prefix = 'beendete_sessions' + + cur.close() + conn.close() + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'{filename_prefix}_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'sessions', + additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name=sheet_name) + + # Spaltenbreiten anpassen + worksheet = writer.sheets[sheet_name] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/export/resources") +@login_required +def export_resources(): + conn = get_connection() + cur = conn.cursor() + + # Holen der Filter-Parameter + filter_type = request.args.get('type', '') + filter_status = request.args.get('status', '') + search_query = request.args.get('search', '') + show_test = request.args.get('show_test', 'false').lower() == 'true' + export_format = request.args.get('format', 'excel') + + # SQL Query mit Filtern + query = """ + SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, + r.created_at, r.status_changed_at, + l.license_key, c.name as customer_name, c.email as customer_email, + l.license_type + FROM resource_pools r + LEFT JOIN licenses l ON r.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE 1=1 + """ + params = [] + + # Filter für Testdaten + if not show_test: + query += " AND (r.is_test = false OR r.is_test IS NULL)" + + # Filter für Ressourcentyp + if filter_type: + query += " AND r.resource_type = %s" + params.append(filter_type) + + # Filter für Status + if filter_status: + query += " AND r.status = %s" + params.append(filter_status) + + # Suchfilter + if search_query: + query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" + params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) + + query += " ORDER BY r.id DESC" + + cur.execute(query, params) + resources = cur.fetchall() + cur.close() + conn.close() + + # Daten für Export vorbereiten + data = [] + for res in resources: + status_text = { + 'available': 'Verfügbar', + 'allocated': 'Zugewiesen', + 'quarantine': 'Quarantäne' + }.get(res[3], res[3]) + + type_text = { + 'domain': 'Domain', + 'ipv4': 'IPv4', + 'phone': 'Telefon' + }.get(res[1], res[1]) + + data.append({ + 'ID': res[0], + 'Typ': type_text, + 'Ressource': res[2], + 'Status': status_text, + 'Lizenzschlüssel': res[7] or '', + 'Kunde': res[8] or '', + 'Kunden-Email': res[9] or '', + 'Lizenztyp': res[10] or '', + 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', + 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' + }) + + # DataFrame erstellen + df = pd.DataFrame(data) + + # Timestamp für Dateiname + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f'resources_export_{timestamp}' + + # Audit Log für Export + log_audit('EXPORT', 'resources', + additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") + + if export_format == 'csv': + # CSV Export + output = io.StringIO() + # UTF-8 BOM für Excel + output.write('\ufeff') + df.to_csv(output, index=False, sep=';', encoding='utf-8') + output.seek(0) + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv;charset=utf-8', + as_attachment=True, + download_name=f'{filename}.csv' + ) + else: + # Excel Export + output = BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Resources') + + # Spaltenbreiten anpassen + worksheet = writer.sheets['Resources'] + for idx, col in enumerate(df.columns): + max_length = max( + df[col].astype(str).map(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) + + output.seek(0) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + +@app.route("/audit") +@login_required +def audit_log(): + conn = get_connection() + cur = conn.cursor() + + # Parameter + filter_user = request.args.get('user', '').strip() + filter_action = request.args.get('action', '').strip() + filter_entity = request.args.get('entity', '').strip() + page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'timestamp') + order = request.args.get('order', 'desc') + per_page = 50 + + # Whitelist für erlaubte Sortierfelder + allowed_sort_fields = { + 'timestamp': 'timestamp', + 'username': 'username', + 'action': 'action', + 'entity': 'entity_type', + 'ip': 'ip_address' + } + + # Validierung + if sort not in allowed_sort_fields: + sort = 'timestamp' + if order not in ['asc', 'desc']: + order = 'desc' + + sort_field = allowed_sort_fields[sort] + + # SQL Query mit optionalen Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + + params = [] + + # Filter + if filter_user: + query += " AND LOWER(username) LIKE LOWER(%s)" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + # Gesamtanzahl für Pagination + count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" + cur.execute(count_query, params) + total = cur.fetchone()[0] + + # Pagination + offset = (page - 1) * per_page + query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + logs = cur.fetchall() + + # JSON-Werte parsen + parsed_logs = [] + for log in logs: + parsed_log = list(log) + # old_values und new_values sind bereits Dictionaries (JSONB) + # Keine Konvertierung nötig + parsed_logs.append(parsed_log) + + # Pagination Info + total_pages = (total + per_page - 1) // per_page + + cur.close() + conn.close() + + return render_template("audit_log.html", + logs=parsed_logs, + filter_user=filter_user, + filter_action=filter_action, + filter_entity=filter_entity, + page=page, + total_pages=total_pages, + total=total, + sort=sort, + order=order, + username=session.get('username')) + +@app.route("/backups") +@login_required +def backups(): + """Zeigt die Backup-Historie an""" + conn = get_connection() + cur = conn.cursor() + + # Letztes erfolgreiches Backup für Dashboard + cur.execute(""" + SELECT created_at, filesize, duration_seconds + FROM backup_history + WHERE status = 'success' + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup = cur.fetchone() + + # Alle Backups abrufen + cur.execute(""" + SELECT id, filename, filesize, backup_type, status, error_message, + created_at, created_by, tables_count, records_count, + duration_seconds, is_encrypted + FROM backup_history + ORDER BY created_at DESC + """) + backups = cur.fetchall() + + cur.close() + conn.close() + + return render_template("backups.html", + backups=backups, + last_backup=last_backup, + username=session.get('username')) + +@app.route("/backup/create", methods=["POST"]) +@login_required +def create_backup_route(): + """Erstellt ein manuelles Backup""" + username = session.get('username') + success, result = create_backup(backup_type="manual", created_by=username) + + if success: + return jsonify({ + 'success': True, + 'message': f'Backup erfolgreich erstellt: {result}' + }) + else: + return jsonify({ + 'success': False, + 'message': f'Backup fehlgeschlagen: {result}' + }), 500 + +@app.route("/backup/restore/", methods=["POST"]) +@login_required +def restore_backup_route(backup_id): + """Stellt ein Backup wieder her""" + encryption_key = request.form.get('encryption_key') + + success, message = restore_backup(backup_id, encryption_key) + + if success: + return jsonify({ + 'success': True, + 'message': message + }) + else: + return jsonify({ + 'success': False, + 'message': message + }), 500 + +@app.route("/backup/download/") +@login_required +def download_backup(backup_id): + """Lädt eine Backup-Datei herunter""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + cur.close() + conn.close() + + if not backup_info: + return "Backup nicht gefunden", 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + if not filepath.exists(): + return "Backup-Datei nicht gefunden", 404 + + # Audit-Log + log_audit('DOWNLOAD', 'backup', backup_id, + additional_info=f"Backup heruntergeladen: {filename}") + + return send_file(filepath, as_attachment=True, download_name=filename) + +@app.route("/backup/delete/", methods=["DELETE"]) +@login_required +def delete_backup(backup_id): + """Löscht ein Backup""" + conn = get_connection() + cur = conn.cursor() + + try: + # Backup-Informationen abrufen + cur.execute(""" + SELECT filename, filepath + FROM backup_history + WHERE id = %s + """, (backup_id,)) + backup_info = cur.fetchone() + + if not backup_info: + return jsonify({ + 'success': False, + 'message': 'Backup nicht gefunden' + }), 404 + + filename, filepath = backup_info + filepath = Path(filepath) + + # Datei löschen, wenn sie existiert + if filepath.exists(): + filepath.unlink() + + # Aus Datenbank löschen + cur.execute(""" + DELETE FROM backup_history + WHERE id = %s + """, (backup_id,)) + + conn.commit() + + # Audit-Log + log_audit('DELETE', 'backup', backup_id, + additional_info=f"Backup gelöscht: {filename}") + + return jsonify({ + 'success': True, + 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' + }) + + except Exception as e: + conn.rollback() + return jsonify({ + 'success': False, + 'message': f'Fehler beim Löschen des Backups: {str(e)}' + }), 500 + finally: + cur.close() + conn.close() + +@app.route("/security/blocked-ips") +@login_required +def blocked_ips(): + """Zeigt alle gesperrten IPs an""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT + ip_address, + attempt_count, + first_attempt, + last_attempt, + blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + WHERE blocked_until IS NOT NULL + ORDER BY blocked_until DESC + """) + + blocked_ips_list = [] + for ip in cur.fetchall(): + blocked_ips_list.append({ + 'ip_address': ip[0], + 'attempt_count': ip[1], + 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), + 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), + 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), + 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), + 'last_username': ip[5], + 'last_error': ip[6] + }) + + cur.close() + conn.close() + + return render_template("blocked_ips.html", + blocked_ips=blocked_ips_list, + username=session.get('username')) + +@app.route("/security/unblock-ip", methods=["POST"]) +@login_required +def unblock_ip(): + """Entsperrt eine IP-Adresse""" + ip_address = request.form.get('ip_address') + + if ip_address: + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + UPDATE login_attempts + SET blocked_until = NULL + WHERE ip_address = %s + """, (ip_address,)) + + conn.commit() + cur.close() + conn.close() + + # Audit-Log + log_audit('UNBLOCK_IP', 'security', + additional_info=f"IP {ip_address} manuell entsperrt") + + return redirect(url_for('blocked_ips')) + +@app.route("/security/clear-attempts", methods=["POST"]) +@login_required +def clear_attempts(): + """Löscht alle Login-Versuche für eine IP""" + ip_address = request.form.get('ip_address') + + if ip_address: + reset_login_attempts(ip_address) + + # Audit-Log + log_audit('CLEAR_ATTEMPTS', 'security', + additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") + + return redirect(url_for('blocked_ips')) + +# API Endpoints for License Management +@app.route("/api/license//toggle", methods=["POST"]) +@login_required +def toggle_license_api(license_id): + """Toggle license active status via API""" + try: + data = request.get_json() + is_active = data.get('is_active', False) + + conn = get_connection() + cur = conn.cursor() + + # Update license status + cur.execute(""" + UPDATE licenses + SET is_active = %s + WHERE id = %s + """, (is_active, license_id)) + + conn.commit() + + # Log the action + log_audit('UPDATE', 'license', license_id, + new_values={'is_active': is_active}, + additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-activate", methods=["POST"]) +@login_required +def bulk_activate_licenses(): + """Activate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = TRUE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': True, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen aktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) +@login_required +def bulk_deactivate_licenses(): + """Deactivate multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Update all selected licenses (nur Live-Daten) + cur.execute(""" + UPDATE licenses + SET is_active = FALSE + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_UPDATE', 'licenses', None, + new_values={'is_active': False, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen deaktiviert") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +@app.route("/api/license//devices") +@login_required +def get_license_devices(license_id): + """Hole alle registrierten Geräte einer Lizenz""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und hole device_limit + cur.execute(""" + SELECT device_limit FROM licenses WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit = license_data[0] + + # Hole alle Geräte für diese Lizenz + cur.execute(""" + SELECT id, hardware_id, device_name, operating_system, + first_seen, last_seen, is_active, ip_address + FROM device_registrations + WHERE license_id = %s + ORDER BY is_active DESC, last_seen DESC + """, (license_id,)) + + devices = [] + for row in cur.fetchall(): + devices.append({ + 'id': row[0], + 'hardware_id': row[1], + 'device_name': row[2] or 'Unbekanntes Gerät', + 'operating_system': row[3] or 'Unbekannt', + 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', + 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', + 'is_active': row[6], + 'ip_address': row[7] or '-' + }) + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'devices': devices, + 'device_limit': device_limit, + 'active_count': sum(1 for d in devices if d['is_active']) + }) + + except Exception as e: + logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 + +@app.route("/api/license//register-device", methods=["POST"]) +def register_device(license_id): + """Registriere ein neues Gerät für eine Lizenz""" + try: + data = request.get_json() + hardware_id = data.get('hardware_id') + device_name = data.get('device_name', '') + operating_system = data.get('operating_system', '') + + if not hardware_id: + return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Lizenz existiert und aktiv ist + cur.execute(""" + SELECT device_limit, is_active, valid_until + FROM licenses + WHERE id = %s + """, (license_id,)) + license_data = cur.fetchone() + + if not license_data: + return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 + + device_limit, is_active, valid_until = license_data + + # Prüfe ob Lizenz aktiv und gültig ist + if not is_active: + return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 + + if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): + return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 + + # Prüfe ob Gerät bereits registriert ist + cur.execute(""" + SELECT id, is_active FROM device_registrations + WHERE license_id = %s AND hardware_id = %s + """, (license_id, hardware_id)) + existing_device = cur.fetchone() + + if existing_device: + device_id, is_device_active = existing_device + if is_device_active: + # Gerät ist bereits aktiv, update last_seen + cur.execute(""" + UPDATE device_registrations + SET last_seen = CURRENT_TIMESTAMP, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) + else: + # Gerät war deaktiviert, prüfe ob wir es reaktivieren können + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Reaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = TRUE, + last_seen = CURRENT_TIMESTAMP, + deactivated_at = NULL, + deactivated_by = NULL, + ip_address = %s, + user_agent = %s + WHERE id = %s + """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) + conn.commit() + return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) + + # Neues Gerät - prüfe Gerätelimit + cur.execute(""" + SELECT COUNT(*) FROM device_registrations + WHERE license_id = %s AND is_active = TRUE + """, (license_id,)) + active_count = cur.fetchone()[0] + + if active_count >= device_limit: + return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 + + # Registriere neues Gerät + cur.execute(""" + INSERT INTO device_registrations + (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, (license_id, hardware_id, device_name, operating_system, + get_client_ip(), request.headers.get('User-Agent', ''))) + device_id = cur.fetchone()[0] + + conn.commit() + + # Audit Log + log_audit('DEVICE_REGISTER', 'device', device_id, + new_values={'license_id': license_id, 'hardware_id': hardware_id}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) + + except Exception as e: + logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 + +@app.route("/api/license//deactivate-device/", methods=["POST"]) +@login_required +def deactivate_device(license_id, device_id): + """Deaktiviere ein registriertes Gerät""" + try: + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob das Gerät zu dieser Lizenz gehört + cur.execute(""" + SELECT id FROM device_registrations + WHERE id = %s AND license_id = %s AND is_active = TRUE + """, (device_id, license_id)) + + if not cur.fetchone(): + return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 + + # Deaktiviere das Gerät + cur.execute(""" + UPDATE device_registrations + SET is_active = FALSE, + deactivated_at = CURRENT_TIMESTAMP, + deactivated_by = %s + WHERE id = %s + """, (session['username'], device_id)) + + conn.commit() + + # Audit Log + log_audit('DEVICE_DEACTIVATE', 'device', device_id, + old_values={'is_active': True}, + new_values={'is_active': False}) + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) + + except Exception as e: + logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") + return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 + +@app.route("/api/licenses/bulk-delete", methods=["POST"]) +@login_required +def bulk_delete_licenses(): + """Delete multiple licenses at once""" + try: + data = request.get_json() + license_ids = data.get('ids', []) + + if not license_ids: + return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 + + conn = get_connection() + cur = conn.cursor() + + # Get license info for audit log (nur Live-Daten) + cur.execute(""" + SELECT license_key + FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + license_keys = [row[0] for row in cur.fetchall()] + + # Delete all selected licenses (nur Live-Daten) + cur.execute(""" + DELETE FROM licenses + WHERE id = ANY(%s) AND is_test = FALSE + """, (license_ids,)) + + affected_rows = cur.rowcount + conn.commit() + + # Log the bulk action + log_audit('BULK_DELETE', 'licenses', None, + old_values={'license_keys': license_keys, 'count': affected_rows}, + additional_info=f"{affected_rows} Lizenzen gelöscht") + + cur.close() + conn.close() + + return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) + except Exception as e: + return jsonify({'success': False, 'message': str(e)}), 500 + +# ===================== RESOURCE POOL MANAGEMENT ===================== + +@app.route('/resources') +@login_required +def resources(): + """Resource Pool Hauptübersicht""" + conn = get_connection() + cur = conn.cursor() + + # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + # Statistiken abrufen + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + WHERE is_test = %s + GROUP BY resource_type + """, (show_test,)) + + stats = {} + for row in cur.fetchall(): + stats[row[0]] = { + 'available': row[1], + 'allocated': row[2], + 'quarantine': row[3], + 'total': row[4], + 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) + } + + # Letzte Aktivitäten (gefiltert nach Test/Live) + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rp.resource_type, + rp.resource_value, + rh.details + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + WHERE rp.is_test = %s + ORDER BY rh.action_at DESC + LIMIT 10 + """, (show_test,)) + recent_activities = cur.fetchall() + + # Ressourcen-Liste mit Pagination + page = request.args.get('page', 1, type=int) + per_page = 50 + offset = (page - 1) * per_page + + resource_type = request.args.get('type', '') + status_filter = request.args.get('status', '') + search = request.args.get('search', '') + + # Sortierung + sort_by = request.args.get('sort', 'id') + sort_order = request.args.get('order', 'desc') + + # Base Query + query = """ + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + rp.allocated_to_license, + l.license_key, + c.name as customer_name, + rp.status_changed_at, + rp.quarantine_reason, + rp.quarantine_until, + c.id as customer_id + FROM resource_pools rp + LEFT JOIN licenses l ON rp.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rp.is_test = %s + """ + params = [show_test] + + if resource_type: + query += " AND rp.resource_type = %s" + params.append(resource_type) + + if status_filter: + query += " AND rp.status = %s" + params.append(status_filter) + + if search: + query += " AND rp.resource_value ILIKE %s" + params.append(f'%{search}%') + + # Count total + count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" + cur.execute(count_query, params) + total = cur.fetchone()[0] + total_pages = (total + per_page - 1) // per_page + + # Get paginated results with dynamic sorting + sort_column_map = { + 'id': 'rp.id', + 'type': 'rp.resource_type', + 'resource': 'rp.resource_value', + 'status': 'rp.status', + 'assigned': 'c.name', + 'changed': 'rp.status_changed_at' + } + + sort_column = sort_column_map.get(sort_by, 'rp.id') + sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' + + query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + resources = cur.fetchall() + + cur.close() + conn.close() + + return render_template('resources.html', + stats=stats, + resources=resources, + recent_activities=recent_activities, + page=page, + total_pages=total_pages, + total=total, + resource_type=resource_type, + status_filter=status_filter, + search=search, + show_test=show_test, + sort_by=sort_by, + sort_order=sort_order, + datetime=datetime, + timedelta=timedelta) + +@app.route('/resources/add', methods=['GET', 'POST']) +@login_required +def add_resources(): + """Ressourcen zum Pool hinzufügen""" + # Hole show_test Parameter für die Anzeige + show_test = request.args.get('show_test', 'false').lower() == 'true' + + if request.method == 'POST': + resource_type = request.form.get('resource_type') + resources_text = request.form.get('resources_text', '') + is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten + + # Parse resources (one per line) + resources = [r.strip() for r in resources_text.split('\n') if r.strip()] + + if not resources: + flash('Keine Ressourcen angegeben', 'error') + return redirect(url_for('add_resources', show_test=show_test)) + + conn = get_connection() + cur = conn.cursor() + + added = 0 + duplicates = 0 + + for resource_value in resources: + try: + cur.execute(""" + INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) + VALUES (%s, %s, %s, %s) + ON CONFLICT (resource_type, resource_value) DO NOTHING + """, (resource_type, resource_value, session['username'], is_test)) + + if cur.rowcount > 0: + added += 1 + # Get the inserted ID + cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", + (resource_type, resource_value)) + resource_id = cur.fetchone()[0] + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'created', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + else: + duplicates += 1 + + except Exception as e: + app.logger.error(f"Error adding resource {resource_value}: {e}") + + conn.commit() + cur.close() + conn.close() + + log_audit('CREATE', 'resource_pool', None, + new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, + additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") + + flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') + return redirect(url_for('resources', show_test=show_test)) + + return render_template('add_resources.html', show_test=show_test) + +@app.route('/resources/quarantine/', methods=['POST']) +@login_required +def quarantine_resource(resource_id): + """Ressource in Quarantäne setzen""" + reason = request.form.get('reason', 'review') + until_date = request.form.get('until_date') + notes = request.form.get('notes', '') + + conn = get_connection() + cur = conn.cursor() + + # Get current resource info + cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) + resource = cur.fetchone() + + if not resource: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + old_status = resource[2] + + # Update resource + cur.execute(""" + UPDATE resource_pools + SET status = 'quarantine', + quarantine_reason = %s, + quarantine_until = %s, + notes = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) + + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) + VALUES (%s, 'quarantined', %s, %s, %s) + """, (resource_id, session['username'], get_client_ip(), + Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource', resource_id, + old_values={'status': old_status}, + new_values={'status': 'quarantine', 'reason': reason}, + additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") + + flash('Ressource in Quarantäne gesetzt', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/resources/release', methods=['POST']) +@login_required +def release_resources(): + """Ressourcen aus Quarantäne freigeben""" + resource_ids = request.form.getlist('resource_ids') + + if not resource_ids: + flash('Keine Ressourcen ausgewählt', 'error') + return redirect(url_for('resources')) + + conn = get_connection() + cur = conn.cursor() + + released = 0 + for resource_id in resource_ids: + cur.execute(""" + UPDATE resource_pools + SET status = 'available', + quarantine_reason = NULL, + quarantine_until = NULL, + allocated_to_license = NULL, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s AND status = 'quarantine' + """, (session['username'], resource_id)) + + if cur.rowcount > 0: + released += 1 + # Log in history + cur.execute(""" + INSERT INTO resource_history (resource_id, action, action_by, ip_address) + VALUES (%s, 'released', %s, %s) + """, (resource_id, session['username'], get_client_ip())) + + conn.commit() + cur.close() + conn.close() + + log_audit('UPDATE', 'resource_pool', None, + new_values={'released': released}, + additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") + + flash(f'{released} Ressourcen freigegeben', 'success') + + # Redirect mit allen aktuellen Filtern + return redirect(url_for('resources', + show_test=request.args.get('show_test', request.form.get('show_test', 'false')), + type=request.args.get('type', request.form.get('type', '')), + status=request.args.get('status', request.form.get('status', '')), + search=request.args.get('search', request.form.get('search', '')))) + +@app.route('/api/resources/allocate', methods=['POST']) +@login_required +def allocate_resources_api(): + """API für Ressourcen-Zuweisung bei Lizenzerstellung""" + data = request.json + license_id = data.get('license_id') + domain_count = data.get('domain_count', 1) + ipv4_count = data.get('ipv4_count', 1) + phone_count = data.get('phone_count', 1) + + conn = get_connection() + cur = conn.cursor() + + try: + allocated = {'domains': [], 'ipv4s': [], 'phones': []} + + # Allocate domains + if domain_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'domain' AND status = 'available' + LIMIT %s FOR UPDATE + """, (domain_count,)) + domains = cur.fetchall() + + if len(domains) < domain_count: + raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") + + for domain_id, domain_value in domains: + # Update resource status + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], domain_id)) + + # Create assignment + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, domain_id, session['username'])) + + # Log history + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (domain_id, license_id, session['username'], get_client_ip())) + + allocated['domains'].append(domain_value) + + # Allocate IPv4s (similar logic) + if ipv4_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'ipv4' AND status = 'available' + LIMIT %s FOR UPDATE + """, (ipv4_count,)) + ipv4s = cur.fetchall() + + if len(ipv4s) < ipv4_count: + raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") + + for ipv4_id, ipv4_value in ipv4s: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], ipv4_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, ipv4_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (ipv4_id, license_id, session['username'], get_client_ip())) + + allocated['ipv4s'].append(ipv4_value) + + # Allocate phones (similar logic) + if phone_count > 0: + cur.execute(""" + SELECT id, resource_value FROM resource_pools + WHERE resource_type = 'phone' AND status = 'available' + LIMIT %s FOR UPDATE + """, (phone_count,)) + phones = cur.fetchall() + + if len(phones) < phone_count: + raise ValueError(f"Nicht genügend Telefonnummern verfügbar") + + for phone_id, phone_value in phones: + cur.execute(""" + UPDATE resource_pools + SET status = 'allocated', + allocated_to_license = %s, + status_changed_at = CURRENT_TIMESTAMP, + status_changed_by = %s + WHERE id = %s + """, (license_id, session['username'], phone_id)) + + cur.execute(""" + INSERT INTO license_resources (license_id, resource_id, assigned_by) + VALUES (%s, %s, %s) + """, (license_id, phone_id, session['username'])) + + cur.execute(""" + INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) + VALUES (%s, %s, 'allocated', %s, %s) + """, (phone_id, license_id, session['username'], get_client_ip())) + + allocated['phones'].append(phone_value) + + # Update license resource counts + cur.execute(""" + UPDATE licenses + SET domain_count = %s, + ipv4_count = %s, + phone_count = %s + WHERE id = %s + """, (domain_count, ipv4_count, phone_count, license_id)) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'allocated': allocated + }) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({ + 'success': False, + 'error': str(e) + }), 400 + +@app.route('/api/resources/check-availability', methods=['GET']) +@login_required +def check_resource_availability(): + """Prüft verfügbare Ressourcen""" + resource_type = request.args.get('type', '') + count = request.args.get('count', 10, type=int) + show_test = request.args.get('show_test', 'false').lower() == 'true' + + conn = get_connection() + cur = conn.cursor() + + if resource_type: + # Spezifische Ressourcen für einen Typ + cur.execute(""" + SELECT id, resource_value + FROM resource_pools + WHERE status = 'available' + AND resource_type = %s + AND is_test = %s + ORDER BY resource_value + LIMIT %s + """, (resource_type, show_test, count)) + + resources = [] + for row in cur.fetchall(): + resources.append({ + 'id': row[0], + 'value': row[1] + }) + + cur.close() + conn.close() + + return jsonify({ + 'available': resources, + 'type': resource_type, + 'count': len(resources) + }) + else: + # Zusammenfassung aller Typen + cur.execute(""" + SELECT + resource_type, + COUNT(*) as available + FROM resource_pools + WHERE status = 'available' + AND is_test = %s + GROUP BY resource_type + """, (show_test,)) + + availability = {} + for row in cur.fetchall(): + availability[row[0]] = row[1] + + cur.close() + conn.close() + + return jsonify(availability) + +@app.route('/api/global-search', methods=['GET']) +@login_required +def global_search(): + """Global search API endpoint for searching customers and licenses""" + query = request.args.get('q', '').strip() + + if not query or len(query) < 2: + return jsonify({'customers': [], 'licenses': []}) + + conn = get_connection() + cur = conn.cursor() + + # Search pattern with wildcards + search_pattern = f'%{query}%' + + # Search customers + cur.execute(""" + SELECT id, name, email, company_name + FROM customers + WHERE (LOWER(name) LIKE LOWER(%s) + OR LOWER(email) LIKE LOWER(%s) + OR LOWER(company_name) LIKE LOWER(%s)) + AND is_test = FALSE + ORDER BY name + LIMIT 5 + """, (search_pattern, search_pattern, search_pattern)) + + customers = [] + for row in cur.fetchall(): + customers.append({ + 'id': row[0], + 'name': row[1], + 'email': row[2], + 'company_name': row[3] + }) + + # Search licenses + cur.execute(""" + SELECT l.id, l.license_key, c.name as customer_name + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE LOWER(l.license_key) LIKE LOWER(%s) + AND l.is_test = FALSE + ORDER BY l.created_at DESC + LIMIT 5 + """, (search_pattern,)) + + licenses = [] + for row in cur.fetchall(): + licenses.append({ + 'id': row[0], + 'license_key': row[1], + 'customer_name': row[2] + }) + + cur.close() + conn.close() + + return jsonify({ + 'customers': customers, + 'licenses': licenses + }) + +@app.route('/resources/history/') +@login_required +def resource_history(resource_id): + """Zeigt die komplette Historie einer Ressource""" + conn = get_connection() + cur = conn.cursor() + + # Get complete resource info using named columns + cur.execute(""" + SELECT id, resource_type, resource_value, status, allocated_to_license, + status_changed_at, status_changed_by, quarantine_reason, + quarantine_until, created_at, notes + FROM resource_pools + WHERE id = %s + """, (resource_id,)) + row = cur.fetchone() + + if not row: + flash('Ressource nicht gefunden', 'error') + return redirect(url_for('resources')) + + # Create resource object with named attributes + resource = { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'allocated_to_license': row[4], + 'status_changed_at': row[5], + 'status_changed_by': row[6], + 'quarantine_reason': row[7], + 'quarantine_until': row[8], + 'created_at': row[9], + 'notes': row[10] + } + + # Get license info if allocated + license_info = None + if resource['allocated_to_license']: + cur.execute("SELECT license_key FROM licenses WHERE id = %s", + (resource['allocated_to_license'],)) + lic = cur.fetchone() + if lic: + license_info = {'license_key': lic[0]} + + # Get history with named columns + cur.execute(""" + SELECT + rh.action, + rh.action_by, + rh.action_at, + rh.details, + rh.license_id, + rh.ip_address + FROM resource_history rh + WHERE rh.resource_id = %s + ORDER BY rh.action_at DESC + """, (resource_id,)) + + history = [] + for row in cur.fetchall(): + history.append({ + 'action': row[0], + 'action_by': row[1], + 'action_at': row[2], + 'details': row[3], + 'license_id': row[4], + 'ip_address': row[5] + }) + + cur.close() + conn.close() + + # Convert to object-like for template + class ResourceObj: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + + resource_obj = ResourceObj(resource) + history_objs = [ResourceObj(h) for h in history] + + return render_template('resource_history.html', + resource=resource_obj, + license_info=license_info, + history=history_objs) + +@app.route('/resources/metrics') +@login_required +def resources_metrics(): + """Dashboard für Resource Metrics und Reports""" + conn = get_connection() + cur = conn.cursor() + + # Overall stats with fallback values + cur.execute(""" + SELECT + COUNT(DISTINCT resource_id) as total_resources, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(cost), 0) as total_cost, + COALESCE(SUM(revenue), 0) as total_revenue, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + """) + row = cur.fetchone() + + # Calculate ROI + roi = 0 + if row[2] > 0: # if total_cost > 0 + roi = row[3] / row[2] # revenue / cost + + stats = { + 'total_resources': row[0] or 0, + 'avg_performance': row[1] or 0, + 'total_cost': row[2] or 0, + 'total_revenue': row[3] or 0, + 'total_issues': row[4] or 0, + 'roi': roi + } + + # Performance by type + cur.execute(""" + SELECT + rp.resource_type, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COUNT(DISTINCT rp.id) as resource_count + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY rp.resource_type + ORDER BY rp.resource_type + """) + performance_by_type = cur.fetchall() + + # Utilization data + cur.execute(""" + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) as total, + ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent + FROM resource_pools + GROUP BY resource_type + """) + utilization_rows = cur.fetchall() + utilization_data = [ + { + 'type': row[0].upper(), + 'allocated': row[1], + 'total': row[2], + 'allocated_percent': row[3] + } + for row in utilization_rows + ] + + # Top performing resources + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + COALESCE(AVG(rm.performance_score), 0) as avg_score, + COALESCE(SUM(rm.revenue), 0) as total_revenue, + COALESCE(SUM(rm.cost), 1) as total_cost, + CASE + WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 + ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) + END as roi + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rp.status != 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value + HAVING AVG(rm.performance_score) IS NOT NULL + ORDER BY avg_score DESC + LIMIT 10 + """) + top_rows = cur.fetchall() + top_performers = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'avg_score': row[3], + 'roi': row[6] + } + for row in top_rows + ] + + # Resources with issues + cur.execute(""" + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + COALESCE(SUM(rm.issues_count), 0) as total_issues + FROM resource_pools rp + LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id + AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' + WHERE rm.issues_count > 0 OR rp.status = 'quarantine' + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + HAVING SUM(rm.issues_count) > 0 + ORDER BY total_issues DESC + LIMIT 10 + """) + problem_rows = cur.fetchall() + problem_resources = [ + { + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'total_issues': row[4] + } + for row in problem_rows + ] + + # Daily metrics for trend chart (last 30 days) + cur.execute(""" + SELECT + metric_date, + COALESCE(AVG(performance_score), 0) as avg_performance, + COALESCE(SUM(issues_count), 0) as total_issues + FROM resource_metrics + WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' + GROUP BY metric_date + ORDER BY metric_date + """) + daily_rows = cur.fetchall() + daily_metrics = [ + { + 'date': row[0].strftime('%d.%m'), + 'performance': float(row[1]), + 'issues': int(row[2]) + } + for row in daily_rows + ] + + cur.close() + conn.close() + + return render_template('resource_metrics.html', + stats=stats, + performance_by_type=performance_by_type, + utilization_data=utilization_data, + top_performers=top_performers, + problem_resources=problem_resources, + daily_metrics=daily_metrics) + +@app.route('/resources/report', methods=['GET']) +@login_required +def resources_report(): + """Generiert Ressourcen-Reports oder zeigt Report-Formular""" + # Prüfe ob Download angefordert wurde + if request.args.get('download') == 'true': + report_type = request.args.get('type', 'usage') + format_type = request.args.get('format', 'excel') + date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) + date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) + + conn = get_connection() + cur = conn.cursor() + + if report_type == 'usage': + # Auslastungsreport + query = """ + SELECT + rp.resource_type, + rp.resource_value, + rp.status, + COUNT(DISTINCT rh.license_id) as unique_licenses, + COUNT(rh.id) as total_allocations, + MIN(rh.action_at) as first_used, + MAX(rh.action_at) as last_used + FROM resource_pools rp + LEFT JOIN resource_history rh ON rp.id = rh.resource_id + AND rh.action = 'allocated' + AND rh.action_at BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status + ORDER BY rp.resource_type, total_allocations DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] + + elif report_type == 'performance': + # Performance-Report + query = """ + SELECT + rp.resource_type, + rp.resource_value, + AVG(rm.performance_score) as avg_performance, + SUM(rm.usage_count) as total_usage, + SUM(rm.revenue) as total_revenue, + SUM(rm.cost) as total_cost, + SUM(rm.revenue - rm.cost) as profit, + SUM(rm.issues_count) as total_issues + FROM resource_pools rp + JOIN resource_metrics rm ON rp.id = rm.resource_id + WHERE rm.metric_date BETWEEN %s AND %s + GROUP BY rp.id, rp.resource_type, rp.resource_value + ORDER BY profit DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] + + elif report_type == 'compliance': + # Compliance-Report + query = """ + SELECT + rh.action_at, + rh.action, + rh.action_by, + rp.resource_type, + rp.resource_value, + l.license_key, + c.name as customer_name, + rh.ip_address + FROM resource_history rh + JOIN resource_pools rp ON rh.resource_id = rp.id + LEFT JOIN licenses l ON rh.license_id = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE rh.action_at BETWEEN %s AND %s + ORDER BY rh.action_at DESC + """ + cur.execute(query, (date_from, date_to)) + columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] + + else: # inventory report + # Inventar-Report + query = """ + SELECT + resource_type, + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'allocated') as allocated, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resource_pools + GROUP BY resource_type + ORDER BY resource_type + """ + cur.execute(query) + columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] + + # Convert to DataFrame + data = cur.fetchall() + df = pd.DataFrame(data, columns=columns) + + cur.close() + conn.close() + + # Generate file + timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') + filename = f"resource_report_{report_type}_{timestamp}" + + if format_type == 'excel': + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='Report', index=False) + + # Auto-adjust columns width + worksheet = writer.sheets['Report'] + for column in worksheet.columns: + max_length = 0 + column = [cell for cell in column] + for cell in column: + try: + if len(str(cell.value)) > max_length: + max_length = len(str(cell.value)) + except: + pass + adjusted_width = (max_length + 2) + worksheet.column_dimensions[column[0].column_letter].width = adjusted_width + + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx') + + else: # CSV + output = io.StringIO() + df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') + output.seek(0) + + log_audit('EXPORT', 'resource_report', None, + new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, + additional_info=f"Resource Report {report_type} exportiert") + + return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), + mimetype='text/csv', + as_attachment=True, + download_name=f'{filename}.csv') + + # Wenn kein Download, zeige Report-Formular + return render_template('resource_report.html', + datetime=datetime, + timedelta=timedelta, + username=session.get('username')) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/__init__.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/__init__.py new file mode 100644 index 0000000..8ca1225 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/__init__.py @@ -0,0 +1 @@ +# 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 new file mode 100644 index 0000000..fda9c05 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/decorators.py @@ -0,0 +1,44 @@ +from functools import wraps +from flask import session, redirect, url_for, flash, request +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +import logging +from utils.audit import log_audit + +logger = logging.getLogger(__name__) + + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'logged_in' not in session: + return redirect(url_for('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 new file mode 100644 index 0000000..785466f --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/password.py @@ -0,0 +1,11 @@ +import bcrypt + + +def hash_password(password): + """Hash a password using bcrypt""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + +def verify_password(password, hashed): + """Verify a password against its hash""" + return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) \ 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 new file mode 100644 index 0000000..8aca82b --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/rate_limiting.py @@ -0,0 +1,124 @@ +import random +import logging +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from flask import request +from db import execute_query, get_db_connection, get_db_cursor +from config import FAIL_MESSAGES, MAX_LOGIN_ATTEMPTS, BLOCK_DURATION_HOURS, EMAIL_ENABLED +from utils.audit import log_audit +from utils.network import get_client_ip + +logger = logging.getLogger(__name__) + + +def check_ip_blocked(ip_address): + """Check if an IP address is blocked""" + result = execute_query( + """ + SELECT blocked_until FROM login_attempts + WHERE ip_address = %s AND blocked_until IS NOT NULL + """, + (ip_address,), + fetch_one=True + ) + + if result and result[0]: + if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): + return True, result[0] + return False, None + + +def record_failed_attempt(ip_address, username): + """Record a failed login attempt""" + # Random error message + error_message = random.choice(FAIL_MESSAGES) + + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + try: + # Check if IP already exists + cur.execute(""" + SELECT attempt_count FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + + result = cur.fetchone() + + if result: + # Update existing entry + new_count = result[0] + 1 + blocked_until = None + + if new_count >= MAX_LOGIN_ATTEMPTS: + blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) + # Email notification (if enabled) + if EMAIL_ENABLED: + send_security_alert_email(ip_address, username, new_count) + + cur.execute(""" + UPDATE login_attempts + SET attempt_count = %s, + last_attempt = CURRENT_TIMESTAMP, + blocked_until = %s, + last_username_tried = %s, + last_error_message = %s + WHERE ip_address = %s + """, (new_count, blocked_until, username, error_message, ip_address)) + else: + # Create new entry + cur.execute(""" + INSERT INTO login_attempts + (ip_address, attempt_count, last_username_tried, last_error_message) + VALUES (%s, 1, %s, %s) + """, (ip_address, username, error_message)) + + conn.commit() + + # Audit log + log_audit('LOGIN_FAILED', 'user', + additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") + + except Exception as e: + logger.error(f"Rate limiting error: {e}") + conn.rollback() + + return error_message + + +def reset_login_attempts(ip_address): + """Reset login attempts for an IP""" + execute_query( + "DELETE FROM login_attempts WHERE ip_address = %s", + (ip_address,) + ) + + +def get_login_attempts(ip_address): + """Get the number of login attempts for an IP""" + result = execute_query( + "SELECT attempt_count FROM login_attempts WHERE ip_address = %s", + (ip_address,), + fetch_one=True + ) + return result[0] if result else 0 + + +def send_security_alert_email(ip_address, username, attempt_count): + """Send a security alert email""" + subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" + body = f""" + WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! + + IP-Adresse: {ip_address} + Versuchter Benutzername: {username} + Anzahl Versuche: {attempt_count} + Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} + + Die IP-Adresse wurde für 24 Stunden gesperrt. + + Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. + """ + + # TODO: Email sending implementation when SMTP is configured + logger.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") + print(f"E-Mail würde gesendet: {subject}") \ 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 new file mode 100644 index 0000000..474555d --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/auth/two_factor.py @@ -0,0 +1,57 @@ +import pyotp +import qrcode +import random +import string +import hashlib +from io import BytesIO +import base64 + + +def generate_totp_secret(): + """Generate a new TOTP secret""" + return pyotp.random_base32() + + +def generate_qr_code(username, totp_secret): + """Generate QR code for TOTP setup""" + totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( + name=username, + issuer_name='V2 Admin Panel' + ) + + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(totp_uri) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buf = BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + + return base64.b64encode(buf.getvalue()).decode() + + +def verify_totp(totp_secret, token): + """Verify a TOTP token""" + totp = pyotp.TOTP(totp_secret) + return totp.verify(token, valid_window=1) + + +def generate_backup_codes(count=8): + """Generate backup codes for 2FA recovery""" + codes = [] + for _ in range(count): + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + codes.append(code) + return codes + + +def hash_backup_code(code): + """Hash a backup code for storage""" + return hashlib.sha256(code.encode()).hexdigest() + + +def verify_backup_code(code, hashed_codes): + """Verify a backup code against stored hashes""" + code_hash = hashlib.sha256(code.encode()).hexdigest() + return code_hash in hashed_codes \ 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 new file mode 100644 index 0000000..9beeadb --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/config.py @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000..bc84d1a --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/cookies.txt @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 0000000..202f4e8 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/create_users_table.sql @@ -0,0 +1,20 @@ +-- 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 new file mode 100644 index 0000000..be8284e --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/db.py @@ -0,0 +1,84 @@ +import psycopg2 +from psycopg2.extras import Json, RealDictCursor +from contextlib import contextmanager +from config import DATABASE_CONFIG + + +def get_connection(): + """Create and return a new database connection""" + conn = psycopg2.connect(**DATABASE_CONFIG) + conn.set_client_encoding('UTF8') + return conn + + +@contextmanager +def get_db_connection(): + """Context manager for database connections""" + conn = get_connection() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +@contextmanager +def get_db_cursor(conn=None): + """Context manager for database cursors""" + if conn is None: + with get_db_connection() as connection: + cur = connection.cursor() + try: + yield cur + finally: + cur.close() + else: + cur = conn.cursor() + try: + yield cur + finally: + cur.close() + + +@contextmanager +def get_dict_cursor(conn=None): + """Context manager for dictionary cursors""" + if conn is None: + with get_db_connection() as connection: + cur = connection.cursor(cursor_factory=RealDictCursor) + try: + yield cur + finally: + cur.close() + else: + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + yield cur + finally: + cur.close() + + +def execute_query(query, params=None, fetch_one=False, fetch_all=False, as_dict=False): + """Execute a query and optionally fetch results""" + with get_db_connection() as conn: + cursor_func = get_dict_cursor if as_dict else get_db_cursor + with cursor_func(conn) as cur: + cur.execute(query, params) + + if fetch_one: + return cur.fetchone() + elif fetch_all: + return cur.fetchall() + else: + return cur.rowcount + + +def execute_many(query, params_list): + """Execute a query multiple times with different parameters""" + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.executemany(query, params_list) + return cur.rowcount \ 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 new file mode 100644 index 0000000..da6c431 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/fix_license_keys.sql @@ -0,0 +1,13 @@ +-- 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 new file mode 100644 index 0000000..fc91a66 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/init.sql @@ -0,0 +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 $$; 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 new file mode 100644 index 0000000..2377e7a --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/mark_resources_as_test.sql @@ -0,0 +1,5 @@ +-- 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 new file mode 100644 index 0000000..d62291f --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_device_limit.sql @@ -0,0 +1,13 @@ +-- 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 new file mode 100644 index 0000000..6ff91e6 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_license_keys.sql @@ -0,0 +1,54 @@ +-- 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 new file mode 100644 index 0000000..106833d --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/migrate_users.py @@ -0,0 +1,78 @@ +#!/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 new file mode 100644 index 0000000..4c3bb1c --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/models.py @@ -0,0 +1,29 @@ +# 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 new file mode 100644 index 0000000..648aab0 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/remove_duplicate_routes.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Remove duplicate routes that have been moved to blueprints +""" + +import re + +# Read the current app.py +with open('app.py', 'r') as f: + content = f.read() + +# List of function names that have been moved to blueprints +moved_functions = [ + # Auth routes + 'login', + 'logout', + 'verify_2fa', + 'profile', + 'change_password', + 'setup_2fa', + 'enable_2fa', + 'disable_2fa', + 'heartbeat', + # Admin routes + 'dashboard', + 'audit_log', + 'backups', + 'create_backup_route', + 'restore_backup_route', + 'download_backup', + 'delete_backup', + 'blocked_ips', + 'unblock_ip', + 'clear_attempts' +] + +# Create a pattern to match route decorators and their functions +for func_name in moved_functions: + # Pattern to match from @app.route to the end of the function + pattern = rf'@app\.route\([^)]+\)\s*(?:@login_required\s*)?def {func_name}\([^)]*\):.*?(?=\n@app\.route|\n@[a-zA-Z]|\nif __name__|$)' + + # Replace with a comment + replacement = f'# Function {func_name} moved to blueprint' + + content = re.sub(pattern, replacement, content, flags=re.DOTALL) + +# Write the modified content +with open('app_no_duplicates.py', 'w') as f: + f.write(content) + +print("Created app_no_duplicates.py with duplicate routes removed") +print("Please review the file before using it") \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/requirements.txt b/backups/refactoring_20250616_223724/v2_adminpanel_backup/requirements.txt new file mode 100644 index 0000000..f1fcc95 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/requirements.txt @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..4f9ede3 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/__init__.py @@ -0,0 +1,2 @@ +# Routes module initialization +# This module contains all Flask blueprints organized by functionality \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/admin_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/admin_routes.py new file mode 100644 index 0000000..cf3528f --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/admin_routes.py @@ -0,0 +1,540 @@ +import os +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path +from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file, jsonify + +import config +from auth.decorators import login_required +from utils.audit import log_audit +from utils.backup import create_backup, restore_backup +from utils.network import get_client_ip +from db import get_connection, get_db_connection, get_db_cursor, execute_query +from utils.export import create_excel_export, prepare_audit_export_data + +# Create Blueprint +admin_bp = Blueprint('admin', __name__) + + +@admin_bp.route("/") +@login_required +def dashboard(): + conn = get_connection() + cur = conn.cursor() + + try: + # Hole Statistiken + # Anzahl aktiver Lizenzen + cur.execute("SELECT COUNT(*) FROM licenses WHERE active = true") + active_licenses = cur.fetchone()[0] + + # Anzahl Kunden + cur.execute("SELECT COUNT(*) FROM customers") + total_customers = cur.fetchone()[0] + + # Anzahl aktiver Sessions + cur.execute("SELECT COUNT(*) FROM sessions WHERE active = true") + active_sessions = cur.fetchone()[0] + + # Top 10 Lizenzen nach Nutzung (letzte 30 Tage) + cur.execute(""" + SELECT + l.license_key, + c.name as customer_name, + COUNT(DISTINCT s.id) as session_count, + COUNT(DISTINCT s.username) as unique_users, + MAX(s.last_activity) as last_activity + FROM licenses l + LEFT JOIN customers c ON l.customer_id = c.id + LEFT JOIN sessions s ON l.license_key = s.license_key + AND s.login_time >= CURRENT_TIMESTAMP - INTERVAL '30 days' + GROUP BY l.license_key, c.name + ORDER BY session_count DESC + LIMIT 10 + """) + top_licenses = cur.fetchall() + + # Letzte 10 Aktivitäten aus dem Audit Log + cur.execute(""" + SELECT + id, + timestamp AT TIME ZONE 'Europe/Berlin' as timestamp, + username, + action, + entity_type, + entity_id, + additional_info + FROM audit_log + ORDER BY timestamp DESC + LIMIT 10 + """) + recent_activities = cur.fetchall() + + # Lizenztyp-Verteilung + cur.execute(""" + SELECT + CASE + WHEN is_test_license THEN 'Test' + ELSE 'Full' + END as license_type, + COUNT(*) as count + FROM licenses + GROUP BY is_test_license + """) + license_distribution = cur.fetchall() + + # Sessions nach Stunden (letzte 24h) + cur.execute(""" + WITH hours AS ( + SELECT generate_series( + CURRENT_TIMESTAMP - INTERVAL '23 hours', + CURRENT_TIMESTAMP, + INTERVAL '1 hour' + ) AS hour + ) + SELECT + TO_CHAR(hours.hour AT TIME ZONE 'Europe/Berlin', 'HH24:00') as hour_label, + COUNT(DISTINCT s.id) as session_count + FROM hours + LEFT JOIN sessions s ON + s.login_time >= hours.hour AND + s.login_time < hours.hour + INTERVAL '1 hour' + GROUP BY hours.hour + ORDER BY hours.hour + """) + hourly_sessions = cur.fetchall() + + # System-Status + cur.execute("SELECT pg_database_size(current_database())") + db_size = cur.fetchone()[0] + + # Letzte Backup-Info + cur.execute(""" + SELECT filename, created_at, filesize, status + FROM backup_history + WHERE status = 'success' + ORDER BY created_at DESC + LIMIT 1 + """) + last_backup = cur.fetchone() + + # Resource Statistiken + cur.execute(""" + SELECT + COUNT(*) FILTER (WHERE status = 'available') as available, + COUNT(*) FILTER (WHERE status = 'in_use') as in_use, + COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, + COUNT(*) as total + FROM resources + """) + resource_stats = cur.fetchone() + + return render_template('dashboard.html', + active_licenses=active_licenses, + total_customers=total_customers, + active_sessions=active_sessions, + top_licenses=top_licenses, + recent_activities=recent_activities, + license_distribution=license_distribution, + hourly_sessions=hourly_sessions, + db_size=db_size, + last_backup=last_backup, + resource_stats=resource_stats, + username=session.get('username')) + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/audit") +@login_required +def audit_log(): + page = request.args.get('page', 1, type=int) + per_page = 50 + search = request.args.get('search', '') + action_filter = request.args.get('action', '') + entity_filter = request.args.get('entity', '') + + conn = get_connection() + cur = conn.cursor() + + try: + # Base query + query = """ + SELECT + id, + timestamp AT TIME ZONE 'Europe/Berlin' as timestamp, + username, + action, + entity_type, + entity_id, + old_values::text, + new_values::text, + ip_address, + user_agent, + additional_info + FROM audit_log + WHERE 1=1 + """ + params = [] + + # Suchfilter + if search: + query += """ AND ( + username ILIKE %s OR + action ILIKE %s OR + entity_type ILIKE %s OR + additional_info ILIKE %s OR + ip_address ILIKE %s + )""" + search_param = f"%{search}%" + params.extend([search_param] * 5) + + # Action Filter + if action_filter: + query += " AND action = %s" + params.append(action_filter) + + # Entity Filter + if entity_filter: + query += " AND entity_type = %s" + params.append(entity_filter) + + # Count total + count_query = f"SELECT COUNT(*) FROM ({query}) as filtered" + cur.execute(count_query, params) + total_count = cur.fetchone()[0] + + # Add pagination + query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s" + params.extend([per_page, (page - 1) * per_page]) + + cur.execute(query, params) + logs = cur.fetchall() + + # Get unique actions and entities for filters + cur.execute("SELECT DISTINCT action FROM audit_log ORDER BY action") + actions = [row[0] for row in cur.fetchall()] + + cur.execute("SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type") + entities = [row[0] for row in cur.fetchall()] + + # Pagination info + total_pages = (total_count + per_page - 1) // per_page + + # Convert to dictionaries for easier template access + audit_logs = [] + for log in logs: + audit_logs.append({ + 'id': log[0], + 'timestamp': log[1], + 'username': log[2], + 'action': log[3], + 'entity_type': log[4], + 'entity_id': log[5], + 'old_values': log[6], + 'new_values': log[7], + 'ip_address': log[8], + 'user_agent': log[9], + 'additional_info': log[10] + }) + + return render_template('audit_log.html', + logs=audit_logs, + page=page, + total_pages=total_pages, + total_count=total_count, + search=search, + action_filter=action_filter, + entity_filter=entity_filter, + actions=actions, + entities=entities, + username=session.get('username')) + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/backups") +@login_required +def backups(): + conn = get_connection() + cur = conn.cursor() + + try: + # Hole alle Backups + cur.execute(""" + SELECT + id, + filename, + created_at AT TIME ZONE 'Europe/Berlin' as created_at, + filesize, + backup_type, + status, + created_by, + duration_seconds, + tables_count, + records_count, + error_message, + is_encrypted + FROM backup_history + ORDER BY created_at DESC + """) + backups = cur.fetchall() + + # Prüfe ob Dateien noch existieren + backups_with_status = [] + for backup in backups: + backup_dict = { + 'id': backup[0], + 'filename': backup[1], + 'created_at': backup[2], + 'filesize': backup[3], + 'backup_type': backup[4], + 'status': backup[5], + 'created_by': backup[6], + 'duration_seconds': backup[7], + 'tables_count': backup[8], + 'records_count': backup[9], + 'error_message': backup[10], + 'is_encrypted': backup[11], + 'file_exists': False + } + + # Prüfe ob Datei existiert + if backup[1]: # filename + filepath = config.BACKUP_DIR / backup[1] + backup_dict['file_exists'] = filepath.exists() + + backups_with_status.append(backup_dict) + + return render_template('backups.html', + backups=backups_with_status, + username=session.get('username')) + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/backup/create", methods=["POST"]) +@login_required +def create_backup_route(): + """Manuelles Backup erstellen""" + success, result = create_backup(backup_type="manual", created_by=session.get('username')) + + if success: + flash(f'Backup erfolgreich erstellt: {result}', 'success') + else: + flash(f'Backup fehlgeschlagen: {result}', 'error') + + return redirect(url_for('admin.backups')) + + +@admin_bp.route("/backup/restore/", methods=["POST"]) +@login_required +def restore_backup_route(backup_id): + """Backup wiederherstellen""" + encryption_key = request.form.get('encryption_key') + + success, message = restore_backup(backup_id, encryption_key) + + if success: + flash(message, 'success') + else: + flash(f'Wiederherstellung fehlgeschlagen: {message}', 'error') + + return redirect(url_for('admin.backups')) + + +@admin_bp.route("/backup/download/") +@login_required +def download_backup(backup_id): + """Backup herunterladen""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole Backup-Info + cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,)) + result = cur.fetchone() + + if not result: + flash('Backup nicht gefunden', 'error') + return redirect(url_for('admin.backups')) + + filename, filepath = result + filepath = Path(filepath) + + if not filepath.exists(): + flash('Backup-Datei nicht gefunden', 'error') + return redirect(url_for('admin.backups')) + + # Audit-Log + log_audit('BACKUP_DOWNLOAD', 'backup', backup_id, + additional_info=f"Backup heruntergeladen: {filename}") + + return send_file(filepath, as_attachment=True, download_name=filename) + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/backup/delete/", methods=["DELETE"]) +@login_required +def delete_backup(backup_id): + """Backup löschen""" + conn = get_connection() + cur = conn.cursor() + + try: + # Hole Backup-Info + cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,)) + result = cur.fetchone() + + if not result: + return jsonify({'success': False, 'message': 'Backup nicht gefunden'}), 404 + + filename, filepath = result + filepath = Path(filepath) + + # Lösche Datei wenn vorhanden + if filepath.exists(): + try: + filepath.unlink() + except Exception as e: + return jsonify({'success': False, 'message': f'Fehler beim Löschen der Datei: {str(e)}'}), 500 + + # Lösche Datenbank-Eintrag + cur.execute("DELETE FROM backup_history WHERE id = %s", (backup_id,)) + conn.commit() + + # Audit-Log + log_audit('BACKUP_DELETE', 'backup', backup_id, + additional_info=f"Backup gelöscht: {filename}") + + return jsonify({'success': True, 'message': 'Backup erfolgreich gelöscht'}) + + except Exception as e: + conn.rollback() + return jsonify({'success': False, 'message': str(e)}), 500 + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/security/blocked-ips") +@login_required +def blocked_ips(): + """Zeigt gesperrte IP-Adressen""" + conn = get_connection() + cur = conn.cursor() + + try: + cur.execute(""" + SELECT + ip_address, + attempt_count, + last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt, + blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP + ORDER BY blocked_until DESC + """) + blocked = cur.fetchall() + + # Alle Login-Versuche (auch nicht gesperrte) + cur.execute(""" + SELECT + ip_address, + attempt_count, + last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt, + blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until, + last_username_tried, + last_error_message + FROM login_attempts + ORDER BY last_attempt DESC + LIMIT 100 + """) + all_attempts = cur.fetchall() + + return render_template('blocked_ips.html', + blocked_ips=blocked, + all_attempts=all_attempts, + username=session.get('username')) + + finally: + cur.close() + conn.close() + + +@admin_bp.route("/security/unblock-ip", methods=["POST"]) +@login_required +def unblock_ip(): + """Entsperrt eine IP-Adresse""" + ip_address = request.form.get('ip_address') + + if not ip_address: + flash('Keine IP-Adresse angegeben', 'error') + return redirect(url_for('admin.blocked_ips')) + + conn = get_connection() + cur = conn.cursor() + + try: + cur.execute(""" + UPDATE login_attempts + SET blocked_until = NULL + WHERE ip_address = %s + """, (ip_address,)) + + if cur.rowcount > 0: + conn.commit() + flash(f'IP-Adresse {ip_address} wurde entsperrt', 'success') + log_audit('UNBLOCK_IP', 'security', + additional_info=f"IP-Adresse entsperrt: {ip_address}") + else: + flash(f'IP-Adresse {ip_address} nicht gefunden', 'warning') + + except Exception as e: + conn.rollback() + flash(f'Fehler beim Entsperren: {str(e)}', 'error') + + finally: + cur.close() + conn.close() + + return redirect(url_for('admin.blocked_ips')) + + +@admin_bp.route("/security/clear-attempts", methods=["POST"]) +@login_required +def clear_attempts(): + """Löscht alle Login-Versuche""" + conn = get_connection() + cur = conn.cursor() + + try: + cur.execute("DELETE FROM login_attempts") + count = cur.rowcount + conn.commit() + + flash(f'{count} Login-Versuche wurden gelöscht', 'success') + log_audit('CLEAR_LOGIN_ATTEMPTS', 'security', + additional_info=f"{count} Login-Versuche gelöscht") + + except Exception as e: + conn.rollback() + flash(f'Fehler beim Löschen: {str(e)}', 'error') + + finally: + cur.close() + conn.close() + + return redirect(url_for('admin.blocked_ips')) \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/api_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/api_routes.py new file mode 100644 index 0000000..0964a90 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/api_routes.py @@ -0,0 +1,906 @@ +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 new file mode 100644 index 0000000..69a5c7d --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/auth_routes.py @@ -0,0 +1,377 @@ +import time +import json +from datetime import datetime +from zoneinfo import ZoneInfo +from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify + +import config +from auth.decorators import login_required +from auth.password import hash_password, verify_password +from auth.two_factor import ( + generate_totp_secret, generate_qr_code, verify_totp, + generate_backup_codes, hash_backup_code, verify_backup_code +) +from auth.rate_limiting import ( + check_ip_blocked, record_failed_attempt, + reset_login_attempts, get_login_attempts +) +from utils.network import get_client_ip +from utils.audit import log_audit +from models import get_user_by_username +from db import get_db_connection, get_db_cursor +from utils.recaptcha import verify_recaptcha + +# Create Blueprint +auth_bp = Blueprint('auth', __name__) + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + # Timing-Attack Schutz - Start Zeit merken + start_time = time.time() + + # IP-Adresse ermitteln + ip_address = get_client_ip() + + # Prüfen ob IP gesperrt ist + is_blocked, blocked_until = check_ip_blocked(ip_address) + if is_blocked: + time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + return render_template("login.html", error=error_msg, error_type="blocked") + + # Anzahl bisheriger Versuche + attempt_count = get_login_attempts(ip_address) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + captcha_response = request.form.get("g-recaptcha-response") + + # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind + recaptcha_site_key = config.RECAPTCHA_SITE_KEY + if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + if not captcha_response: + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA ERFORDERLICH!", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # CAPTCHA validieren + if not verify_recaptcha(captcha_response): + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # Check user in database first, fallback to env vars + user = get_user_by_username(username) + login_success = False + needs_2fa = False + + if user: + # Database user authentication + if verify_password(password, user['password_hash']): + login_success = True + needs_2fa = user['totp_enabled'] + else: + # Fallback to environment variables for backward compatibility + if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: + login_success = True + + # Timing-Attack Schutz - Mindestens 1 Sekunde warten + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + if login_success: + # Erfolgreicher Login + if needs_2fa: + # Store temporary session for 2FA verification + session['temp_username'] = username + session['temp_user_id'] = user['id'] + session['awaiting_2fa'] = True + return redirect(url_for('auth.verify_2fa')) + else: + # Complete login without 2FA + session.permanent = True # Aktiviert das Timeout + session['logged_in'] = True + session['username'] = username + session['user_id'] = user['id'] if user else None + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + reset_login_attempts(ip_address) + log_audit('LOGIN_SUCCESS', 'user', + additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + return redirect(url_for('admin.dashboard')) + else: + # Fehlgeschlagener Login + error_message = record_failed_attempt(ip_address, username) + new_attempt_count = get_login_attempts(ip_address) + + # Prüfen ob jetzt gesperrt + is_now_blocked, _ = check_ip_blocked(ip_address) + if is_now_blocked: + log_audit('LOGIN_BLOCKED', 'security', + additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + + return render_template("login.html", + error=error_message, + show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + error_type="failed", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + + # GET Request + return render_template("login.html", + show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + + +@auth_bp.route("/logout") +def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + session.pop('logged_in', None) + session.pop('username', None) + session.pop('user_id', None) + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + return redirect(url_for('auth.login')) + + +@auth_bp.route("/verify-2fa", methods=["GET", "POST"]) +def verify_2fa(): + if not session.get('awaiting_2fa'): + return redirect(url_for('auth.login')) + + if request.method == "POST": + token = request.form.get('token', '').replace(' ', '') + username = session.get('temp_username') + user_id = session.get('temp_user_id') + + if not username or not user_id: + flash('Session expired. Please login again.', 'error') + return redirect(url_for('auth.login')) + + user = get_user_by_username(username) + if not user: + flash('User not found.', 'error') + return redirect(url_for('auth.login')) + + # Check if it's a backup code + if len(token) == 8 and token.isupper(): + # Try backup code + backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + if verify_backup_code(token, backup_codes): + # Remove used backup code + code_hash = hash_backup_code(token) + backup_codes.remove(code_hash) + + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + (json.dumps(backup_codes), user_id)) + + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + flash('Login successful using backup code. Please generate new backup codes.', 'warning') + log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + return redirect(url_for('admin.dashboard')) + else: + # Try TOTP token + if verify_totp(user['totp_secret'], token): + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + return redirect(url_for('admin.dashboard')) + + # Failed verification + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + (datetime.now(), user_id)) + + flash('Invalid authentication code. Please try again.', 'error') + log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + return render_template('verify_2fa.html') + + +@auth_bp.route("/profile") +@login_required +def profile(): + user = get_user_by_username(session['username']) + if not user: + # For environment-based users, redirect with message + flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') + return redirect(url_for('admin.dashboard')) + return render_template('profile.html', user=user) + + +@auth_bp.route("/profile/change-password", methods=["POST"]) +@login_required +def change_password(): + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + user = get_user_by_username(session['username']) + + # Verify current password + if not verify_password(current_password, user['password_hash']): + flash('Current password is incorrect.', 'error') + return redirect(url_for('auth.profile')) + + # Check new password + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return redirect(url_for('auth.profile')) + + if len(new_password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return redirect(url_for('auth.profile')) + + # Update password + new_hash = hash_password(new_password) + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", + (new_hash, datetime.now(), user['id'])) + + log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], + additional_info="Password changed successfully") + flash('Password changed successfully.', 'success') + return redirect(url_for('auth.profile')) + + +@auth_bp.route("/profile/setup-2fa") +@login_required +def setup_2fa(): + user = get_user_by_username(session['username']) + + if user['totp_enabled']: + flash('2FA is already enabled for your account.', 'info') + return redirect(url_for('auth.profile')) + + # Generate new TOTP secret + totp_secret = generate_totp_secret() + session['temp_totp_secret'] = totp_secret + + # Generate QR code + qr_code = generate_qr_code(user['username'], totp_secret) + + return render_template('setup_2fa.html', + totp_secret=totp_secret, + qr_code=qr_code) + + +@auth_bp.route("/profile/enable-2fa", methods=["POST"]) +@login_required +def enable_2fa(): + token = request.form.get('token', '').replace(' ', '') + totp_secret = session.get('temp_totp_secret') + + if not totp_secret: + flash('2FA setup session expired. Please try again.', 'error') + return redirect(url_for('auth.setup_2fa')) + + # Verify the token + if not verify_totp(totp_secret, token): + flash('Invalid authentication code. Please try again.', 'error') + return redirect(url_for('auth.setup_2fa')) + + # Generate backup codes + backup_codes = generate_backup_codes() + backup_codes_hashed = [hash_backup_code(code) for code in backup_codes] + + # Enable 2FA for user + user = get_user_by_username(session['username']) + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute(""" + UPDATE users + SET totp_secret = %s, totp_enabled = true, backup_codes = %s + WHERE id = %s + """, (totp_secret, json.dumps(backup_codes_hashed), user['id'])) + + # Clear temp secret + session.pop('temp_totp_secret', None) + + log_audit('2FA_ENABLED', 'user', entity_id=user['id'], + additional_info="2FA successfully enabled") + + # Show backup codes + return render_template('backup_codes.html', backup_codes=backup_codes) + + +@auth_bp.route("/profile/disable-2fa", methods=["POST"]) +@login_required +def disable_2fa(): + password = request.form.get('password') + + user = get_user_by_username(session['username']) + + # Verify password + if not verify_password(password, user['password_hash']): + flash('Incorrect password. 2FA was not disabled.', 'error') + return redirect(url_for('auth.profile')) + + # Disable 2FA + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute(""" + UPDATE users + SET totp_enabled = false, totp_secret = NULL, backup_codes = NULL + WHERE id = %s + """, (user['id'],)) + + log_audit('2FA_DISABLED', 'user', entity_id=user['id'], + additional_info="2FA disabled by user") + flash('2FA has been disabled for your account.', 'success') + return redirect(url_for('auth.profile')) + + +@auth_bp.route("/heartbeat", methods=['POST']) +@login_required +def heartbeat(): + """Endpoint für Session Keep-Alive - aktualisiert last_activity""" + # Aktualisiere last_activity nur wenn explizit angefordert + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # Force session save + session.modified = True + + return jsonify({ + 'status': 'ok', + 'last_activity': session['last_activity'], + 'username': session.get('username') + }) \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/batch_routes.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/batch_routes.py new file mode 100644 index 0000000..15ec50e --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/batch_routes.py @@ -0,0 +1,377 @@ +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 new file mode 100644 index 0000000..2f84c22 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/customer_routes.py @@ -0,0 +1,338 @@ +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 new file mode 100644 index 0000000..bd184d0 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/export_routes.py @@ -0,0 +1,364 @@ +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 new file mode 100644 index 0000000..1139e26 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/license_routes.py @@ -0,0 +1,374 @@ +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 new file mode 100644 index 0000000..fe2dc0b --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/resource_routes.py @@ -0,0 +1,617 @@ +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 new file mode 100644 index 0000000..b7135f2 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/routes/session_routes.py @@ -0,0 +1,388 @@ +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 new file mode 100644 index 0000000..66cafcc --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/add_resources.html @@ -0,0 +1,439 @@ +{% 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 new file mode 100644 index 0000000..cfcd996 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/audit_log.html @@ -0,0 +1,318 @@ +{% 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 new file mode 100644 index 0000000..f62c4db --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/backup_codes.html @@ -0,0 +1,228 @@ +{% 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 new file mode 100644 index 0000000..0211ecd --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/backups.html @@ -0,0 +1,301 @@ +{% 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 new file mode 100644 index 0000000..af1bba6 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/base.html @@ -0,0 +1,679 @@ + + + + + + {% 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 new file mode 100644 index 0000000..0a5901e --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/batch_form.html @@ -0,0 +1,464 @@ +{% 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 new file mode 100644 index 0000000..9ebf0dd --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/batch_result.html @@ -0,0 +1,156 @@ +{% 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 new file mode 100644 index 0000000..f0d80c6 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/blocked_ips.html @@ -0,0 +1,98 @@ +{% 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 new file mode 100644 index 0000000..55518fc --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/create_customer.html @@ -0,0 +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 %} +{% 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 new file mode 100644 index 0000000..684d0af --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers.html @@ -0,0 +1,176 @@ +{% 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 new file mode 100644 index 0000000..ff92edd --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers_licenses.html @@ -0,0 +1,1219 @@ +{% 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 new file mode 100644 index 0000000..40ec906 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/customers_licenses_old.html @@ -0,0 +1,488 @@ +{% 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 new file mode 100644 index 0000000..e445290 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/dashboard.html @@ -0,0 +1,433 @@ +{% 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 new file mode 100644 index 0000000..ec7f744 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/edit_customer.html @@ -0,0 +1,103 @@ +{% 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 new file mode 100644 index 0000000..fce45eb --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/edit_license.html @@ -0,0 +1,84 @@ +{% 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 new file mode 100644 index 0000000..a14329a --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/index.html @@ -0,0 +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 %} diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/licenses.html b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/licenses.html new file mode 100644 index 0000000..eb397af --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/licenses.html @@ -0,0 +1,375 @@ +{% 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 new file mode 100644 index 0000000..f98cd96 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/login.html @@ -0,0 +1,125 @@ + + + + + + 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 new file mode 100644 index 0000000..3448e6b --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/profile.html @@ -0,0 +1,216 @@ +{% 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 new file mode 100644 index 0000000..9174760 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_history.html @@ -0,0 +1,365 @@ +{% 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 new file mode 100644 index 0000000..d34205a --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_metrics.html @@ -0,0 +1,559 @@ +{% 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 new file mode 100644 index 0000000..23d86f6 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resource_report.html @@ -0,0 +1,212 @@ +{% 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 new file mode 100644 index 0000000..a55cc01 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/resources.html @@ -0,0 +1,898 @@ +{% 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 new file mode 100644 index 0000000..0c85100 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/sessions.html @@ -0,0 +1,183 @@ +{% 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 new file mode 100644 index 0000000..f30af6b --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/setup_2fa.html @@ -0,0 +1,210 @@ +{% 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 new file mode 100644 index 0000000..6ab8f70 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/templates/verify_2fa.html @@ -0,0 +1,131 @@ + + + + + + 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 new file mode 100644 index 0000000..fcca661 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/test_blueprint_routes.py @@ -0,0 +1,188 @@ +#!/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 new file mode 100644 index 0000000..30710b4 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/test_blueprints.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Test if blueprints can be imported successfully""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +try: + from routes.auth_routes import auth_bp + print("✓ auth_routes blueprint imported successfully") + print(f" Routes: {[str(r) for r in auth_bp.url_values_defaults]}") +except Exception as e: + print(f"✗ Error importing auth_routes: {e}") + +try: + from routes.admin_routes import admin_bp + print("✓ admin_routes blueprint imported successfully") +except Exception as e: + print(f"✗ Error importing admin_routes: {e}") + +print("\nBlueprints are ready to use!") \ No newline at end of file diff --git a/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/__init__.py b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/__init__.py new file mode 100644 index 0000000..921d9bb --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/__init__.py @@ -0,0 +1 @@ +# 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 new file mode 100644 index 0000000..b480547 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/audit.py @@ -0,0 +1,37 @@ +import logging +from flask import session, request +from psycopg2.extras import Json +from db import get_db_connection, get_db_cursor +from utils.network import get_client_ip + +logger = logging.getLogger(__name__) + + +def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): + """Log changes to the audit log""" + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + try: + username = session.get('username', 'system') + ip_address = get_client_ip() if request else None + user_agent = request.headers.get('User-Agent') if request else None + + # Debug logging + logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") + + # Convert dictionaries to JSONB + old_json = Json(old_values) if old_values else None + new_json = Json(new_values) if new_values else None + + cur.execute(""" + INSERT INTO audit_log + (username, action, entity_type, entity_id, old_values, new_values, + ip_address, user_agent, additional_info) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (username, action, entity_type, entity_id, old_json, new_json, + ip_address, user_agent, 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 new file mode 100644 index 0000000..def2648 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/backup.py @@ -0,0 +1,223 @@ +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 new file mode 100644 index 0000000..0ccbd31 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/export.py @@ -0,0 +1,127 @@ +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 new file mode 100644 index 0000000..6c5cb7d --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/license.py @@ -0,0 +1,50 @@ +import re +import secrets +from datetime import datetime +from zoneinfo import ZoneInfo + + +def generate_license_key(license_type='full'): + """ + Generate a license key in format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ + + AF = Account Factory (Product identifier) + F/T = F for Full version, T for Test version + YYYY = Year + MM = Month + XXXX-YYYY-ZZZZ = Random alphanumeric characters + """ + # Allowed characters (without confusing ones like 0/O, 1/I/l) + chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + + # Date part + now = datetime.now(ZoneInfo("Europe/Berlin")) + date_part = now.strftime('%Y%m') + type_char = 'F' if license_type == 'full' else 'T' + + # Generate random parts (3 blocks of 4 characters) + parts = [] + for _ in range(3): + part = ''.join(secrets.choice(chars) for _ in range(4)) + parts.append(part) + + # Assemble key + key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" + + return key + + +def validate_license_key(key): + """ + Validate the License Key Format + Expected: AF-F-YYYYMM-XXXX-YYYY-ZZZZ or AF-T-YYYYMM-XXXX-YYYY-ZZZZ + """ + if not key: + return False + + # Pattern for the new format + # AF- (fixed) + F or T + - + 6 digits (YYYYMM) + - + 4 characters + - + 4 characters + - + 4 characters + pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + + # Uppercase for comparison + return bool(re.match(pattern, key.upper())) \ 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 new file mode 100644 index 0000000..3714331 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/network.py @@ -0,0 +1,23 @@ +import logging +from flask import request + +logger = logging.getLogger(__name__) + + +def get_client_ip(): + """Get the real IP address of the client""" + # Debug logging + logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, " + f"X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, " + f"Remote-Addr: {request.remote_addr}") + + # Try X-Real-IP first (set by nginx) + if request.headers.get('X-Real-IP'): + return request.headers.get('X-Real-IP') + # Then X-Forwarded-For + elif request.headers.get('X-Forwarded-For'): + # X-Forwarded-For can contain multiple IPs, take the first one + return request.headers.get('X-Forwarded-For').split(',')[0].strip() + # Fallback to remote_addr + else: + return request.remote_addr \ 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 new file mode 100644 index 0000000..344b545 --- /dev/null +++ b/backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/recaptcha.py @@ -0,0 +1,39 @@ +import logging +import requests +import config + + +def verify_recaptcha(response): + """Verifiziert die reCAPTCHA v2 Response mit Google""" + secret_key = config.RECAPTCHA_SECRET_KEY + + # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) + if not secret_key: + logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") + return True + + # Verifizierung bei Google + try: + verify_url = 'https://www.google.com/recaptcha/api/siteverify' + data = { + 'secret': secret_key, + 'response': response + } + + # Timeout für Request setzen + r = requests.post(verify_url, data=data, timeout=5) + result = r.json() + + # Log für Debugging + if not result.get('success'): + logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") + + return result.get('success', False) + + except requests.exceptions.RequestException as e: + logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") + # Bei Netzwerkfehlern CAPTCHA als bestanden werten + return True + except Exception as e: + logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") + return False \ No newline at end of file diff --git a/cloud-init.yaml b/cloud-init.yaml new file mode 100644 index 0000000..f9d3385 --- /dev/null +++ b/cloud-init.yaml @@ -0,0 +1,255 @@ +#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 new file mode 100644 index 0000000..5259dc8 --- /dev/null +++ b/generate-secrets.py @@ -0,0 +1,35 @@ +#!/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 new file mode 100644 index 0000000..faf20c2 --- /dev/null +++ b/lizenzserver/.env.example @@ -0,0 +1,30 @@ +# 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 new file mode 100644 index 0000000..380ea0b --- /dev/null +++ b/lizenzserver/API_DOCUMENTATION.md @@ -0,0 +1,561 @@ +# 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 new file mode 100644 index 0000000..7745445 --- /dev/null +++ b/lizenzserver/Dockerfile.admin @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..10c99c1 --- /dev/null +++ b/lizenzserver/Dockerfile.analytics @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..79dd22d --- /dev/null +++ b/lizenzserver/Dockerfile.auth @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..3cf555b --- /dev/null +++ b/lizenzserver/Dockerfile.license @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..1976dec --- /dev/null +++ b/lizenzserver/Makefile @@ -0,0 +1,86 @@ +.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 new file mode 100644 index 0000000..59dda9d --- /dev/null +++ b/lizenzserver/README.md @@ -0,0 +1,244 @@ +# 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 new file mode 100644 index 0000000..ef90505 --- /dev/null +++ b/lizenzserver/config.py @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000..8abf7d7 --- /dev/null +++ b/lizenzserver/docker-compose.yaml @@ -0,0 +1,123 @@ +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 new file mode 100644 index 0000000..e774469 --- /dev/null +++ b/lizenzserver/docker-compose.yml @@ -0,0 +1,191 @@ +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 new file mode 100644 index 0000000..001c7e9 --- /dev/null +++ b/lizenzserver/events/__init__.py @@ -0,0 +1 @@ +# Events Module \ No newline at end of file diff --git a/lizenzserver/events/event_bus.py b/lizenzserver/events/event_bus.py new file mode 100644 index 0000000..c0f391b --- /dev/null +++ b/lizenzserver/events/event_bus.py @@ -0,0 +1,191 @@ +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 new file mode 100644 index 0000000..75ac4aa --- /dev/null +++ b/lizenzserver/init.sql @@ -0,0 +1,177 @@ +-- 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 new file mode 100644 index 0000000..b62ab17 --- /dev/null +++ b/lizenzserver/middleware/__init__.py @@ -0,0 +1 @@ +# Middleware Module \ No newline at end of file diff --git a/lizenzserver/middleware/rate_limiter.py b/lizenzserver/middleware/rate_limiter.py new file mode 100644 index 0000000..59bdd9c --- /dev/null +++ b/lizenzserver/middleware/rate_limiter.py @@ -0,0 +1,158 @@ +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 new file mode 100644 index 0000000..e3541fe --- /dev/null +++ b/lizenzserver/models/__init__.py @@ -0,0 +1,127 @@ +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 new file mode 100644 index 0000000..353ef0c --- /dev/null +++ b/lizenzserver/nginx.conf @@ -0,0 +1,167 @@ +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 new file mode 100644 index 0000000..fce3c38 --- /dev/null +++ b/lizenzserver/repositories/base.py @@ -0,0 +1,94 @@ +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 new file mode 100644 index 0000000..83fc3f8 --- /dev/null +++ b/lizenzserver/repositories/cache_repo.py @@ -0,0 +1,178 @@ +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 new file mode 100644 index 0000000..938f262 --- /dev/null +++ b/lizenzserver/repositories/license_repo.py @@ -0,0 +1,228 @@ +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 new file mode 100644 index 0000000..13136ad --- /dev/null +++ b/lizenzserver/requirements.txt @@ -0,0 +1,31 @@ +# 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 new file mode 100644 index 0000000..68928c2 --- /dev/null +++ b/lizenzserver/services/admin_api/__init__.py @@ -0,0 +1 @@ +# 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 new file mode 100644 index 0000000..f803fda --- /dev/null +++ b/lizenzserver/services/admin_api/app.py @@ -0,0 +1,666 @@ +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 new file mode 100644 index 0000000..5c63479 --- /dev/null +++ b/lizenzserver/services/analytics/__init__.py @@ -0,0 +1 @@ +# Analytics Service \ No newline at end of file diff --git a/lizenzserver/services/analytics/app.py b/lizenzserver/services/analytics/app.py new file mode 100644 index 0000000..f1d9561 --- /dev/null +++ b/lizenzserver/services/analytics/app.py @@ -0,0 +1,478 @@ +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 new file mode 100644 index 0000000..6390647 --- /dev/null +++ b/lizenzserver/services/auth/Dockerfile @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..b560c05 --- /dev/null +++ b/lizenzserver/services/auth/app.py @@ -0,0 +1,279 @@ +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 new file mode 100644 index 0000000..60da21f --- /dev/null +++ b/lizenzserver/services/auth/config.py @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..1c13f39 --- /dev/null +++ b/lizenzserver/services/auth/requirements.txt @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..d8e624d --- /dev/null +++ b/lizenzserver/services/license_api/Dockerfile @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..14d55a5 --- /dev/null +++ b/lizenzserver/services/license_api/app.py @@ -0,0 +1,409 @@ +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 new file mode 100644 index 0000000..7f36e3a --- /dev/null +++ b/lizenzserver/services/license_api/requirements.txt @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..4b67ddc --- /dev/null +++ b/scripts/reset-to-dhcp.ps1 @@ -0,0 +1,38 @@ +# 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 new file mode 100644 index 0000000..a8c2758 --- /dev/null +++ b/scripts/set-static-ip.ps1 @@ -0,0 +1,54 @@ +# 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 new file mode 100644 index 0000000..1b0e9f6 --- /dev/null +++ b/scripts/setup-firewall.ps1 @@ -0,0 +1,16 @@ +# 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 new file mode 100644 index 0000000..616e37c --- /dev/null +++ b/v2/.env @@ -0,0 +1,70 @@ +# 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 new file mode 100644 index 0000000..d8f0056 --- /dev/null +++ b/v2/.env.production.template @@ -0,0 +1,56 @@ +# 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 new file mode 100644 index 0000000..a1ddb79 --- /dev/null +++ b/v2/backup_before_timezone_change.sql @@ -0,0 +1,624 @@ +-- +-- 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 new file mode 100644 index 0000000..32e94c9 --- /dev/null +++ b/v2/cookies.txt @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 0000000..c8ad7b6 --- /dev/null +++ b/v2/docker-compose.yaml @@ -0,0 +1,164 @@ +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 new file mode 100644 index 0000000..cee53bf --- /dev/null +++ b/v2_adminpanel/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.11-slim + +# Locale für deutsche Sprache und UTF-8 setzen +ENV LANG=de_DE.UTF-8 +ENV LC_ALL=de_DE.UTF-8 +ENV PYTHONIOENCODING=utf-8 + +# Zeitzone auf Europe/Berlin setzen +ENV TZ=Europe/Berlin + +WORKDIR /app + +# System-Dependencies inkl. PostgreSQL-Tools installieren +RUN apt-get update && apt-get install -y \ + locales \ + postgresql-client \ + tzdata \ + && sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \ + && locale-gen \ + && update-locale LANG=de_DE.UTF-8 \ + && ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \ + && echo "Europe/Berlin" > /etc/timezone \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/v2_adminpanel/ERROR_HANDLING_GUIDE.md b/v2_adminpanel/ERROR_HANDLING_GUIDE.md new file mode 100644 index 0000000..a93a05d --- /dev/null +++ b/v2_adminpanel/ERROR_HANDLING_GUIDE.md @@ -0,0 +1,456 @@ +# Error Handling Guide + +## Overview + +This guide describes the error handling system implemented in the v2_adminpanel application. The system provides: + +- Centralized error handling with custom exception hierarchy +- Input validation framework +- Structured logging +- Monitoring and alerting +- Consistent error responses + +## Architecture + +### 1. Custom Exception Hierarchy + +``` +BaseApplicationException +├── ValidationException +│ ├── InputValidationError +│ ├── BusinessRuleViolation +│ └── DataIntegrityError +├── AuthenticationException +│ ├── InvalidCredentialsError +│ ├── SessionExpiredError +│ └── InsufficientPermissionsError +├── DatabaseException +│ ├── ConnectionError +│ ├── QueryError +│ └── TransactionError +├── ExternalServiceException +│ ├── APIError +│ └── TimeoutError +└── ResourceException + ├── ResourceNotFoundError + ├── ResourceConflictError + └── ResourceLimitExceeded +``` + +### 2. Core Components + +- **core/exceptions.py**: Custom exception classes +- **core/error_handlers.py**: Global error handlers and decorators +- **core/validators.py**: Input validation framework +- **core/logging_config.py**: Structured logging setup +- **core/monitoring.py**: Error metrics and alerting +- **middleware/error_middleware.py**: Request-level error handling + +## Usage Examples + +### 1. Raising Custom Exceptions + +```python +from core.exceptions import ( + InputValidationError, + ResourceNotFoundError, + BusinessRuleViolation +) + +# Validation error +if not email_is_valid: + raise InputValidationError( + field='email', + message='Invalid email format', + value=email_value, + expected_type='email' + ) + +# Resource not found +user = db.get_user(user_id) +if not user: + raise ResourceNotFoundError( + resource_type='User', + resource_id=user_id + ) + +# Business rule violation +if active_licenses >= license_limit: + raise BusinessRuleViolation( + rule='license_limit', + message='License limit exceeded', + context={ + 'current': active_licenses, + 'limit': license_limit + } + ) +``` + +### 2. Using Error Decorators + +```python +from core.error_handlers import handle_errors, validate_request +from core.validators import validate + +@handle_errors( + catch=(psycopg2.Error,), + message='Database operation failed', + user_message='Datenbankfehler aufgetreten', + redirect_to='admin.dashboard' +) +def update_customer(customer_id): + # Database operations + pass + +@validate_request( + required_fields={ + 'email': str, + 'age': int, + 'active': bool + } +) +def create_user(): + # Request data is validated + pass + +@validate({ + 'email': { + 'type': 'email', + 'required': True + }, + 'password': { + 'type': 'password', + 'required': True + }, + 'age': { + 'type': 'integer', + 'required': True, + 'min_value': 18, + 'max_value': 120 + } +}) +def register_user(): + # Access validated data + data = request.validated_data + # Use data safely +``` + +### 3. Input Validation + +```python +from core.validators import Validators + +# Email validation +email = Validators.email(user_input, field_name='email') + +# Phone validation +phone = Validators.phone(user_input, field_name='phone') + +# License key validation +license_key = Validators.license_key(user_input) + +# Integer with constraints +age = Validators.integer( + user_input, + field_name='age', + min_value=0, + max_value=150 +) + +# String with constraints +username = Validators.string( + user_input, + field_name='username', + min_length=3, + max_length=50, + safe_only=True +) + +# Password validation +password = Validators.password(user_input) + +# Custom enum validation +status = Validators.enum( + user_input, + field_name='status', + allowed_values=['active', 'inactive', 'pending'] +) +``` + +### 4. Error Context Manager + +```python +from core.error_handlers import ErrorContext + +with ErrorContext( + operation='create_license', + resource_type='License', + resource_id=license_key +): + # Operations that might fail + db.insert_license(license_data) + # Errors are automatically logged with context +``` + +### 5. Logging + +```python +from core.logging_config import get_logger, log_error, log_security_event + +logger = get_logger(__name__) + +# Standard logging +logger.info('User created', extra={ + 'user_id': user.id, + 'email': user.email +}) + +# Error logging +try: + risky_operation() +except Exception as e: + log_error( + logger, + 'Risky operation failed', + error=e, + user_id=user.id, + operation='risky_operation' + ) + +# Security event logging +log_security_event( + 'INVALID_LOGIN_ATTEMPT', + 'Multiple failed login attempts', + username=username, + ip_address=request.remote_addr, + attempt_count=5 +) +``` + +## Error Response Format + +### JSON Responses (API) + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid input provided", + "timestamp": "2024-01-15T10:30:00Z", + "request_id": "550e8400-e29b-41d4-a716-446655440000", + "details": { + "field": "email", + "value": "invalid-email", + "expected_type": "email" + } + } +} +``` + +### HTML Responses + +- User-friendly error messages +- Error code and request ID for support +- Helpful suggestions for resolution +- Navigation options (back, dashboard, retry) + +## Monitoring and Alerts + +### Metrics Exposed + +- `app_errors_total`: Total error count by code, status, endpoint +- `app_error_rate`: Errors per minute by error code +- `app_validation_errors_total`: Validation errors by field +- `app_auth_failures_total`: Authentication failures +- `app_database_errors_total`: Database errors +- `app_request_duration_seconds`: Request duration histogram + +### Alert Thresholds + +- Error rate > 10/min: Critical alert +- Auth failure rate > 5/min: Security alert +- DB error rate > 3/min: Infrastructure alert +- Response time 95th percentile > 2s: Performance alert + +### Accessing Metrics + +- Prometheus metrics: `/metrics` +- Active alerts: `/api/alerts` + +## Best Practices + +### 1. Always Use Specific Exceptions + +```python +# Bad +raise Exception("User not found") + +# Good +raise ResourceNotFoundError('User', user_id) +``` + +### 2. Provide Context + +```python +# Bad +raise ValidationException("Invalid data") + +# Good +raise InputValidationError( + field='email', + message='Email domain not allowed', + value=email, + expected_type='corporate_email' +) +``` + +### 3. Handle Database Errors + +```python +# Bad +result = db.execute(query) + +# Good +try: + result = db.execute(query) +except psycopg2.IntegrityError as e: + if e.pgcode == '23505': + raise DataIntegrityError( + entity='User', + constraint='unique_email', + message='Email already exists' + ) + raise +``` + +### 4. Validate Early + +```python +# Bad +def process_order(data): + # Process without validation + total = data['quantity'] * data['price'] + +# Good +@validate({ + 'quantity': {'type': 'integer', 'min_value': 1}, + 'price': {'type': 'float', 'min_value': 0} +}) +def process_order(): + data = request.validated_data + total = data['quantity'] * data['price'] +``` + +### 5. Log Security Events + +```python +# Failed login attempts +log_security_event( + 'LOGIN_FAILURE', + f'Failed login for user {username}', + username=username, + ip_address=request.remote_addr +) + +# Suspicious activity +log_security_event( + 'SUSPICIOUS_ACTIVITY', + 'Rapid API requests detected', + ip_address=request.remote_addr, + request_count=count, + time_window=60 +) +``` + +## Migration Guide + +### Converting Existing Error Handling + +1. **Replace generic exceptions**: +```python +# Old +except Exception as e: + flash(f"Error: {str(e)}", "error") + return redirect(url_for('admin.dashboard')) + +# New +except DatabaseException as e: + # Already handled by global handler + raise +``` + +2. **Update validation**: +```python +# Old +email = request.form.get('email') +if not email or '@' not in email: + flash("Invalid email", "error") + return redirect(request.url) + +# New +from core.validators import Validators +try: + email = Validators.email(request.form.get('email')) +except InputValidationError as e: + # Handled automatically + raise +``` + +3. **Use decorators**: +```python +# Old +@app.route('/api/user', methods=['POST']) +def create_user(): + try: + # validation code + # database operations + except Exception as e: + return jsonify({'error': str(e)}), 500 + +# New +@app.route('/api/user', methods=['POST']) +@validate({ + 'email': {'type': 'email', 'required': True}, + 'name': {'type': 'string', 'required': True, 'min_length': 2} +}) +def create_user(): + data = request.validated_data + # Use validated data directly +``` + +## Testing + +Run the test suite: + +```bash +pytest v2_adminpanel/tests/test_error_handling.py -v +``` + +Test coverage includes: +- Exception creation and properties +- Error handler responses (JSON/HTML) +- Validation functions +- Decorators +- Monitoring and metrics +- Alert generation + +## Troubleshooting + +### Common Issues + +1. **Import errors**: Ensure you import from `core` package +2. **Validation not working**: Check decorator order (validate must be closest to function) +3. **Logs not appearing**: Verify LOG_LEVEL environment variable +4. **Metrics missing**: Ensure prometheus_client is installed + +### Debug Mode + +In development, set: +```python +app.config['DEBUG'] = True +``` + +This will: +- Include detailed error information +- Show stack traces +- Log to console with readable format \ No newline at end of file diff --git a/v2_adminpanel/__pycache__/app.cpython-312.pyc b/v2_adminpanel/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..37a2c18 Binary files /dev/null and b/v2_adminpanel/__pycache__/app.cpython-312.pyc differ diff --git a/v2_adminpanel/app.py b/v2_adminpanel/app.py new file mode 100644 index 0000000..e84b8d0 --- /dev/null +++ b/v2_adminpanel/app.py @@ -0,0 +1,168 @@ +import os +import sys +import logging +from datetime import datetime + +# Add current directory to Python path to ensure modules can be imported +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from flask import Flask, render_template, session +from flask_session import Session +from werkzeug.middleware.proxy_fix import ProxyFix +from apscheduler.schedulers.background import BackgroundScheduler +from prometheus_flask_exporter import PrometheusMetrics + +# Import our configuration and utilities +import config +from utils.backup import create_backup + +# Import error handling system +from core.error_handlers import init_error_handlers +from core.logging_config import setup_logging +from core.monitoring import init_monitoring +from middleware.error_middleware import ErrorHandlingMiddleware + +app = Flask(__name__) + +# Initialize Prometheus metrics +metrics = PrometheusMetrics(app) +metrics.info('admin_panel_info', 'Admin Panel Information', version='1.0.0') +# Load configuration from config module +app.config['SECRET_KEY'] = config.SECRET_KEY +app.config['SESSION_TYPE'] = config.SESSION_TYPE +app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII +app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE +app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME +app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY +app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE +app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE +app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME +app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST +Session(app) + +# ProxyFix für korrekte IP-Adressen hinter Nginx +app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 +) + +# Configuration is now loaded from config module + +# Initialize error handling system +setup_logging(app) +init_error_handlers(app) +init_monitoring(app) +ErrorHandlingMiddleware(app) + +# Logging konfigurieren +logging.basicConfig(level=logging.INFO) + +# Initialize scheduler from scheduler module +from scheduler import init_scheduler +scheduler = init_scheduler() + +# Import and register blueprints +try: + from routes.auth_routes import auth_bp + from routes.admin_routes import admin_bp + from routes.api_routes import api_bp + from routes.batch_routes import batch_bp + from routes.customer_routes import customer_bp + from routes.export_routes import export_bp + from routes.license_routes import license_bp + from routes.resource_routes import resource_bp + from routes.session_routes import session_bp + from routes.monitoring_routes import monitoring_bp + from leads import leads_bp + print("All blueprints imported successfully!") +except Exception as e: + print(f"Blueprint import error: {str(e)}") + import traceback + traceback.print_exc() + +# Register all blueprints +app.register_blueprint(auth_bp) +app.register_blueprint(admin_bp) +app.register_blueprint(api_bp) +app.register_blueprint(batch_bp) +app.register_blueprint(customer_bp) +app.register_blueprint(export_bp) +app.register_blueprint(license_bp) +app.register_blueprint(resource_bp) +app.register_blueprint(session_bp) +app.register_blueprint(monitoring_bp) +app.register_blueprint(leads_bp, url_prefix='/leads') + +# Template filters +@app.template_filter('nl2br') +def nl2br_filter(s): + """Convert newlines to
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 new file mode 100644 index 0000000..3030a05 --- /dev/null +++ b/v2_adminpanel/apply_lead_migration.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Apply Lead Management Tables Migration +""" +import psycopg2 +import os +from db import get_db_connection + +def apply_migration(): + """Apply the lead tables migration""" + try: + # Read migration SQL + migration_file = os.path.join(os.path.dirname(__file__), + 'migrations', 'create_lead_tables.sql') + + with open(migration_file, 'r') as f: + migration_sql = f.read() + + # Connect and execute + with get_db_connection() as conn: + cur = conn.cursor() + + print("Applying lead management tables migration...") + cur.execute(migration_sql) + + # Verify tables were created + cur.execute(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name LIKE 'lead_%' + ORDER BY table_name + """) + + tables = cur.fetchall() + print(f"\nCreated {len(tables)} tables:") + for table in tables: + print(f" - {table[0]}") + + cur.close() + + print("\n✅ Migration completed successfully!") + + except FileNotFoundError: + print(f"❌ Migration file not found: {migration_file}") + except psycopg2.Error as e: + print(f"❌ Database error: {e}") + except Exception as e: + print(f"❌ Unexpected error: {e}") + +if __name__ == "__main__": + apply_migration() \ No newline at end of file diff --git a/v2_adminpanel/apply_license_heartbeats_migration.py b/v2_adminpanel/apply_license_heartbeats_migration.py new file mode 100644 index 0000000..843627d --- /dev/null +++ b/v2_adminpanel/apply_license_heartbeats_migration.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +Apply the license_heartbeats table migration +""" + +import os +import psycopg2 +import logging +from datetime import datetime + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def get_db_connection(): + """Get database connection""" + return psycopg2.connect( + host=os.environ.get('POSTGRES_HOST', 'postgres'), + database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'), + user=os.environ.get('POSTGRES_USER', 'postgres'), + password=os.environ.get('POSTGRES_PASSWORD', 'postgres') + ) + +def apply_migration(): + """Apply the license_heartbeats migration""" + conn = None + try: + logger.info("Connecting to database...") + conn = get_db_connection() + cur = conn.cursor() + + # Read migration file + migration_file = os.path.join(os.path.dirname(__file__), 'migrations', 'create_license_heartbeats_table.sql') + logger.info(f"Reading migration file: {migration_file}") + + with open(migration_file, 'r') as f: + migration_sql = f.read() + + # Execute migration + logger.info("Executing migration...") + cur.execute(migration_sql) + + # Verify table was created + cur.execute(""" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_name = 'license_heartbeats' + ) + """) + + if cur.fetchone()[0]: + logger.info("✓ license_heartbeats table created successfully!") + + # Check partitions + cur.execute(""" + SELECT tablename + FROM pg_tables + WHERE tablename LIKE 'license_heartbeats_%' + ORDER BY tablename + """) + + partitions = cur.fetchall() + logger.info(f"✓ Created {len(partitions)} partitions:") + for partition in partitions: + logger.info(f" - {partition[0]}") + else: + logger.error("✗ Failed to create license_heartbeats table") + return False + + conn.commit() + logger.info("✓ Migration completed successfully!") + return True + + except Exception as e: + logger.error(f"✗ Migration failed: {str(e)}") + if conn: + conn.rollback() + return False + finally: + if conn: + cur.close() + conn.close() + +if __name__ == "__main__": + logger.info("=== Applying license_heartbeats migration ===") + logger.info(f"Timestamp: {datetime.now()}") + + if apply_migration(): + logger.info("=== Migration successful! ===") + else: + logger.error("=== Migration failed! ===") + exit(1) \ No newline at end of file diff --git a/v2_adminpanel/apply_partition_migration.py b/v2_adminpanel/apply_partition_migration.py new file mode 100644 index 0000000..c4f1c5d --- /dev/null +++ b/v2_adminpanel/apply_partition_migration.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Apply partition migration for license_heartbeats table. +This script creates missing partitions for the current and future months. +""" + +import psycopg2 +import os +import sys +from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta + +def get_db_connection(): + """Get database connection""" + return psycopg2.connect( + host=os.environ.get('POSTGRES_HOST', 'postgres'), + database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'), + user=os.environ.get('POSTGRES_USER', 'postgres'), + password=os.environ.get('POSTGRES_PASSWORD', 'postgres') + ) + +def create_partition(cursor, year, month): + """Create a partition for the given year and month""" + partition_name = f"license_heartbeats_{year}_{month:02d}" + start_date = f"{year}-{month:02d}-01" + + # Calculate end date (first day of next month) + if month == 12: + end_date = f"{year + 1}-01-01" + else: + end_date = f"{year}-{month + 1:02d}-01" + + # Check if partition already exists + cursor.execute(""" + SELECT EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = %s + ) + """, (partition_name,)) + + exists = cursor.fetchone()[0] + + if not exists: + try: + cursor.execute(f""" + CREATE TABLE {partition_name} PARTITION OF license_heartbeats + FOR VALUES FROM ('{start_date}') TO ('{end_date}') + """) + print(f"✓ Created partition {partition_name}") + return True + except Exception as e: + print(f"✗ Error creating partition {partition_name}: {e}") + return False + else: + print(f"- Partition {partition_name} already exists") + return False + +def main(): + """Main function""" + print("Applying license_heartbeats partition migration...") + print("-" * 50) + + try: + # Connect to database + conn = get_db_connection() + cursor = conn.cursor() + + # Check if license_heartbeats table exists + cursor.execute(""" + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_name = 'license_heartbeats' + ) + """) + + if not cursor.fetchone()[0]: + print("✗ Error: license_heartbeats table does not exist!") + print(" Please run the init.sql script first.") + return 1 + + # Get current date + current_date = datetime.now() + partitions_created = 0 + + # Create partitions for the next 6 months (including current month) + for i in range(7): + target_date = current_date + relativedelta(months=i) + if create_partition(cursor, target_date.year, target_date.month): + partitions_created += 1 + + # Commit changes + conn.commit() + + print("-" * 50) + print(f"✓ Migration complete. Created {partitions_created} new partitions.") + + # List all partitions + cursor.execute(""" + SELECT tablename + FROM pg_tables + WHERE tablename LIKE 'license_heartbeats_%' + ORDER BY tablename + """) + + partitions = cursor.fetchall() + print(f"\nTotal partitions: {len(partitions)}") + for partition in partitions: + print(f" - {partition[0]}") + + cursor.close() + conn.close() + + return 0 + + except Exception as e: + print(f"✗ Error: {e}") + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/v2_adminpanel/auth/__init__.py b/v2_adminpanel/auth/__init__.py new file mode 100644 index 0000000..8ca1225 --- /dev/null +++ b/v2_adminpanel/auth/__init__.py @@ -0,0 +1 @@ +# Auth module initialization \ No newline at end of file diff --git a/v2_adminpanel/auth/decorators.py b/v2_adminpanel/auth/decorators.py new file mode 100644 index 0000000..9e0b004 --- /dev/null +++ b/v2_adminpanel/auth/decorators.py @@ -0,0 +1,44 @@ +from functools import wraps +from flask import session, redirect, url_for, flash, request +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +import logging +from utils.audit import log_audit + +logger = logging.getLogger(__name__) + + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'logged_in' not in session: + return redirect(url_for('auth.login')) + + # Check if session has expired + if 'last_activity' in session: + last_activity = datetime.fromisoformat(session['last_activity']) + time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity + + # Debug logging + logger.info(f"Session check for {session.get('username', 'unknown')}: " + f"Last activity: {last_activity}, " + f"Time since: {time_since_activity.total_seconds()} seconds") + + if time_since_activity > timedelta(minutes=5): + # Session expired - Logout + username = session.get('username', 'unbekannt') + logger.info(f"Session timeout for user {username} - auto logout") + # Audit log for automatic logout (before session.clear()!) + try: + log_audit('AUTO_LOGOUT', 'session', + additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) + except: + pass + session.clear() + flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') + return redirect(url_for('auth.login')) + + # Activity is NOT automatically updated + # Only on explicit user actions (done by heartbeat) + return f(*args, **kwargs) + return decorated_function \ No newline at end of file diff --git a/v2_adminpanel/auth/password.py b/v2_adminpanel/auth/password.py new file mode 100644 index 0000000..785466f --- /dev/null +++ b/v2_adminpanel/auth/password.py @@ -0,0 +1,11 @@ +import bcrypt + + +def hash_password(password): + """Hash a password using bcrypt""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + +def verify_password(password, hashed): + """Verify a password against its hash""" + return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) \ No newline at end of file diff --git a/v2_adminpanel/auth/rate_limiting.py b/v2_adminpanel/auth/rate_limiting.py new file mode 100644 index 0000000..8aca82b --- /dev/null +++ b/v2_adminpanel/auth/rate_limiting.py @@ -0,0 +1,124 @@ +import random +import logging +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from flask import request +from db import execute_query, get_db_connection, get_db_cursor +from config import FAIL_MESSAGES, MAX_LOGIN_ATTEMPTS, BLOCK_DURATION_HOURS, EMAIL_ENABLED +from utils.audit import log_audit +from utils.network import get_client_ip + +logger = logging.getLogger(__name__) + + +def check_ip_blocked(ip_address): + """Check if an IP address is blocked""" + result = execute_query( + """ + SELECT blocked_until FROM login_attempts + WHERE ip_address = %s AND blocked_until IS NOT NULL + """, + (ip_address,), + fetch_one=True + ) + + if result and result[0]: + if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): + return True, result[0] + return False, None + + +def record_failed_attempt(ip_address, username): + """Record a failed login attempt""" + # Random error message + error_message = random.choice(FAIL_MESSAGES) + + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + try: + # Check if IP already exists + cur.execute(""" + SELECT attempt_count FROM login_attempts + WHERE ip_address = %s + """, (ip_address,)) + + result = cur.fetchone() + + if result: + # Update existing entry + new_count = result[0] + 1 + blocked_until = None + + if new_count >= MAX_LOGIN_ATTEMPTS: + blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) + # Email notification (if enabled) + if EMAIL_ENABLED: + send_security_alert_email(ip_address, username, new_count) + + cur.execute(""" + UPDATE login_attempts + SET attempt_count = %s, + last_attempt = CURRENT_TIMESTAMP, + blocked_until = %s, + last_username_tried = %s, + last_error_message = %s + WHERE ip_address = %s + """, (new_count, blocked_until, username, error_message, ip_address)) + else: + # Create new entry + cur.execute(""" + INSERT INTO login_attempts + (ip_address, attempt_count, last_username_tried, last_error_message) + VALUES (%s, 1, %s, %s) + """, (ip_address, username, error_message)) + + conn.commit() + + # Audit log + log_audit('LOGIN_FAILED', 'user', + additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") + + except Exception as e: + logger.error(f"Rate limiting error: {e}") + conn.rollback() + + return error_message + + +def reset_login_attempts(ip_address): + """Reset login attempts for an IP""" + execute_query( + "DELETE FROM login_attempts WHERE ip_address = %s", + (ip_address,) + ) + + +def get_login_attempts(ip_address): + """Get the number of login attempts for an IP""" + result = execute_query( + "SELECT attempt_count FROM login_attempts WHERE ip_address = %s", + (ip_address,), + fetch_one=True + ) + return result[0] if result else 0 + + +def send_security_alert_email(ip_address, username, attempt_count): + """Send a security alert email""" + subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" + body = f""" + WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! + + IP-Adresse: {ip_address} + Versuchter Benutzername: {username} + Anzahl Versuche: {attempt_count} + Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} + + Die IP-Adresse wurde für 24 Stunden gesperrt. + + Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. + """ + + # TODO: Email sending implementation when SMTP is configured + logger.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") + print(f"E-Mail würde gesendet: {subject}") \ No newline at end of file diff --git a/v2_adminpanel/auth/two_factor.py b/v2_adminpanel/auth/two_factor.py new file mode 100644 index 0000000..474555d --- /dev/null +++ b/v2_adminpanel/auth/two_factor.py @@ -0,0 +1,57 @@ +import pyotp +import qrcode +import random +import string +import hashlib +from io import BytesIO +import base64 + + +def generate_totp_secret(): + """Generate a new TOTP secret""" + return pyotp.random_base32() + + +def generate_qr_code(username, totp_secret): + """Generate QR code for TOTP setup""" + totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( + name=username, + issuer_name='V2 Admin Panel' + ) + + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(totp_uri) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buf = BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + + return base64.b64encode(buf.getvalue()).decode() + + +def verify_totp(totp_secret, token): + """Verify a TOTP token""" + totp = pyotp.TOTP(totp_secret) + return totp.verify(token, valid_window=1) + + +def generate_backup_codes(count=8): + """Generate backup codes for 2FA recovery""" + codes = [] + for _ in range(count): + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + codes.append(code) + return codes + + +def hash_backup_code(code): + """Hash a backup code for storage""" + return hashlib.sha256(code.encode()).hexdigest() + + +def verify_backup_code(code, hashed_codes): + """Verify a backup code against stored hashes""" + code_hash = hashlib.sha256(code.encode()).hexdigest() + return code_hash in hashed_codes \ No newline at end of file diff --git a/v2_adminpanel/config.py b/v2_adminpanel/config.py new file mode 100644 index 0000000..de4c1df --- /dev/null +++ b/v2_adminpanel/config.py @@ -0,0 +1,70 @@ +import os +from datetime import timedelta +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() + +# Flask Configuration +SECRET_KEY = os.urandom(24) +SESSION_TYPE = 'filesystem' +JSON_AS_ASCII = False +JSONIFY_MIMETYPE = 'application/json; charset=utf-8' +PERMANENT_SESSION_LIFETIME = timedelta(minutes=5) +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "true").lower() == "true" # Default True for HTTPS +SESSION_COOKIE_SAMESITE = 'Lax' +SESSION_COOKIE_NAME = 'admin_session' +SESSION_REFRESH_EACH_REQUEST = False + +# Database Configuration +DATABASE_CONFIG = { + 'host': os.getenv("POSTGRES_HOST", "postgres"), + 'port': os.getenv("POSTGRES_PORT", "5432"), + 'dbname': os.getenv("POSTGRES_DB"), + 'user': os.getenv("POSTGRES_USER"), + 'password': os.getenv("POSTGRES_PASSWORD"), + 'options': '-c client_encoding=UTF8' +} + +# Backup Configuration +BACKUP_DIR = Path("/app/backups") +BACKUP_DIR.mkdir(exist_ok=True) +BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY") + +# Rate Limiting Configuration +FAIL_MESSAGES = [ + "NOPE!", + "ACCESS DENIED, TRY HARDER", + "WRONG! 🚫", + "COMPUTER SAYS NO", + "YOU FAILED" +] +MAX_LOGIN_ATTEMPTS = 5 +BLOCK_DURATION_HOURS = 24 +CAPTCHA_AFTER_ATTEMPTS = 2 + +# reCAPTCHA Configuration +RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY') +RECAPTCHA_SECRET_KEY = os.getenv('RECAPTCHA_SECRET_KEY') + +# Email Configuration +EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "false").lower() == "true" + +# Admin Users (for backward compatibility) +ADMIN_USERS = { + os.getenv("ADMIN1_USERNAME"): os.getenv("ADMIN1_PASSWORD"), + os.getenv("ADMIN2_USERNAME"): os.getenv("ADMIN2_PASSWORD") +} + +# Scheduler Configuration +SCHEDULER_CONFIG = { + 'backup_hour': 3, + 'backup_minute': 0 +} + +# Logging Configuration +LOGGING_CONFIG = { + 'level': 'INFO', + 'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s' +} \ No newline at end of file diff --git a/v2_adminpanel/core/__init__.py b/v2_adminpanel/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/v2_adminpanel/core/error_handlers.py b/v2_adminpanel/core/error_handlers.py new file mode 100644 index 0000000..ec3708a --- /dev/null +++ b/v2_adminpanel/core/error_handlers.py @@ -0,0 +1,273 @@ +import logging +import traceback +from functools import wraps +from typing import Optional, Dict, Any, Callable, Union +from flask import ( + Flask, request, jsonify, render_template, flash, redirect, + url_for, current_app, g +) +from werkzeug.exceptions import HTTPException +import psycopg2 + +from .exceptions import ( + BaseApplicationException, DatabaseException, ValidationException, + AuthenticationException, ResourceException, QueryError, + ConnectionError, TransactionError +) + + +logger = logging.getLogger(__name__) + + +def init_error_handlers(app: Flask) -> None: + @app.before_request + def before_request(): + g.request_id = request.headers.get('X-Request-ID', + BaseApplicationException('', '', 0).request_id) + + @app.errorhandler(BaseApplicationException) + def handle_application_error(error: BaseApplicationException): + return _handle_error(error) + + @app.errorhandler(HTTPException) + def handle_http_error(error: HTTPException): + return _handle_error(error) + + @app.errorhandler(psycopg2.Error) + def handle_database_error(error: psycopg2.Error): + db_exception = _convert_psycopg2_error(error) + return _handle_error(db_exception) + + @app.errorhandler(Exception) + def handle_unexpected_error(error: Exception): + logger.error( + f"Unexpected error: {str(error)}", + exc_info=True, + extra={'request_id': getattr(g, 'request_id', 'unknown')} + ) + + if current_app.debug: + raise + + generic_error = BaseApplicationException( + message="An unexpected error occurred", + code="INTERNAL_ERROR", + status_code=500, + user_message="Ein unerwarteter Fehler ist aufgetreten" + ) + return _handle_error(generic_error) + + +def _handle_error(error: Union[BaseApplicationException, HTTPException, Exception]) -> tuple: + if isinstance(error, HTTPException): + status_code = error.code + error_dict = { + 'error': { + 'code': error.name.upper().replace(' ', '_'), + 'message': error.description or str(error), + 'request_id': getattr(g, 'request_id', 'unknown') + } + } + user_message = error.description or str(error) + elif isinstance(error, BaseApplicationException): + status_code = error.status_code + error_dict = error.to_dict(include_details=current_app.debug) + error_dict['error']['request_id'] = getattr(g, 'request_id', error.request_id) + user_message = error.user_message + + logger.error( + f"{error.__class__.__name__}: {error.message}", + extra={ + 'error_code': error.code, + 'details': error.details, + 'request_id': error_dict['error']['request_id'] + } + ) + else: + status_code = 500 + error_dict = { + 'error': { + 'code': 'INTERNAL_ERROR', + 'message': 'An internal error occurred', + 'request_id': getattr(g, 'request_id', 'unknown') + } + } + user_message = "Ein interner Fehler ist aufgetreten" + + if _is_json_request(): + return jsonify(error_dict), status_code + else: + if status_code == 404: + return render_template('404.html'), 404 + elif status_code >= 500: + return render_template('500.html', error=user_message), status_code + else: + flash(user_message, 'error') + return render_template('error.html', + error=user_message, + error_code=error_dict['error']['code'], + request_id=error_dict['error']['request_id']), status_code + + +def _convert_psycopg2_error(error: psycopg2.Error) -> DatabaseException: + error_code = getattr(error, 'pgcode', None) + error_message = str(error).split('\n')[0] + + if isinstance(error, psycopg2.OperationalError): + return ConnectionError( + message=f"Database connection failed: {error_message}", + host=None + ) + elif isinstance(error, psycopg2.IntegrityError): + if error_code == '23505': + return ValidationException( + message="Duplicate entry violation", + details={'constraint': error_message}, + user_message="Dieser Eintrag existiert bereits" + ) + elif error_code == '23503': + return ValidationException( + message="Foreign key violation", + details={'constraint': error_message}, + user_message="Referenzierte Daten existieren nicht" + ) + else: + return ValidationException( + message="Data integrity violation", + details={'error_code': error_code}, + user_message="Datenintegritätsfehler" + ) + elif isinstance(error, psycopg2.DataError): + return ValidationException( + message="Invalid data format", + details={'error': error_message}, + user_message="Ungültiges Datenformat" + ) + else: + return QueryError( + message=error_message, + query="[query hidden for security]", + error_code=error_code + ) + + +def _is_json_request() -> bool: + return (request.is_json or + request.path.startswith('/api/') or + request.accept_mimetypes.best == 'application/json') + + +def handle_errors( + catch: tuple = (Exception,), + message: str = "Operation failed", + user_message: Optional[str] = None, + redirect_to: Optional[str] = None +) -> Callable: + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except catch as e: + if isinstance(e, BaseApplicationException): + raise + + logger.error( + f"Error in {func.__name__}: {str(e)}", + exc_info=True, + extra={'request_id': getattr(g, 'request_id', 'unknown')} + ) + + if _is_json_request(): + return jsonify({ + 'error': { + 'code': 'OPERATION_FAILED', + 'message': user_message or message, + 'request_id': getattr(g, 'request_id', 'unknown') + } + }), 500 + else: + flash(user_message or message, 'error') + if redirect_to: + return redirect(url_for(redirect_to)) + return redirect(request.referrer or url_for('admin.dashboard')) + + return wrapper + return decorator + + +def validate_request( + required_fields: Optional[Dict[str, type]] = None, + optional_fields: Optional[Dict[str, type]] = None +) -> Callable: + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + data = request.get_json() if request.is_json else request.form + + if required_fields: + for field, expected_type in required_fields.items(): + if field not in data: + raise ValidationException( + message=f"Missing required field: {field}", + field=field, + user_message=f"Pflichtfeld fehlt: {field}" + ) + + try: + if expected_type != str: + if expected_type == int: + int(data[field]) + elif expected_type == float: + float(data[field]) + elif expected_type == bool: + if isinstance(data[field], str): + if data[field].lower() not in ['true', 'false', '1', '0']: + raise ValueError + except (ValueError, TypeError): + raise ValidationException( + message=f"Invalid type for field {field}", + field=field, + value=data[field], + details={'expected_type': expected_type.__name__}, + user_message=f"Ungültiger Typ für Feld {field}" + ) + + return func(*args, **kwargs) + return wrapper + return decorator + + +class ErrorContext: + def __init__( + self, + operation: str, + resource_type: Optional[str] = None, + resource_id: Optional[Any] = None + ): + self.operation = operation + self.resource_type = resource_type + self.resource_id = resource_id + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val is None: + return False + + if isinstance(exc_val, BaseApplicationException): + return False + + logger.error( + f"Error during {self.operation}", + exc_info=True, + extra={ + 'operation': self.operation, + 'resource_type': self.resource_type, + 'resource_id': self.resource_id, + 'request_id': getattr(g, 'request_id', 'unknown') + } + ) + + return False \ No newline at end of file diff --git a/v2_adminpanel/core/exceptions.py b/v2_adminpanel/core/exceptions.py new file mode 100644 index 0000000..275818c --- /dev/null +++ b/v2_adminpanel/core/exceptions.py @@ -0,0 +1,356 @@ +import uuid +from typing import Optional, Dict, Any +from datetime import datetime + + +class BaseApplicationException(Exception): + def __init__( + self, + message: str, + code: str, + status_code: int = 500, + details: Optional[Dict[str, Any]] = None, + user_message: Optional[str] = None + ): + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + self.details = details or {} + self.user_message = user_message or message + self.timestamp = datetime.utcnow() + self.request_id = str(uuid.uuid4()) + + def to_dict(self, include_details: bool = False) -> Dict[str, Any]: + result = { + 'error': { + 'code': self.code, + 'message': self.user_message, + 'timestamp': self.timestamp.isoformat(), + 'request_id': self.request_id + } + } + + if include_details and self.details: + result['error']['details'] = self.details + + return result + + +class ValidationException(BaseApplicationException): + def __init__( + self, + message: str, + field: Optional[str] = None, + value: Any = None, + details: Optional[Dict[str, Any]] = None, + user_message: Optional[str] = None + ): + details = details or {} + if field: + details['field'] = field + if value is not None: + details['value'] = str(value) + + super().__init__( + message=message, + code='VALIDATION_ERROR', + status_code=400, + details=details, + user_message=user_message or "Ungültige Eingabe" + ) + + +class InputValidationError(ValidationException): + def __init__( + self, + field: str, + message: str, + value: Any = None, + expected_type: Optional[str] = None + ): + details = {'expected_type': expected_type} if expected_type else None + super().__init__( + message=f"Invalid input for field '{field}': {message}", + field=field, + value=value, + details=details, + user_message=f"Ungültiger Wert für Feld '{field}'" + ) + + +class BusinessRuleViolation(ValidationException): + def __init__( + self, + rule: str, + message: str, + context: Optional[Dict[str, Any]] = None + ): + super().__init__( + message=message, + details={'rule': rule, 'context': context or {}}, + user_message="Geschäftsregel verletzt" + ) + + +class DataIntegrityError(ValidationException): + def __init__( + self, + entity: str, + constraint: str, + message: str, + details: Optional[Dict[str, Any]] = None + ): + details = details or {} + details.update({'entity': entity, 'constraint': constraint}) + super().__init__( + message=message, + details=details, + user_message="Datenintegritätsfehler" + ) + + +class AuthenticationException(BaseApplicationException): + def __init__( + self, + message: str, + details: Optional[Dict[str, Any]] = None, + user_message: Optional[str] = None + ): + super().__init__( + message=message, + code='AUTHENTICATION_ERROR', + status_code=401, + details=details, + user_message=user_message or "Authentifizierung fehlgeschlagen" + ) + + +class InvalidCredentialsError(AuthenticationException): + def __init__(self, username: Optional[str] = None): + details = {'username': username} if username else None + super().__init__( + message="Invalid username or password", + details=details, + user_message="Ungültiger Benutzername oder Passwort" + ) + + +class SessionExpiredError(AuthenticationException): + def __init__(self, session_id: Optional[str] = None): + details = {'session_id': session_id} if session_id else None + super().__init__( + message="Session has expired", + details=details, + user_message="Ihre Sitzung ist abgelaufen" + ) + + +class InsufficientPermissionsError(AuthenticationException): + def __init__( + self, + required_permission: str, + user_permissions: Optional[list] = None + ): + super().__init__( + message=f"User lacks required permission: {required_permission}", + details={ + 'required': required_permission, + 'user_permissions': user_permissions or [] + }, + user_message="Unzureichende Berechtigungen für diese Aktion" + ) + self.status_code = 403 + + +class DatabaseException(BaseApplicationException): + def __init__( + self, + message: str, + query: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + user_message: Optional[str] = None + ): + details = details or {} + if query: + details['query_hash'] = str(hash(query)) + + super().__init__( + message=message, + code='DATABASE_ERROR', + status_code=500, + details=details, + user_message=user_message or "Datenbankfehler aufgetreten" + ) + + +class ConnectionError(DatabaseException): + def __init__(self, message: str, host: Optional[str] = None): + details = {'host': host} if host else None + super().__init__( + message=message, + details=details, + user_message="Datenbankverbindung fehlgeschlagen" + ) + + +class QueryError(DatabaseException): + def __init__( + self, + message: str, + query: str, + error_code: Optional[str] = None + ): + super().__init__( + message=message, + query=query, + details={'error_code': error_code} if error_code else None, + user_message="Datenbankabfrage fehlgeschlagen" + ) + + +class TransactionError(DatabaseException): + def __init__(self, message: str, operation: str): + super().__init__( + message=message, + details={'operation': operation}, + user_message="Transaktion fehlgeschlagen" + ) + + +class ExternalServiceException(BaseApplicationException): + def __init__( + self, + service_name: str, + message: str, + details: Optional[Dict[str, Any]] = None, + user_message: Optional[str] = None + ): + details = details or {} + details['service'] = service_name + + super().__init__( + message=message, + code='EXTERNAL_SERVICE_ERROR', + status_code=502, + details=details, + user_message=user_message or f"Fehler beim Zugriff auf {service_name}" + ) + + +class APIError(ExternalServiceException): + def __init__( + self, + service_name: str, + endpoint: str, + status_code: int, + message: str + ): + super().__init__( + service_name=service_name, + message=message, + details={ + 'endpoint': endpoint, + 'response_status': status_code + }, + user_message=f"API-Fehler bei {service_name}" + ) + + +class TimeoutError(ExternalServiceException): + def __init__( + self, + service_name: str, + timeout_seconds: int, + operation: str + ): + super().__init__( + service_name=service_name, + message=f"Timeout after {timeout_seconds}s while {operation}", + details={ + 'timeout_seconds': timeout_seconds, + 'operation': operation + }, + user_message=f"Zeitüberschreitung bei {service_name}" + ) + + +class ResourceException(BaseApplicationException): + def __init__( + self, + message: str, + resource_type: str, + resource_id: Any = None, + details: Optional[Dict[str, Any]] = None, + user_message: Optional[str] = None + ): + details = details or {} + details.update({ + 'resource_type': resource_type, + 'resource_id': str(resource_id) if resource_id else None + }) + + super().__init__( + message=message, + code='RESOURCE_ERROR', + status_code=404, + details=details, + user_message=user_message or "Ressourcenfehler" + ) + + +class ResourceNotFoundError(ResourceException): + def __init__( + self, + resource_type: str, + resource_id: Any = None, + search_criteria: Optional[Dict[str, Any]] = None + ): + details = {'search_criteria': search_criteria} if search_criteria else None + super().__init__( + message=f"{resource_type} not found", + resource_type=resource_type, + resource_id=resource_id, + details=details, + user_message=f"{resource_type} nicht gefunden" + ) + self.status_code = 404 + + +class ResourceConflictError(ResourceException): + def __init__( + self, + resource_type: str, + resource_id: Any, + conflict_reason: str + ): + super().__init__( + message=f"Conflict with {resource_type}: {conflict_reason}", + resource_type=resource_type, + resource_id=resource_id, + details={'conflict_reason': conflict_reason}, + user_message=f"Konflikt mit {resource_type}" + ) + self.status_code = 409 + + +class ResourceLimitExceeded(ResourceException): + def __init__( + self, + resource_type: str, + limit: int, + current: int, + requested: Optional[int] = None + ): + details = { + 'limit': limit, + 'current': current, + 'requested': requested + } + super().__init__( + message=f"{resource_type} limit exceeded: {current}/{limit}", + resource_type=resource_type, + details=details, + user_message=f"Limit für {resource_type} überschritten" + ) + self.status_code = 429 \ No newline at end of file diff --git a/v2_adminpanel/core/logging_config.py b/v2_adminpanel/core/logging_config.py new file mode 100644 index 0000000..56df1dd --- /dev/null +++ b/v2_adminpanel/core/logging_config.py @@ -0,0 +1,190 @@ +import logging +import logging.handlers +import json +import sys +import os +from datetime import datetime +from typing import Dict, Any +from flask import g, request, has_request_context + + +class StructuredFormatter(logging.Formatter): + def format(self, record): + log_data = { + 'timestamp': datetime.utcnow().isoformat(), + 'level': record.levelname, + 'logger': record.name, + 'message': record.getMessage(), + 'module': record.module, + 'function': record.funcName, + 'line': record.lineno + } + + if has_request_context(): + log_data['request'] = { + 'method': request.method, + 'path': request.path, + 'remote_addr': request.remote_addr, + 'user_agent': request.user_agent.string, + 'request_id': getattr(g, 'request_id', 'unknown') + } + + if hasattr(record, 'request_id'): + log_data['request_id'] = record.request_id + + if hasattr(record, 'error_code'): + log_data['error_code'] = record.error_code + + if hasattr(record, 'details') and record.details: + log_data['details'] = self._sanitize_details(record.details) + + if record.exc_info: + log_data['exception'] = { + 'type': record.exc_info[0].__name__, + 'message': str(record.exc_info[1]), + 'traceback': self.formatException(record.exc_info) + } + + return json.dumps(log_data, ensure_ascii=False) + + def _sanitize_details(self, details: Dict[str, Any]) -> Dict[str, Any]: + sensitive_fields = { + 'password', 'secret', 'token', 'api_key', 'authorization', + 'credit_card', 'ssn', 'pin' + } + + sanitized = {} + for key, value in details.items(): + if any(field in key.lower() for field in sensitive_fields): + sanitized[key] = '[REDACTED]' + elif isinstance(value, dict): + sanitized[key] = self._sanitize_details(value) + else: + sanitized[key] = value + + return sanitized + + +class ErrorLevelFilter(logging.Filter): + def __init__(self, min_level=logging.ERROR): + self.min_level = min_level + + def filter(self, record): + return record.levelno >= self.min_level + + +def setup_logging(app): + log_level = os.getenv('LOG_LEVEL', 'INFO').upper() + log_dir = os.getenv('LOG_DIR', 'logs') + + os.makedirs(log_dir, exist_ok=True) + + root_logger = logging.getLogger() + root_logger.setLevel(getattr(logging, log_level)) + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(logging.INFO) + + if app.debug: + console_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + else: + console_formatter = StructuredFormatter() + + console_handler.setFormatter(console_formatter) + root_logger.addHandler(console_handler) + + app_log_handler = logging.handlers.RotatingFileHandler( + os.path.join(log_dir, 'app.log'), + maxBytes=10 * 1024 * 1024, + backupCount=10 + ) + app_log_handler.setLevel(logging.DEBUG) + app_log_handler.setFormatter(StructuredFormatter()) + root_logger.addHandler(app_log_handler) + + error_log_handler = logging.handlers.RotatingFileHandler( + os.path.join(log_dir, 'errors.log'), + maxBytes=10 * 1024 * 1024, + backupCount=10 + ) + error_log_handler.setLevel(logging.ERROR) + error_log_handler.setFormatter(StructuredFormatter()) + error_log_handler.addFilter(ErrorLevelFilter()) + root_logger.addHandler(error_log_handler) + + security_logger = logging.getLogger('security') + security_handler = logging.handlers.RotatingFileHandler( + os.path.join(log_dir, 'security.log'), + maxBytes=10 * 1024 * 1024, + backupCount=20 + ) + security_handler.setFormatter(StructuredFormatter()) + security_logger.addHandler(security_handler) + security_logger.setLevel(logging.INFO) + + werkzeug_logger = logging.getLogger('werkzeug') + werkzeug_logger.setLevel(logging.WARNING) + + @app.before_request + def log_request_info(): + logger = logging.getLogger('request') + logger.info( + 'Request started', + extra={ + 'request_id': getattr(g, 'request_id', 'unknown'), + 'details': { + 'method': request.method, + 'path': request.path, + 'query_params': dict(request.args), + 'content_length': request.content_length + } + } + ) + + @app.after_request + def log_response_info(response): + logger = logging.getLogger('request') + logger.info( + 'Request completed', + extra={ + 'request_id': getattr(g, 'request_id', 'unknown'), + 'details': { + 'status_code': response.status_code, + 'content_length': response.content_length or 0 + } + } + ) + return response + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) + + +def log_error(logger: logging.Logger, message: str, error: Exception = None, **kwargs): + extra = kwargs.copy() + + if error: + extra['error_type'] = type(error).__name__ + extra['error_message'] = str(error) + + if hasattr(error, 'code'): + extra['error_code'] = error.code + if hasattr(error, 'details'): + extra['details'] = error.details + + logger.error(message, exc_info=error, extra=extra) + + +def log_security_event(event_type: str, message: str, **details): + logger = logging.getLogger('security') + logger.warning( + f"Security Event: {event_type} - {message}", + extra={ + 'security_event': event_type, + 'details': details, + 'request_id': getattr(g, 'request_id', 'unknown') if has_request_context() else None + } + ) \ No newline at end of file diff --git a/v2_adminpanel/core/monitoring.py b/v2_adminpanel/core/monitoring.py new file mode 100644 index 0000000..700aab9 --- /dev/null +++ b/v2_adminpanel/core/monitoring.py @@ -0,0 +1,246 @@ +import time +import functools +from typing import Dict, Any, Optional, List +from collections import defaultdict, deque +from datetime import datetime, timedelta +from threading import Lock +import logging + +from prometheus_client import Counter, Histogram, Gauge, generate_latest +from flask import g, request, Response + +from .exceptions import BaseApplicationException +from .logging_config import log_security_event + + +logger = logging.getLogger(__name__) + + +class ErrorMetrics: + def __init__(self): + self.error_counter = Counter( + 'app_errors_total', + 'Total number of errors', + ['error_code', 'status_code', 'endpoint'] + ) + + self.error_rate = Gauge( + 'app_error_rate', + 'Error rate per minute', + ['error_code'] + ) + + self.request_duration = Histogram( + 'app_request_duration_seconds', + 'Request duration in seconds', + ['method', 'endpoint', 'status_code'] + ) + + self.validation_errors = Counter( + 'app_validation_errors_total', + 'Total validation errors', + ['field', 'endpoint'] + ) + + self.auth_failures = Counter( + 'app_auth_failures_total', + 'Total authentication failures', + ['reason', 'endpoint'] + ) + + self.db_errors = Counter( + 'app_database_errors_total', + 'Total database errors', + ['error_type', 'operation'] + ) + + self._error_history = defaultdict(lambda: deque(maxlen=60)) + self._lock = Lock() + + def record_error(self, error: BaseApplicationException, endpoint: str = None): + endpoint = endpoint or request.endpoint or 'unknown' + + self.error_counter.labels( + error_code=error.code, + status_code=error.status_code, + endpoint=endpoint + ).inc() + + with self._lock: + self._error_history[error.code].append(datetime.utcnow()) + self._update_error_rates() + + if error.code == 'VALIDATION_ERROR' and 'field' in error.details: + self.validation_errors.labels( + field=error.details['field'], + endpoint=endpoint + ).inc() + elif error.code == 'AUTHENTICATION_ERROR': + reason = error.__class__.__name__ + self.auth_failures.labels( + reason=reason, + endpoint=endpoint + ).inc() + elif error.code == 'DATABASE_ERROR': + error_type = error.__class__.__name__ + operation = error.details.get('operation', 'unknown') + self.db_errors.labels( + error_type=error_type, + operation=operation + ).inc() + + def _update_error_rates(self): + now = datetime.utcnow() + one_minute_ago = now - timedelta(minutes=1) + + for error_code, timestamps in self._error_history.items(): + recent_count = sum(1 for ts in timestamps if ts >= one_minute_ago) + self.error_rate.labels(error_code=error_code).set(recent_count) + + +class AlertManager: + def __init__(self): + self.alerts = [] + self.alert_thresholds = { + 'error_rate': 10, + 'auth_failure_rate': 5, + 'db_error_rate': 3, + 'response_time_95th': 2.0 + } + self._lock = Lock() + + def check_alerts(self, metrics: ErrorMetrics): + new_alerts = [] + + for error_code, rate in self._get_current_error_rates(metrics).items(): + if rate > self.alert_thresholds['error_rate']: + new_alerts.append({ + 'type': 'high_error_rate', + 'severity': 'critical', + 'error_code': error_code, + 'rate': rate, + 'threshold': self.alert_thresholds['error_rate'], + 'message': f'High error rate for {error_code}: {rate}/min', + 'timestamp': datetime.utcnow() + }) + + auth_failure_rate = self._get_auth_failure_rate(metrics) + if auth_failure_rate > self.alert_thresholds['auth_failure_rate']: + new_alerts.append({ + 'type': 'auth_failures', + 'severity': 'warning', + 'rate': auth_failure_rate, + 'threshold': self.alert_thresholds['auth_failure_rate'], + 'message': f'High authentication failure rate: {auth_failure_rate}/min', + 'timestamp': datetime.utcnow() + }) + + log_security_event( + 'HIGH_AUTH_FAILURE_RATE', + f'Authentication failure rate exceeded threshold', + rate=auth_failure_rate, + threshold=self.alert_thresholds['auth_failure_rate'] + ) + + with self._lock: + self.alerts.extend(new_alerts) + self.alerts = [a for a in self.alerts + if a['timestamp'] > datetime.utcnow() - timedelta(hours=24)] + + return new_alerts + + def _get_current_error_rates(self, metrics: ErrorMetrics) -> Dict[str, float]: + rates = {} + with metrics._lock: + now = datetime.utcnow() + one_minute_ago = now - timedelta(minutes=1) + + for error_code, timestamps in metrics._error_history.items(): + rates[error_code] = sum(1 for ts in timestamps if ts >= one_minute_ago) + + return rates + + def _get_auth_failure_rate(self, metrics: ErrorMetrics) -> float: + return sum( + sample.value + for sample in metrics.auth_failures._child_samples() + ) / 60.0 + + def get_active_alerts(self) -> List[Dict[str, Any]]: + with self._lock: + return list(self.alerts) + + +error_metrics = ErrorMetrics() +alert_manager = AlertManager() + + +def init_monitoring(app): + @app.before_request + def before_request(): + g.start_time = time.time() + + @app.after_request + def after_request(response): + if hasattr(g, 'start_time'): + duration = time.time() - g.start_time + error_metrics.request_duration.labels( + method=request.method, + endpoint=request.endpoint or 'unknown', + status_code=response.status_code + ).observe(duration) + + return response + + @app.route('/metrics') + def metrics(): + alert_manager.check_alerts(error_metrics) + return Response(generate_latest(), mimetype='text/plain') + + @app.route('/api/alerts') + def get_alerts(): + alerts = alert_manager.get_active_alerts() + return { + 'alerts': alerts, + 'total': len(alerts), + 'critical': len([a for a in alerts if a['severity'] == 'critical']), + 'warning': len([a for a in alerts if a['severity'] == 'warning']) + } + + +def monitor_performance(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + start_time = time.time() + + try: + result = func(*args, **kwargs) + return result + finally: + duration = time.time() - start_time + if duration > 1.0: + logger.warning( + f"Slow function execution: {func.__name__}", + extra={ + 'function': func.__name__, + 'duration': duration, + 'request_id': getattr(g, 'request_id', 'unknown') + } + ) + + return wrapper + + +def track_error(error: BaseApplicationException): + error_metrics.record_error(error) + + if error.status_code >= 500: + logger.error( + f"Critical error occurred: {error.code}", + extra={ + 'error_code': error.code, + 'message': error.message, + 'details': error.details, + 'request_id': error.request_id + } + ) \ No newline at end of file diff --git a/v2_adminpanel/core/validators.py b/v2_adminpanel/core/validators.py new file mode 100644 index 0000000..db715dc --- /dev/null +++ b/v2_adminpanel/core/validators.py @@ -0,0 +1,435 @@ +import re +from typing import Any, Optional, List, Dict, Callable, Union +from datetime import datetime, date +from functools import wraps +import ipaddress +from flask import request + +from .exceptions import InputValidationError, ValidationException + + +class ValidationRules: + EMAIL_PATTERN = re.compile( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + ) + + PHONE_PATTERN = re.compile( + r'^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,10}$' + ) + + LICENSE_KEY_PATTERN = re.compile( + r'^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + ) + + SAFE_STRING_PATTERN = re.compile( + r'^[a-zA-Z0-9\s\-\_\.\,\!\?\@\#\$\%\&\*\(\)\[\]\{\}\:\;\'\"\+\=\/\\]+$' + ) + + USERNAME_PATTERN = re.compile( + r'^[a-zA-Z0-9_\-\.]{3,50}$' + ) + + PASSWORD_MIN_LENGTH = 8 + PASSWORD_REQUIRE_UPPER = True + PASSWORD_REQUIRE_LOWER = True + PASSWORD_REQUIRE_DIGIT = True + PASSWORD_REQUIRE_SPECIAL = True + + +class Validators: + @staticmethod + def required(value: Any, field_name: str = "field") -> Any: + if value is None or (isinstance(value, str) and not value.strip()): + raise InputValidationError( + field=field_name, + message="This field is required", + value=value + ) + return value + + @staticmethod + def email(value: str, field_name: str = "email") -> str: + value = Validators.required(value, field_name).strip() + + if not ValidationRules.EMAIL_PATTERN.match(value): + raise InputValidationError( + field=field_name, + message="Invalid email format", + value=value, + expected_type="email" + ) + + return value.lower() + + @staticmethod + def phone(value: str, field_name: str = "phone") -> str: + value = Validators.required(value, field_name).strip() + + cleaned = re.sub(r'[\s\-\(\)]', '', value) + + if not ValidationRules.PHONE_PATTERN.match(value): + raise InputValidationError( + field=field_name, + message="Invalid phone number format", + value=value, + expected_type="phone" + ) + + return cleaned + + @staticmethod + def license_key(value: str, field_name: str = "license_key") -> str: + value = Validators.required(value, field_name).strip().upper() + + if not ValidationRules.LICENSE_KEY_PATTERN.match(value): + raise InputValidationError( + field=field_name, + message="Invalid license key format (expected: XXXX-XXXX-XXXX-XXXX)", + value=value, + expected_type="license_key" + ) + + return value + + @staticmethod + def integer( + value: Union[str, int], + field_name: str = "field", + min_value: Optional[int] = None, + max_value: Optional[int] = None + ) -> int: + try: + int_value = int(value) + except (ValueError, TypeError): + raise InputValidationError( + field=field_name, + message="Must be a valid integer", + value=value, + expected_type="integer" + ) + + if min_value is not None and int_value < min_value: + raise InputValidationError( + field=field_name, + message=f"Must be at least {min_value}", + value=int_value + ) + + if max_value is not None and int_value > max_value: + raise InputValidationError( + field=field_name, + message=f"Must be at most {max_value}", + value=int_value + ) + + return int_value + + @staticmethod + def float_number( + value: Union[str, float], + field_name: str = "field", + min_value: Optional[float] = None, + max_value: Optional[float] = None + ) -> float: + try: + float_value = float(value) + except (ValueError, TypeError): + raise InputValidationError( + field=field_name, + message="Must be a valid number", + value=value, + expected_type="float" + ) + + if min_value is not None and float_value < min_value: + raise InputValidationError( + field=field_name, + message=f"Must be at least {min_value}", + value=float_value + ) + + if max_value is not None and float_value > max_value: + raise InputValidationError( + field=field_name, + message=f"Must be at most {max_value}", + value=float_value + ) + + return float_value + + @staticmethod + def boolean(value: Union[str, bool], field_name: str = "field") -> bool: + if isinstance(value, bool): + return value + + if isinstance(value, str): + value_lower = value.lower() + if value_lower in ['true', '1', 'yes', 'on']: + return True + elif value_lower in ['false', '0', 'no', 'off']: + return False + + raise InputValidationError( + field=field_name, + message="Must be a valid boolean", + value=value, + expected_type="boolean" + ) + + @staticmethod + def string( + value: str, + field_name: str = "field", + min_length: Optional[int] = None, + max_length: Optional[int] = None, + pattern: Optional[re.Pattern] = None, + safe_only: bool = False + ) -> str: + value = Validators.required(value, field_name).strip() + + if min_length is not None and len(value) < min_length: + raise InputValidationError( + field=field_name, + message=f"Must be at least {min_length} characters", + value=value + ) + + if max_length is not None and len(value) > max_length: + raise InputValidationError( + field=field_name, + message=f"Must be at most {max_length} characters", + value=value + ) + + if safe_only and not ValidationRules.SAFE_STRING_PATTERN.match(value): + raise InputValidationError( + field=field_name, + message="Contains invalid characters", + value=value + ) + + if pattern and not pattern.match(value): + raise InputValidationError( + field=field_name, + message="Does not match required format", + value=value + ) + + return value + + @staticmethod + def username(value: str, field_name: str = "username") -> str: + value = Validators.required(value, field_name).strip() + + if not ValidationRules.USERNAME_PATTERN.match(value): + raise InputValidationError( + field=field_name, + message="Username must be 3-50 characters and contain only letters, numbers, _, -, or .", + value=value, + expected_type="username" + ) + + return value + + @staticmethod + def password(value: str, field_name: str = "password") -> str: + value = Validators.required(value, field_name) + + errors = [] + + if len(value) < ValidationRules.PASSWORD_MIN_LENGTH: + errors.append(f"at least {ValidationRules.PASSWORD_MIN_LENGTH} characters") + + if ValidationRules.PASSWORD_REQUIRE_UPPER and not re.search(r'[A-Z]', value): + errors.append("at least one uppercase letter") + + if ValidationRules.PASSWORD_REQUIRE_LOWER and not re.search(r'[a-z]', value): + errors.append("at least one lowercase letter") + + if ValidationRules.PASSWORD_REQUIRE_DIGIT and not re.search(r'\d', value): + errors.append("at least one digit") + + if ValidationRules.PASSWORD_REQUIRE_SPECIAL and not re.search(r'[!@#$%^&*(),.?":{}|<>]', value): + errors.append("at least one special character") + + if errors: + raise InputValidationError( + field=field_name, + message=f"Password must contain {', '.join(errors)}", + value="[hidden]" + ) + + return value + + @staticmethod + def date_string( + value: str, + field_name: str = "date", + format: str = "%Y-%m-%d", + min_date: Optional[date] = None, + max_date: Optional[date] = None + ) -> date: + value = Validators.required(value, field_name).strip() + + try: + date_value = datetime.strptime(value, format).date() + except ValueError: + raise InputValidationError( + field=field_name, + message=f"Invalid date format (expected: {format})", + value=value, + expected_type="date" + ) + + if min_date and date_value < min_date: + raise InputValidationError( + field=field_name, + message=f"Date must be after {min_date}", + value=value + ) + + if max_date and date_value > max_date: + raise InputValidationError( + field=field_name, + message=f"Date must be before {max_date}", + value=value + ) + + return date_value + + @staticmethod + def ip_address( + value: str, + field_name: str = "ip_address", + version: Optional[int] = None + ) -> str: + value = Validators.required(value, field_name).strip() + + try: + ip = ipaddress.ip_address(value) + if version and ip.version != version: + raise ValueError + except ValueError: + version_str = f"IPv{version}" if version else "IP" + raise InputValidationError( + field=field_name, + message=f"Invalid {version_str} address", + value=value, + expected_type="ip_address" + ) + + return str(ip) + + @staticmethod + def url( + value: str, + field_name: str = "url", + require_https: bool = False + ) -> str: + value = Validators.required(value, field_name).strip() + + url_pattern = re.compile( + r'^https?://' + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' + r'localhost|' + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' + r'(?::\d+)?' + r'(?:/?|[/?]\S+)$', re.IGNORECASE + ) + + if not url_pattern.match(value): + raise InputValidationError( + field=field_name, + message="Invalid URL format", + value=value, + expected_type="url" + ) + + if require_https and not value.startswith('https://'): + raise InputValidationError( + field=field_name, + message="URL must use HTTPS", + value=value + ) + + return value + + @staticmethod + def enum( + value: Any, + field_name: str, + allowed_values: List[Any] + ) -> Any: + if value not in allowed_values: + raise InputValidationError( + field=field_name, + message=f"Must be one of: {', '.join(map(str, allowed_values))}", + value=value + ) + + return value + + +def validate(rules: Dict[str, Dict[str, Any]]) -> Callable: + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + data = request.get_json() if request.is_json else request.form + validated_data = {} + + for field_name, field_rules in rules.items(): + value = data.get(field_name) + + if 'required' in field_rules and field_rules['required']: + value = Validators.required(value, field_name) + elif value is None or value == '': + if 'default' in field_rules: + validated_data[field_name] = field_rules['default'] + continue + + validator_name = field_rules.get('type', 'string') + validator_func = getattr(Validators, validator_name, None) + + if not validator_func: + raise ValueError(f"Unknown validator type: {validator_name}") + + validator_params = { + k: v for k, v in field_rules.items() + if k not in ['type', 'required', 'default'] + } + validator_params['field_name'] = field_name + + validated_data[field_name] = validator_func(value, **validator_params) + + request.validated_data = validated_data + return func(*args, **kwargs) + + return wrapper + return decorator + + +def sanitize_html(value: str) -> str: + dangerous_tags = re.compile( + r'<(script|iframe|object|embed|form|input|button|textarea|select|link|meta|style).*?>.*?', + 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 new file mode 100644 index 0000000..be8284e --- /dev/null +++ b/v2_adminpanel/db.py @@ -0,0 +1,84 @@ +import psycopg2 +from psycopg2.extras import Json, RealDictCursor +from contextlib import contextmanager +from config import DATABASE_CONFIG + + +def get_connection(): + """Create and return a new database connection""" + conn = psycopg2.connect(**DATABASE_CONFIG) + conn.set_client_encoding('UTF8') + return conn + + +@contextmanager +def get_db_connection(): + """Context manager for database connections""" + conn = get_connection() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +@contextmanager +def get_db_cursor(conn=None): + """Context manager for database cursors""" + if conn is None: + with get_db_connection() as connection: + cur = connection.cursor() + try: + yield cur + finally: + cur.close() + else: + cur = conn.cursor() + try: + yield cur + finally: + cur.close() + + +@contextmanager +def get_dict_cursor(conn=None): + """Context manager for dictionary cursors""" + if conn is None: + with get_db_connection() as connection: + cur = connection.cursor(cursor_factory=RealDictCursor) + try: + yield cur + finally: + cur.close() + else: + cur = conn.cursor(cursor_factory=RealDictCursor) + try: + yield cur + finally: + cur.close() + + +def execute_query(query, params=None, fetch_one=False, fetch_all=False, as_dict=False): + """Execute a query and optionally fetch results""" + with get_db_connection() as conn: + cursor_func = get_dict_cursor if as_dict else get_db_cursor + with cursor_func(conn) as cur: + cur.execute(query, params) + + if fetch_one: + return cur.fetchone() + elif fetch_all: + return cur.fetchall() + else: + return cur.rowcount + + +def execute_many(query, params_list): + """Execute a query multiple times with different parameters""" + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.executemany(query, params_list) + return cur.rowcount \ No newline at end of file diff --git a/v2_adminpanel/init.sql b/v2_adminpanel/init.sql new file mode 100644 index 0000000..896ba45 --- /dev/null +++ b/v2_adminpanel/init.sql @@ -0,0 +1,704 @@ +-- UTF-8 Encoding für deutsche Sonderzeichen sicherstellen +SET client_encoding = 'UTF8'; + +-- Zeitzone auf Europe/Berlin setzen +SET timezone = 'Europe/Berlin'; + +CREATE TABLE IF NOT EXISTS customers ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + is_fake BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_email UNIQUE (email) +); + +CREATE TABLE IF NOT EXISTS licenses ( + id SERIAL PRIMARY KEY, + license_key TEXT UNIQUE NOT NULL, + customer_id INTEGER REFERENCES customers(id), + license_type TEXT NOT NULL, + valid_from DATE NOT NULL, + valid_until DATE NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + is_fake BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS sessions ( + id SERIAL PRIMARY KEY, + license_id INTEGER REFERENCES licenses(id), + license_key VARCHAR(60), -- Denormalized for performance + session_id TEXT UNIQUE NOT NULL, + username VARCHAR(50), + computer_name VARCHAR(100), + hardware_id VARCHAR(100), + ip_address TEXT, + user_agent TEXT, + app_version VARCHAR(20), + login_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, -- Alias for started_at + last_activity TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, -- Alias for last_heartbeat + logout_time TIMESTAMP WITH TIME ZONE, -- Alias for ended_at + started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + ended_at TIMESTAMP WITH TIME ZONE, + is_active BOOLEAN DEFAULT TRUE, + active BOOLEAN DEFAULT TRUE -- Alias for is_active +); + +-- Audit-Log-Tabelle für Änderungsprotokolle +CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + username TEXT NOT NULL, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + old_values JSONB, + new_values JSONB, + ip_address TEXT, + user_agent TEXT, + additional_info TEXT +); + +-- Index für bessere Performance bei Abfragen +CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC); +CREATE INDEX idx_audit_log_username ON audit_log(username); +CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id); + +-- Backup-Historie-Tabelle +CREATE TABLE IF NOT EXISTS backup_history ( + id SERIAL PRIMARY KEY, + filename TEXT NOT NULL, + filepath TEXT NOT NULL, + filesize BIGINT, + backup_type TEXT NOT NULL, -- 'manual' oder 'scheduled' + status TEXT NOT NULL, -- 'success', 'failed', 'in_progress' + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by TEXT NOT NULL, + tables_count INTEGER, + records_count INTEGER, + duration_seconds NUMERIC, + is_encrypted BOOLEAN DEFAULT TRUE +); + +-- Index für bessere Performance +CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC); +CREATE INDEX idx_backup_history_status ON backup_history(status); + +-- Login-Attempts-Tabelle für Rate-Limiting +CREATE TABLE IF NOT EXISTS login_attempts ( + ip_address VARCHAR(45) PRIMARY KEY, + attempt_count INTEGER DEFAULT 0, + first_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + blocked_until TIMESTAMP WITH TIME ZONE NULL, + last_username_tried TEXT, + last_error_message TEXT +); + +-- Index für schnelle Abfragen +CREATE INDEX idx_login_attempts_blocked_until ON login_attempts(blocked_until); +CREATE INDEX idx_login_attempts_last_attempt ON login_attempts(last_attempt DESC); + +-- Migration: Füge created_at zu licenses hinzu, falls noch nicht vorhanden +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'created_at') THEN + ALTER TABLE licenses ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP; + + -- Setze created_at für bestehende Einträge auf das valid_from Datum + UPDATE licenses SET created_at = valid_from WHERE created_at IS NULL; + END IF; +END $$; + +-- ===================== RESOURCE POOL SYSTEM ===================== + +-- Haupttabelle für den Resource Pool +CREATE TABLE IF NOT EXISTS resource_pools ( + id SERIAL PRIMARY KEY, + resource_type VARCHAR(20) NOT NULL CHECK (resource_type IN ('domain', 'ipv4', 'phone')), + resource_value VARCHAR(255) NOT NULL, + status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('available', 'allocated', 'quarantine')), + allocated_to_license INTEGER REFERENCES licenses(id) ON DELETE SET NULL, + status_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + status_changed_by VARCHAR(50), + quarantine_reason VARCHAR(100) CHECK (quarantine_reason IN ('abuse', 'defect', 'maintenance', 'blacklisted', 'expired', 'review', NULL)), + quarantine_until TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + notes TEXT, + is_fake BOOLEAN DEFAULT FALSE, + UNIQUE(resource_type, resource_value) +); + +-- Resource History für vollständige Nachverfolgbarkeit +CREATE TABLE IF NOT EXISTS resource_history ( + id SERIAL PRIMARY KEY, + resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, + license_id INTEGER REFERENCES licenses(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL CHECK (action IN ('allocated', 'deallocated', 'quarantined', 'released', 'created', 'deleted')), + action_by VARCHAR(50) NOT NULL, + action_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + details JSONB, + ip_address TEXT +); + +-- Resource Metrics für Performance-Tracking und ROI +CREATE TABLE IF NOT EXISTS resource_metrics ( + id SERIAL PRIMARY KEY, + resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, + metric_date DATE NOT NULL, + usage_count INTEGER DEFAULT 0, + performance_score DECIMAL(5,2) DEFAULT 0.00, + cost DECIMAL(10,2) DEFAULT 0.00, + revenue DECIMAL(10,2) DEFAULT 0.00, + issues_count INTEGER DEFAULT 0, + availability_percent DECIMAL(5,2) DEFAULT 100.00, + UNIQUE(resource_id, metric_date) +); + +-- Zuordnungstabelle zwischen Lizenzen und Ressourcen +CREATE TABLE IF NOT EXISTS license_resources ( + id SERIAL PRIMARY KEY, + license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, + resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE, + assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + assigned_by VARCHAR(50), + is_active BOOLEAN DEFAULT TRUE, + UNIQUE(license_id, resource_id) +); + +-- Erweiterung der licenses Tabelle um Resource-Counts +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'domain_count') THEN + ALTER TABLE licenses + ADD COLUMN domain_count INTEGER DEFAULT 1 CHECK (domain_count >= 0 AND domain_count <= 10), + ADD COLUMN ipv4_count INTEGER DEFAULT 1 CHECK (ipv4_count >= 0 AND ipv4_count <= 10), + ADD COLUMN phone_count INTEGER DEFAULT 1 CHECK (phone_count >= 0 AND phone_count <= 10); + END IF; +END $$; + +-- Erweiterung der licenses Tabelle um device_limit +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'device_limit') THEN + ALTER TABLE licenses + ADD COLUMN device_limit INTEGER DEFAULT 3 CHECK (device_limit >= 1 AND device_limit <= 10); + END IF; +END $$; + +-- Tabelle für Geräte-Registrierungen +CREATE TABLE IF NOT EXISTS device_registrations ( + id SERIAL PRIMARY KEY, + license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, + hardware_id TEXT NOT NULL, + device_name TEXT, + operating_system TEXT, + first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE, + deactivated_at TIMESTAMP WITH TIME ZONE, + deactivated_by TEXT, + ip_address TEXT, + user_agent TEXT, + UNIQUE(license_id, hardware_id) +); + +-- Indizes für device_registrations +CREATE INDEX IF NOT EXISTS idx_device_license ON device_registrations(license_id); +CREATE INDEX IF NOT EXISTS idx_device_hardware ON device_registrations(hardware_id); +CREATE INDEX IF NOT EXISTS idx_device_active ON device_registrations(license_id, is_active) WHERE is_active = TRUE; + +-- Indizes für Performance +CREATE INDEX IF NOT EXISTS idx_resource_status ON resource_pools(status); +CREATE INDEX IF NOT EXISTS idx_resource_type_status ON resource_pools(resource_type, status); +CREATE INDEX IF NOT EXISTS idx_resource_allocated ON resource_pools(allocated_to_license) WHERE allocated_to_license IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_resource_quarantine ON resource_pools(quarantine_until) WHERE status = 'quarantine'; +CREATE INDEX IF NOT EXISTS idx_resource_history_date ON resource_history(action_at DESC); +CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id); +CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC); +CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE; + +-- Users table for authentication with password and 2FA support +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + email VARCHAR(100), + totp_secret VARCHAR(32), + totp_enabled BOOLEAN DEFAULT FALSE, + backup_codes TEXT, -- JSON array of hashed backup codes + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + password_reset_token VARCHAR(64), + password_reset_expires TIMESTAMP WITH TIME ZONE, + failed_2fa_attempts INTEGER DEFAULT 0, + last_failed_2fa TIMESTAMP WITH TIME ZONE +); + +-- Index for faster login lookups +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL; + +-- Migration: Add is_fake column to licenses if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'is_fake') THEN + ALTER TABLE licenses ADD COLUMN is_fake BOOLEAN DEFAULT FALSE; + + -- Mark all existing licenses as fake data + UPDATE licenses SET is_fake = TRUE; + + -- Add index for better performance when filtering fake data + CREATE INDEX idx_licenses_is_fake ON licenses(is_fake); + END IF; +END $$; + +-- Migration: Add is_fake column to customers if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'customers' AND column_name = 'is_fake') THEN + ALTER TABLE customers ADD COLUMN is_fake BOOLEAN DEFAULT FALSE; + + -- Mark all existing customers as fake data + UPDATE customers SET is_fake = TRUE; + + -- Add index for better performance + CREATE INDEX idx_customers_is_fake ON customers(is_fake); + END IF; +END $$; + +-- Migration: Add is_fake column to resource_pools if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'resource_pools' AND column_name = 'is_fake') THEN + ALTER TABLE resource_pools ADD COLUMN is_fake BOOLEAN DEFAULT FALSE; + + -- Mark all existing resources as fake data + UPDATE resource_pools SET is_fake = TRUE; + + -- Add index for better performance + CREATE INDEX idx_resource_pools_is_fake ON resource_pools(is_fake); + END IF; +END $$; + +-- Migration: Add missing columns to sessions table +DO $$ +BEGIN + -- Add license_key column + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'license_key') THEN + ALTER TABLE sessions ADD COLUMN license_key VARCHAR(60); + END IF; + + -- Add username column + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'username') THEN + ALTER TABLE sessions ADD COLUMN username VARCHAR(50); + END IF; + + -- Add computer_name column + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'computer_name') THEN + ALTER TABLE sessions ADD COLUMN computer_name VARCHAR(100); + END IF; + + -- Add hardware_id column + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'hardware_id') THEN + ALTER TABLE sessions ADD COLUMN hardware_id VARCHAR(100); + END IF; + + -- Add app_version column + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'app_version') THEN + ALTER TABLE sessions ADD COLUMN app_version VARCHAR(20); + END IF; + + -- Add login_time as alias for started_at + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'login_time') THEN + ALTER TABLE sessions ADD COLUMN login_time TIMESTAMP WITH TIME ZONE; + UPDATE sessions SET login_time = started_at; + END IF; + + -- Add last_activity as alias for last_heartbeat + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'last_activity') THEN + ALTER TABLE sessions ADD COLUMN last_activity TIMESTAMP WITH TIME ZONE; + UPDATE sessions SET last_activity = last_heartbeat; + END IF; + + -- Add logout_time as alias for ended_at + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'logout_time') THEN + ALTER TABLE sessions ADD COLUMN logout_time TIMESTAMP WITH TIME ZONE; + UPDATE sessions SET logout_time = ended_at; + END IF; + + -- Add active as alias for is_active + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'sessions' AND column_name = 'active') THEN + ALTER TABLE sessions ADD COLUMN active BOOLEAN DEFAULT TRUE; + UPDATE sessions SET active = is_active; + END IF; +END $$; + +-- ===================== LICENSE SERVER TABLES ===================== +-- Following best practices: snake_case for DB fields, clear naming conventions + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- License tokens for offline validation +CREATE TABLE IF NOT EXISTS license_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, + token VARCHAR(512) NOT NULL UNIQUE, + hardware_id VARCHAR(255) NOT NULL, + valid_until TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_validated TIMESTAMP, + validation_count INTEGER DEFAULT 0 +); + +CREATE INDEX idx_token ON license_tokens(token); +CREATE INDEX idx_hardware ON license_tokens(hardware_id); +CREATE INDEX idx_valid_until ON license_tokens(valid_until); + +-- Heartbeat tracking with partitioning support +CREATE TABLE IF NOT EXISTS license_heartbeats ( + id BIGSERIAL, + license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, + hardware_id VARCHAR(255) NOT NULL, + ip_address INET, + user_agent VARCHAR(500), + app_version VARCHAR(50), + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + session_data JSONB, + PRIMARY KEY (id, timestamp) +) PARTITION BY RANGE (timestamp); + +-- Create partitions for the current and next month +CREATE TABLE IF NOT EXISTS license_heartbeats_2025_01 PARTITION OF license_heartbeats + FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); + +CREATE TABLE IF NOT EXISTS license_heartbeats_2025_02 PARTITION OF license_heartbeats + FOR VALUES FROM ('2025-02-01') TO ('2025-03-01'); + +-- Add June 2025 partition for current month +CREATE TABLE IF NOT EXISTS license_heartbeats_2025_06 PARTITION OF license_heartbeats + FOR VALUES FROM ('2025-06-01') TO ('2025-07-01'); + +CREATE INDEX idx_heartbeat_license_time ON license_heartbeats(license_id, timestamp DESC); +CREATE INDEX idx_heartbeat_hardware_time ON license_heartbeats(hardware_id, timestamp DESC); + +-- Activation events tracking +CREATE TABLE IF NOT EXISTS activation_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, + event_type VARCHAR(50) NOT NULL CHECK (event_type IN ('activation', 'deactivation', 'reactivation', 'transfer')), + hardware_id VARCHAR(255), + previous_hardware_id VARCHAR(255), + ip_address INET, + user_agent VARCHAR(500), + success BOOLEAN DEFAULT true, + error_message TEXT, + metadata JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_license_events ON activation_events(license_id, created_at DESC); +CREATE INDEX idx_event_type ON activation_events(event_type, created_at DESC); + +-- API rate limiting +CREATE TABLE IF NOT EXISTS api_rate_limits ( + id SERIAL PRIMARY KEY, + api_key VARCHAR(255) NOT NULL UNIQUE, + requests_per_minute INTEGER DEFAULT 60, + requests_per_hour INTEGER DEFAULT 1000, + requests_per_day INTEGER DEFAULT 10000, + burst_size INTEGER DEFAULT 100, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Anomaly detection +CREATE TABLE IF NOT EXISTS anomaly_detections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + license_id INTEGER REFERENCES licenses(id), + anomaly_type VARCHAR(100) NOT NULL CHECK (anomaly_type IN ('multiple_ips', 'rapid_hardware_change', 'suspicious_pattern', 'concurrent_use', 'geo_anomaly')), + severity VARCHAR(20) NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')), + details JSONB NOT NULL, + detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + resolved BOOLEAN DEFAULT false, + resolved_at TIMESTAMP, + resolved_by VARCHAR(255), + action_taken TEXT +); + +CREATE INDEX idx_unresolved ON anomaly_detections(resolved, severity, detected_at DESC); +CREATE INDEX idx_license_anomalies ON anomaly_detections(license_id, detected_at DESC); + +-- API clients for authentication +CREATE TABLE IF NOT EXISTS api_clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_name VARCHAR(255) NOT NULL, + api_key VARCHAR(255) NOT NULL UNIQUE, + secret_key VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT true, + allowed_endpoints TEXT[], + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Feature flags for gradual rollout +CREATE TABLE IF NOT EXISTS feature_flags ( + id SERIAL PRIMARY KEY, + feature_name VARCHAR(100) NOT NULL UNIQUE, + is_enabled BOOLEAN DEFAULT false, + rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage >= 0 AND rollout_percentage <= 100), + whitelist_license_ids INTEGER[], + blacklist_license_ids INTEGER[], + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Insert default feature flags +INSERT INTO feature_flags (feature_name, is_enabled, rollout_percentage) VALUES + ('anomaly_detection', true, 100), + ('offline_tokens', true, 100), + ('advanced_analytics', false, 0), + ('geo_restriction', false, 0) +ON CONFLICT (feature_name) DO NOTHING; + +-- Session management for concurrent use tracking +CREATE TABLE IF NOT EXISTS active_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, + hardware_id VARCHAR(255) NOT NULL, + session_token VARCHAR(512) NOT NULL UNIQUE, + ip_address INET, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); + +CREATE INDEX idx_session_license ON active_sessions(license_id); +CREATE INDEX idx_session_expires ON active_sessions(expires_at); + +-- Update trigger for updated_at columns +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_api_rate_limits_updated_at BEFORE UPDATE ON api_rate_limits + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_api_clients_updated_at BEFORE UPDATE ON api_clients + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_feature_flags_updated_at BEFORE UPDATE ON feature_flags + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Function to automatically create monthly partitions for heartbeats +CREATE OR REPLACE FUNCTION create_monthly_partition() +RETURNS void AS $$ +DECLARE + start_date date; + end_date date; + partition_name text; +BEGIN + start_date := date_trunc('month', CURRENT_DATE + interval '1 month'); + end_date := start_date + interval '1 month'; + partition_name := 'license_heartbeats_' || to_char(start_date, 'YYYY_MM'); + + EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)', + partition_name, start_date, end_date); +END; +$$ LANGUAGE plpgsql; + +-- Migration: Add max_devices column to licenses if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'max_devices') THEN + ALTER TABLE licenses ADD COLUMN max_devices INTEGER DEFAULT 3 CHECK (max_devices >= 1); + END IF; +END $$; + +-- Migration: Add expires_at column to licenses if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'expires_at') THEN + ALTER TABLE licenses ADD COLUMN expires_at TIMESTAMP; + -- Set expires_at based on valid_until for existing licenses + UPDATE licenses SET expires_at = valid_until::timestamp WHERE expires_at IS NULL; + END IF; +END $$; + +-- Migration: Add features column to licenses if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'features') THEN + ALTER TABLE licenses ADD COLUMN features TEXT[] DEFAULT '{}'; + END IF; +END $$; + +-- Migration: Add updated_at column to licenses if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'updated_at') THEN + ALTER TABLE licenses ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + CREATE TRIGGER update_licenses_updated_at BEFORE UPDATE ON licenses + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + + +-- Migration: Add device_type column to device_registrations table +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'device_registrations' AND column_name = 'device_type') THEN + ALTER TABLE device_registrations ADD COLUMN device_type VARCHAR(50) DEFAULT 'unknown'; + + -- Update existing records to have a device_type based on operating system + UPDATE device_registrations + SET device_type = CASE + WHEN operating_system ILIKE '%windows%' THEN 'desktop' + WHEN operating_system ILIKE '%mac%' THEN 'desktop' + WHEN operating_system ILIKE '%linux%' THEN 'desktop' + WHEN operating_system ILIKE '%android%' THEN 'mobile' + WHEN operating_system ILIKE '%ios%' THEN 'mobile' + ELSE 'unknown' + END + WHERE device_type IS NULL OR device_type = 'unknown'; + END IF; +END $$; + +-- Client configuration table for Account Forger +CREATE TABLE IF NOT EXISTS client_configs ( + id SERIAL PRIMARY KEY, + client_name VARCHAR(100) NOT NULL DEFAULT 'Account Forger', + api_key VARCHAR(255) NOT NULL, + heartbeat_interval INTEGER DEFAULT 30, -- seconds + session_timeout INTEGER DEFAULT 60, -- seconds (2x heartbeat) + current_version VARCHAR(20) NOT NULL, + minimum_version VARCHAR(20) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- License sessions for single-session enforcement +CREATE TABLE IF NOT EXISTS license_sessions ( + id SERIAL PRIMARY KEY, + license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, + hardware_id VARCHAR(255) NOT NULL, + ip_address INET, + client_version VARCHAR(20), + session_token VARCHAR(255) UNIQUE NOT NULL, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_heartbeat TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(license_id) -- Only one active session per license +); + +-- Session history for debugging +CREATE TABLE IF NOT EXISTS session_history ( + id SERIAL PRIMARY KEY, + license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE, + hardware_id VARCHAR(255) NOT NULL, + ip_address INET, + client_version VARCHAR(20), + started_at TIMESTAMP, + ended_at TIMESTAMP, + end_reason VARCHAR(50) -- 'normal', 'timeout', 'forced', 'replaced' +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_license_sessions_license_id ON license_sessions(license_id); +CREATE INDEX IF NOT EXISTS idx_license_sessions_last_heartbeat ON license_sessions(last_heartbeat); +CREATE INDEX IF NOT EXISTS idx_session_history_license_id ON session_history(license_id); +CREATE INDEX IF NOT EXISTS idx_session_history_ended_at ON session_history(ended_at); + +-- Insert default client configuration if not exists +INSERT INTO client_configs (client_name, api_key, current_version, minimum_version) +VALUES ('Account Forger', 'AF-' || gen_random_uuid()::text, '1.0.0', '1.0.0') +ON CONFLICT DO NOTHING; + +-- ===================== SYSTEM API KEY TABLE ===================== +-- Single API key for system-wide authentication +CREATE TABLE IF NOT EXISTS system_api_key ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), -- Ensures single row + api_key VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + regenerated_at TIMESTAMP WITH TIME ZONE, + last_used_at TIMESTAMP WITH TIME ZONE, + usage_count INTEGER DEFAULT 0, + created_by VARCHAR(50), + regenerated_by VARCHAR(50) +); + +-- Function to generate API key with AF-YYYY- prefix +CREATE OR REPLACE FUNCTION generate_api_key() RETURNS VARCHAR AS $$ +DECLARE + year_part VARCHAR(4); + random_part VARCHAR(32); +BEGIN + year_part := to_char(CURRENT_DATE, 'YYYY'); + random_part := upper(substring(md5(random()::text || clock_timestamp()::text) from 1 for 32)); + RETURN 'AF-' || year_part || '-' || random_part; +END; +$$ LANGUAGE plpgsql; + +-- Initialize with a default API key if none exists +INSERT INTO system_api_key (api_key, created_by) +SELECT generate_api_key(), 'system' +WHERE NOT EXISTS (SELECT 1 FROM system_api_key); + +-- Audit trigger for API key changes +CREATE OR REPLACE FUNCTION audit_api_key_changes() RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND OLD.api_key != NEW.api_key THEN + INSERT INTO audit_log ( + timestamp, + username, + action, + entity_type, + entity_id, + old_values, + new_values, + additional_info + ) VALUES ( + CURRENT_TIMESTAMP, + COALESCE(NEW.regenerated_by, 'system'), + 'api_key_regenerated', + 'system_api_key', + NEW.id, + jsonb_build_object('api_key', LEFT(OLD.api_key, 8) || '...'), + jsonb_build_object('api_key', LEFT(NEW.api_key, 8) || '...'), + 'API Key regenerated' + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER audit_system_api_key_changes +AFTER UPDATE ON system_api_key +FOR EACH ROW EXECUTE FUNCTION audit_api_key_changes(); diff --git a/v2_adminpanel/leads/__init__.py b/v2_adminpanel/leads/__init__.py new file mode 100644 index 0000000..fa003bc --- /dev/null +++ b/v2_adminpanel/leads/__init__.py @@ -0,0 +1,6 @@ +# 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 new file mode 100644 index 0000000..7671422 --- /dev/null +++ b/v2_adminpanel/leads/models.py @@ -0,0 +1,48 @@ +# Lead Management Data Models +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional, Dict, Any +from uuid import UUID + +@dataclass +class Institution: + id: UUID + name: str + metadata: Dict[str, Any] + created_at: datetime + updated_at: datetime + created_by: str + contact_count: Optional[int] = 0 + +@dataclass +class Contact: + id: UUID + institution_id: UUID + first_name: str + last_name: str + position: Optional[str] + extra_fields: Dict[str, Any] + created_at: datetime + updated_at: datetime + institution_name: Optional[str] = None + +@dataclass +class ContactDetail: + id: UUID + contact_id: UUID + detail_type: str # 'phone', 'email' + detail_value: str + detail_label: Optional[str] # 'Mobil', 'Geschäftlich', etc. + is_primary: bool + created_at: datetime + +@dataclass +class Note: + id: UUID + contact_id: UUID + note_text: str + version: int + is_current: bool + created_at: datetime + created_by: str + parent_note_id: Optional[UUID] = None \ No newline at end of file diff --git a/v2_adminpanel/leads/repositories.py b/v2_adminpanel/leads/repositories.py new file mode 100644 index 0000000..55123d4 --- /dev/null +++ b/v2_adminpanel/leads/repositories.py @@ -0,0 +1,359 @@ +# Database Repository for Lead Management +import psycopg2 +from psycopg2.extras import RealDictCursor +from uuid import UUID +from typing import List, Optional, Dict, Any +from datetime import datetime + +class LeadRepository: + def __init__(self, get_db_connection): + self.get_db_connection = get_db_connection + + # Institution Methods + def get_institutions_with_counts(self) -> List[Dict[str, Any]]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = """ + SELECT + i.id, + i.name, + i.metadata, + i.created_at, + i.updated_at, + i.created_by, + COUNT(c.id) as contact_count + FROM lead_institutions i + LEFT JOIN lead_contacts c ON c.institution_id = i.id + GROUP BY i.id + ORDER BY i.name + """ + + cur.execute(query) + results = cur.fetchall() + cur.close() + + return results + + def create_institution(self, name: str, created_by: str) -> Dict[str, Any]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = """ + INSERT INTO lead_institutions (name, created_by) + VALUES (%s, %s) + RETURNING * + """ + + cur.execute(query, (name, created_by)) + result = cur.fetchone() + cur.close() + + return result + + def get_institution_by_id(self, institution_id: UUID) -> Optional[Dict[str, Any]]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = """ + SELECT * FROM lead_institutions WHERE id = %s + """ + + cur.execute(query, (str(institution_id),)) + result = cur.fetchone() + cur.close() + + return result + + def update_institution(self, institution_id: UUID, name: str) -> Dict[str, Any]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = """ + UPDATE lead_institutions + SET name = %s, updated_at = CURRENT_TIMESTAMP + WHERE id = %s + RETURNING * + """ + + cur.execute(query, (name, str(institution_id))) + result = cur.fetchone() + cur.close() + + return result + + # Contact Methods + def get_contacts_by_institution(self, institution_id: UUID) -> List[Dict[str, Any]]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = """ + SELECT + c.*, + i.name as institution_name + FROM lead_contacts c + JOIN lead_institutions i ON i.id = c.institution_id + WHERE c.institution_id = %s + ORDER BY c.last_name, c.first_name + """ + + cur.execute(query, (str(institution_id),)) + results = cur.fetchall() + cur.close() + + return results + + def create_contact(self, data: Dict[str, Any]) -> Dict[str, Any]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = """ + INSERT INTO lead_contacts + (institution_id, first_name, last_name, position, extra_fields) + VALUES (%s, %s, %s, %s, %s) + RETURNING * + """ + + cur.execute(query, ( + str(data['institution_id']), + data['first_name'], + data['last_name'], + data.get('position'), + psycopg2.extras.Json(data.get('extra_fields', {})) + )) + result = cur.fetchone() + cur.close() + + return result + + def get_contact_with_details(self, contact_id: UUID) -> Dict[str, Any]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + # Get contact base info + query = """ + SELECT + c.*, + i.name as institution_name + FROM lead_contacts c + JOIN lead_institutions i ON i.id = c.institution_id + WHERE c.id = %s + """ + + cur.execute(query, (str(contact_id),)) + contact = cur.fetchone() + + if contact: + # Get contact details (phones, emails) + details_query = """ + SELECT * FROM lead_contact_details + WHERE contact_id = %s + ORDER BY detail_type, is_primary DESC, created_at + """ + cur.execute(details_query, (str(contact_id),)) + contact['details'] = cur.fetchall() + + # Get notes + notes_query = """ + SELECT * FROM lead_notes + WHERE contact_id = %s AND is_current = true + ORDER BY created_at DESC + """ + cur.execute(notes_query, (str(contact_id),)) + contact['notes'] = cur.fetchall() + + cur.close() + + return contact + + def update_contact(self, contact_id: UUID, data: Dict[str, Any]) -> Dict[str, Any]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = """ + UPDATE lead_contacts + SET first_name = %s, last_name = %s, position = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + RETURNING * + """ + + cur.execute(query, ( + data['first_name'], + data['last_name'], + data.get('position'), + str(contact_id) + )) + result = cur.fetchone() + cur.close() + + return result + + # Contact Details Methods + def add_contact_detail(self, contact_id: UUID, detail_type: str, + detail_value: str, detail_label: str = None) -> Dict[str, Any]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = """ + INSERT INTO lead_contact_details + (contact_id, detail_type, detail_value, detail_label) + VALUES (%s, %s, %s, %s) + RETURNING * + """ + + cur.execute(query, ( + str(contact_id), + detail_type, + detail_value, + detail_label + )) + result = cur.fetchone() + cur.close() + + return result + + def get_contact_detail_by_id(self, detail_id: UUID) -> Optional[Dict[str, Any]]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = "SELECT * FROM lead_contact_details WHERE id = %s" + cur.execute(query, (str(detail_id),)) + result = cur.fetchone() + cur.close() + + return result + + def update_contact_detail(self, detail_id: UUID, detail_value: str, + detail_label: str = None) -> Dict[str, Any]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = """ + UPDATE lead_contact_details + SET detail_value = %s, detail_label = %s, updated_at = CURRENT_TIMESTAMP + WHERE id = %s + RETURNING * + """ + + cur.execute(query, (detail_value, detail_label, str(detail_id))) + result = cur.fetchone() + cur.close() + + return result + + def delete_contact_detail(self, detail_id: UUID) -> bool: + with self.get_db_connection() as conn: + cur = conn.cursor() + + query = "DELETE FROM lead_contact_details WHERE id = %s" + cur.execute(query, (str(detail_id),)) + + deleted = cur.rowcount > 0 + cur.close() + + return deleted + + # Notes Methods + def create_note(self, contact_id: UUID, note_text: str, created_by: str) -> Dict[str, Any]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = """ + INSERT INTO lead_notes + (contact_id, note_text, created_by) + VALUES (%s, %s, %s) + RETURNING * + """ + + cur.execute(query, ( + str(contact_id), + note_text, + created_by + )) + result = cur.fetchone() + cur.close() + + return result + + def update_note(self, note_id: UUID, note_text: str, updated_by: str) -> Dict[str, Any]: + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + # First, mark current version as not current + update_old = """ + UPDATE lead_notes + SET is_current = false + WHERE id = %s + """ + cur.execute(update_old, (str(note_id),)) + + # Create new version + create_new = """ + INSERT INTO lead_notes + (contact_id, note_text, created_by, parent_note_id, version) + SELECT contact_id, %s, %s, %s, version + 1 + FROM lead_notes + WHERE id = %s + RETURNING * + """ + + cur.execute(create_new, ( + note_text, + updated_by, + str(note_id), + str(note_id) + )) + result = cur.fetchone() + cur.close() + + return result + + def delete_note(self, note_id: UUID) -> bool: + with self.get_db_connection() as conn: + cur = conn.cursor() + + # Soft delete by marking as not current + query = """ + UPDATE lead_notes + SET is_current = false + WHERE id = %s + """ + cur.execute(query, (str(note_id),)) + + deleted = cur.rowcount > 0 + cur.close() + + return deleted + + def get_all_contacts_with_institutions(self) -> List[Dict[str, Any]]: + """Get all contacts with their institution information""" + with self.get_db_connection() as conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + + query = """ + SELECT + c.id, + c.first_name, + c.last_name, + c.position, + c.created_at, + c.updated_at, + c.institution_id, + i.name as institution_name, + (SELECT COUNT(*) FROM lead_contact_details + WHERE contact_id = c.id AND detail_type = 'phone') as phone_count, + (SELECT COUNT(*) FROM lead_contact_details + WHERE contact_id = c.id AND detail_type = 'email') as email_count, + (SELECT COUNT(*) FROM lead_notes + WHERE contact_id = c.id AND is_current = true) as note_count + FROM lead_contacts c + JOIN lead_institutions i ON i.id = c.institution_id + ORDER BY c.last_name, c.first_name + """ + + cur.execute(query) + results = cur.fetchall() + cur.close() + + return results \ No newline at end of file diff --git a/v2_adminpanel/leads/routes.py b/v2_adminpanel/leads/routes.py new file mode 100644 index 0000000..136f545 --- /dev/null +++ b/v2_adminpanel/leads/routes.py @@ -0,0 +1,397 @@ +# Routes for Lead Management +from flask import render_template, request, jsonify, redirect, url_for, flash +from auth.decorators import login_required +from flask import session as flask_session +from . import leads_bp +from .services import LeadService +from .repositories import LeadRepository +from db import get_db_connection +from uuid import UUID +import traceback + +# Service will be initialized per request +lead_repository = None +lead_service = None + +def get_lead_service(): + """Get or create lead service instance""" + global lead_repository, lead_service + if lead_service is None: + lead_repository = LeadRepository(get_db_connection) # Pass the function, not call it + lead_service = LeadService(lead_repository) + return lead_service + +# HTML Routes +@leads_bp.route('/management') +@login_required +def lead_management(): + """Lead Management Dashboard""" + try: + # Get institutions with contact counts + institutions = get_lead_service().list_institutions() + + # Get all contacts with institution names + all_contacts = get_lead_service().list_all_contacts() + + # Calculate totals + total_institutions = len(institutions) + total_contacts = len(all_contacts) + + return render_template('leads/lead_management.html', + total_institutions=total_institutions, + total_contacts=total_contacts, + institutions=institutions, + all_contacts=all_contacts) + except Exception as e: + import traceback + print(f"Error in lead_management: {str(e)}") + print(traceback.format_exc()) + flash(f'Fehler beim Laden des Dashboards: {str(e)}', 'error') + current_user = flask_session.get('username', 'System') + return render_template('leads/lead_management.html', + total_institutions=0, + total_contacts=0, + institutions=[], + all_contacts=[]) + +@leads_bp.route('/') +@login_required +def institutions(): + """List all institutions""" + try: + institutions = get_lead_service().list_institutions() + return render_template('leads/institutions.html', institutions=institutions) + except Exception as e: + flash(f'Fehler beim Laden der Institutionen: {str(e)}', 'error') + return render_template('leads/institutions.html', institutions=[]) + +@leads_bp.route('/institution/add', methods=['POST']) +@login_required +def add_institution(): + """Add new institution from form""" + try: + name = request.form.get('name') + if not name: + flash('Name ist erforderlich', 'error') + return redirect(url_for('leads.lead_management')) + + # Add institution + get_lead_service().create_institution(name, flask_session.get('username', 'System')) + flash(f'Institution "{name}" wurde erfolgreich hinzugefügt', 'success') + except Exception as e: + flash(f'Fehler beim Hinzufügen der Institution: {str(e)}', 'error') + + return redirect(url_for('leads.lead_management')) + +@leads_bp.route('/contact/add', methods=['POST']) +@login_required +def add_contact(): + """Add new contact from form""" + try: + data = { + 'institution_id': request.form.get('institution_id'), + 'first_name': request.form.get('first_name'), + 'last_name': request.form.get('last_name'), + 'position': request.form.get('position') + } + + # Validate required fields + if not data['institution_id'] or not data['first_name'] or not data['last_name']: + flash('Institution, Vorname und Nachname sind erforderlich', 'error') + return redirect(url_for('leads.lead_management')) + + # Create contact + contact = get_lead_service().create_contact(data, flask_session.get('username', 'System')) + + # Add email if provided + email = request.form.get('email') + if email: + get_lead_service().add_email(contact['id'], email, 'Primär', flask_session.get('username', 'System')) + + # Add phone if provided + phone = request.form.get('phone') + if phone: + get_lead_service().add_phone(contact['id'], phone, 'Primär', flask_session.get('username', 'System')) + + flash(f'Kontakt "{data["first_name"]} {data["last_name"]}" wurde erfolgreich hinzugefügt', 'success') + except Exception as e: + flash(f'Fehler beim Hinzufügen des Kontakts: {str(e)}', 'error') + + return redirect(url_for('leads.lead_management')) + +@leads_bp.route('/institution/') +@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 new file mode 100644 index 0000000..64e9265 --- /dev/null +++ b/v2_adminpanel/leads/services.py @@ -0,0 +1,171 @@ +# Business Logic Service for Lead Management +from typing import List, Dict, Any, Optional +from uuid import UUID +from datetime import datetime +from .repositories import LeadRepository + +class LeadService: + def __init__(self, repository: LeadRepository): + self.repo = repository + + # Institution Services + def list_institutions(self) -> List[Dict[str, Any]]: + """Get all institutions with contact counts""" + return self.repo.get_institutions_with_counts() + + def create_institution(self, name: str, user: str) -> Dict[str, Any]: + """Create a new institution""" + # Validation + if not name or len(name.strip()) == 0: + raise ValueError("Institution name cannot be empty") + + # Create institution + institution = self.repo.create_institution(name.strip(), user) + + # Note: Audit logging removed as it requires different implementation + # Can be added later with proper audit system integration + + return institution + + def update_institution(self, institution_id: UUID, name: str, user: str) -> Dict[str, Any]: + """Update institution name""" + # Validation + if not name or len(name.strip()) == 0: + raise ValueError("Institution name cannot be empty") + + # Get current institution + current = self.repo.get_institution_by_id(institution_id) + if not current: + raise ValueError("Institution not found") + + # Update + institution = self.repo.update_institution(institution_id, name.strip()) + + return institution + + # Contact Services + def list_contacts_by_institution(self, institution_id: UUID) -> List[Dict[str, Any]]: + """Get all contacts for an institution""" + return self.repo.get_contacts_by_institution(institution_id) + + def create_contact(self, data: Dict[str, Any], user: str) -> Dict[str, Any]: + """Create a new contact""" + # Validation + if not data.get('first_name') or not data.get('last_name'): + raise ValueError("First and last name are required") + + if not data.get('institution_id'): + raise ValueError("Institution ID is required") + + # Create contact + contact = self.repo.create_contact(data) + + return contact + + def get_contact_details(self, contact_id: UUID) -> Dict[str, Any]: + """Get full contact information including details and notes""" + contact = self.repo.get_contact_with_details(contact_id) + if not contact: + raise ValueError("Contact not found") + + # Group details by type + contact['phones'] = [d for d in contact.get('details', []) if d['detail_type'] == 'phone'] + contact['emails'] = [d for d in contact.get('details', []) if d['detail_type'] == 'email'] + + return contact + + def update_contact(self, contact_id: UUID, data: Dict[str, Any], user: str) -> Dict[str, Any]: + """Update contact information""" + # Validation + if not data.get('first_name') or not data.get('last_name'): + raise ValueError("First and last name are required") + + # Update contact + contact = self.repo.update_contact(contact_id, data) + + return contact + + # Contact Details Services + def add_phone(self, contact_id: UUID, phone_number: str, + phone_type: str = None, user: str = None) -> Dict[str, Any]: + """Add phone number to contact""" + if not phone_number: + raise ValueError("Phone number is required") + + detail = self.repo.add_contact_detail( + contact_id, 'phone', phone_number, phone_type + ) + + return detail + + def add_email(self, contact_id: UUID, email: str, + email_type: str = None, user: str = None) -> Dict[str, Any]: + """Add email to contact""" + if not email: + raise ValueError("Email is required") + + # Basic email validation + if '@' not in email: + raise ValueError("Invalid email format") + + detail = self.repo.add_contact_detail( + contact_id, 'email', email, email_type + ) + + return detail + + def update_contact_detail(self, detail_id: UUID, detail_value: str, + detail_label: str = None, user: str = None) -> Dict[str, Any]: + """Update a contact detail (phone/email)""" + if not detail_value or len(detail_value.strip()) == 0: + raise ValueError("Detail value cannot be empty") + + # Get current detail to check type + current_detail = self.repo.get_contact_detail_by_id(detail_id) + if not current_detail: + raise ValueError("Contact detail not found") + + # Validation based on type + if current_detail['detail_type'] == 'email' and '@' not in detail_value: + raise ValueError("Invalid email format") + + detail = self.repo.update_contact_detail( + detail_id, detail_value.strip(), detail_label + ) + + return detail + + def delete_contact_detail(self, detail_id: UUID, user: str) -> bool: + """Delete a contact detail (phone/email)""" + success = self.repo.delete_contact_detail(detail_id) + + return success + + # Note Services + def add_note(self, contact_id: UUID, note_text: str, user: str) -> Dict[str, Any]: + """Add a note to contact""" + if not note_text or len(note_text.strip()) == 0: + raise ValueError("Note text cannot be empty") + + note = self.repo.create_note(contact_id, note_text.strip(), user) + + return note + + def update_note(self, note_id: UUID, note_text: str, user: str) -> Dict[str, Any]: + """Update a note (creates new version)""" + if not note_text or len(note_text.strip()) == 0: + raise ValueError("Note text cannot be empty") + + note = self.repo.update_note(note_id, note_text.strip(), user) + + return note + + def delete_note(self, note_id: UUID, user: str) -> bool: + """Delete a note (soft delete)""" + success = self.repo.delete_note(note_id) + + return success + + def list_all_contacts(self) -> List[Dict[str, Any]]: + """Get all contacts across all institutions with summary info""" + return self.repo.get_all_contacts_with_institutions() \ 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 new file mode 100644 index 0000000..ecddeaa --- /dev/null +++ b/v2_adminpanel/leads/templates/leads/all_contacts.html @@ -0,0 +1,239 @@ +{% 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 new file mode 100644 index 0000000..58c8794 --- /dev/null +++ b/v2_adminpanel/leads/templates/leads/contact_detail.html @@ -0,0 +1,622 @@ +{% 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 new file mode 100644 index 0000000..8dc6a4d --- /dev/null +++ b/v2_adminpanel/leads/templates/leads/institution_detail.html @@ -0,0 +1,159 @@ +{% 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 new file mode 100644 index 0000000..d4635da --- /dev/null +++ b/v2_adminpanel/leads/templates/leads/institutions.html @@ -0,0 +1,189 @@ +{% 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 new file mode 100644 index 0000000..6056181 --- /dev/null +++ b/v2_adminpanel/leads/templates/leads/lead_management.html @@ -0,0 +1,367 @@ +{% 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 new file mode 100644 index 0000000..1186bdf --- /dev/null +++ b/v2_adminpanel/middleware/__init__.py @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..3938fc7 --- /dev/null +++ b/v2_adminpanel/middleware/error_middleware.py @@ -0,0 +1,54 @@ +import time +import uuid +from typing import Optional +from flask import request, g +from werkzeug.exceptions import HTTPException + +from core.exceptions import BaseApplicationException +from core.monitoring import track_error +from core.logging_config import get_logger + + +logger = get_logger(__name__) + + +class ErrorHandlingMiddleware: + def __init__(self, app=None): + self.app = app + if app: + self.init_app(app) + + def init_app(self, app): + app.before_request(self._before_request) + app.teardown_appcontext(self._teardown_request) + + def _before_request(self): + g.request_id = request.headers.get('X-Request-ID', str(uuid.uuid4())) + g.start_time = time.time() + g.errors = [] + + def _teardown_request(self, exception=None): + if exception: + self._handle_exception(exception) + + if hasattr(g, 'errors') and g.errors: + for error in g.errors: + if isinstance(error, BaseApplicationException): + track_error(error) + + def _handle_exception(self, exception): + if isinstance(exception, BaseApplicationException): + track_error(exception) + elif isinstance(exception, HTTPException): + pass + else: + logger.error( + f"Unhandled exception: {type(exception).__name__}", + exc_info=True, + extra={ + 'request_id': getattr(g, 'request_id', 'unknown'), + 'endpoint': request.endpoint, + 'method': request.method, + 'path': request.path + } + ) \ No newline at end of file diff --git a/v2_adminpanel/migrations/add_device_type.sql b/v2_adminpanel/migrations/add_device_type.sql new file mode 100644 index 0000000..5d0ad66 --- /dev/null +++ b/v2_adminpanel/migrations/add_device_type.sql @@ -0,0 +1,20 @@ +-- Migration: Add device_type column to device_registrations table +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'device_registrations' AND column_name = 'device_type') THEN + ALTER TABLE device_registrations ADD COLUMN device_type VARCHAR(50) DEFAULT 'unknown'; + + -- Update existing records to have a device_type based on operating system + UPDATE device_registrations + SET device_type = CASE + WHEN operating_system ILIKE '%windows%' THEN 'desktop' + WHEN operating_system ILIKE '%mac%' THEN 'desktop' + WHEN operating_system ILIKE '%linux%' THEN 'desktop' + WHEN operating_system ILIKE '%android%' THEN 'mobile' + WHEN operating_system ILIKE '%ios%' THEN 'mobile' + ELSE 'unknown' + END + WHERE device_type IS NULL OR device_type = 'unknown'; + END IF; +END $$; \ No newline at end of file diff --git a/v2_adminpanel/migrations/add_fake_constraint.sql b/v2_adminpanel/migrations/add_fake_constraint.sql new file mode 100644 index 0000000..e0c93e7 --- /dev/null +++ b/v2_adminpanel/migrations/add_fake_constraint.sql @@ -0,0 +1,72 @@ +-- Add constraint to ensure licenses always inherit is_fake from their customer +-- This migration adds a trigger to automatically sync is_fake status + +-- Function to sync is_fake status +CREATE OR REPLACE FUNCTION sync_license_fake_status() +RETURNS TRIGGER AS $$ +BEGIN + -- When inserting or updating a license, get is_fake from customer + IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND NEW.customer_id != OLD.customer_id) THEN + SELECT is_fake INTO NEW.is_fake + FROM customers + WHERE id = NEW.customer_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger for licenses table +DROP TRIGGER IF EXISTS sync_license_fake_before_insert_update ON licenses; +CREATE TRIGGER sync_license_fake_before_insert_update + BEFORE INSERT OR UPDATE ON licenses + FOR EACH ROW + EXECUTE FUNCTION sync_license_fake_status(); + +-- Function to update licenses when customer is_fake changes +CREATE OR REPLACE FUNCTION sync_customer_fake_to_licenses() +RETURNS TRIGGER AS $$ +BEGIN + -- When customer is_fake changes, update all their licenses + IF TG_OP = 'UPDATE' AND NEW.is_fake != OLD.is_fake THEN + UPDATE licenses + SET is_fake = NEW.is_fake + WHERE customer_id = NEW.id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger for customers table +DROP TRIGGER IF EXISTS sync_customer_fake_after_update ON customers; +CREATE TRIGGER sync_customer_fake_after_update + AFTER UPDATE ON customers + FOR EACH ROW + EXECUTE FUNCTION sync_customer_fake_to_licenses(); + +-- Verify current data is consistent (should return 0) +DO $$ +DECLARE + mismatch_count INTEGER; +BEGIN + SELECT COUNT(*) INTO mismatch_count + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.is_fake != c.is_fake; + + IF mismatch_count > 0 THEN + RAISE NOTICE 'Found % mismatches. Fixing...', mismatch_count; + + -- Fix any existing mismatches + UPDATE licenses l + SET is_fake = c.is_fake + FROM customers c + WHERE l.customer_id = c.id + AND l.is_fake != c.is_fake; + + RAISE NOTICE 'Fixed all mismatches.'; + ELSE + RAISE NOTICE 'No mismatches found. Data is consistent.'; + END IF; +END $$; \ 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 new file mode 100644 index 0000000..4a405f4 --- /dev/null +++ b/v2_adminpanel/migrations/add_june_2025_partition.sql @@ -0,0 +1,58 @@ +-- Migration: Add June 2025 partition for license_heartbeats table +-- This migration adds the missing partition for the current month (June 2025) + +-- Check if the partition already exists before creating it +DO $$ +BEGIN + -- Check if the June 2025 partition exists + IF NOT EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = 'license_heartbeats_2025_06' + ) THEN + -- Create the June 2025 partition + EXECUTE 'CREATE TABLE license_heartbeats_2025_06 PARTITION OF license_heartbeats + FOR VALUES FROM (''2025-06-01'') TO (''2025-07-01'')'; + + RAISE NOTICE 'Created partition license_heartbeats_2025_06'; + ELSE + RAISE NOTICE 'Partition license_heartbeats_2025_06 already exists'; + END IF; +END $$; + +-- Also create partitions for the next few months to avoid future issues +DO $$ +DECLARE + partition_name text; + start_date date; + end_date date; + i integer; +BEGIN + -- Create partitions for the next 6 months + FOR i IN 0..6 LOOP + start_date := date_trunc('month', CURRENT_DATE + (i || ' months')::interval); + end_date := start_date + interval '1 month'; + partition_name := 'license_heartbeats_' || to_char(start_date, 'YYYY_MM'); + + -- Check if partition already exists + IF NOT EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = partition_name + ) THEN + EXECUTE format('CREATE TABLE %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)', + partition_name, start_date, end_date); + + RAISE NOTICE 'Created partition %', partition_name; + END IF; + END LOOP; +END $$; + +-- Verify the partitions were created +SELECT + schemaname, + tablename, + tableowner +FROM pg_tables +WHERE tablename LIKE 'license_heartbeats_%' +ORDER BY tablename; \ 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 new file mode 100644 index 0000000..1f5f222 --- /dev/null +++ b/v2_adminpanel/migrations/cleanup_orphaned_api_tables.sql @@ -0,0 +1,17 @@ +-- Cleanup orphaned API-related tables +-- Since admin panel is exclusively for Account Forger, we only need system_api_key table + +-- Drop tables that depend on api_clients +DROP TABLE IF EXISTS rate_limits CASCADE; +DROP TABLE IF EXISTS license_events CASCADE; + +-- Drop orphaned API tables +DROP TABLE IF EXISTS api_clients CASCADE; +DROP TABLE IF EXISTS api_keys CASCADE; + +-- Add comments to document the single API key system +COMMENT ON TABLE system_api_key IS 'Single API key table for Account Forger authentication. This is the ONLY API key system in use.'; + +-- Log the cleanup +INSERT INTO audit_log (username, action, entity_type, details, ip_address) +VALUES ('SYSTEM', 'CLEANUP', 'database', 'Removed orphaned API tables: api_keys, api_clients, rate_limits, license_events', '127.0.0.1'); \ No newline at end of file diff --git a/v2_adminpanel/migrations/create_lead_tables.sql b/v2_adminpanel/migrations/create_lead_tables.sql new file mode 100644 index 0000000..aa6e3f2 --- /dev/null +++ b/v2_adminpanel/migrations/create_lead_tables.sql @@ -0,0 +1,107 @@ +-- Lead Management Tables Migration +-- This creates all necessary tables for the lead management system + +-- Enable UUID extension if not already enabled +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- 1. Lead Institutions (only name required) +CREATE TABLE IF NOT EXISTS lead_institutions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + -- Metadata for future extensions without schema changes + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + UNIQUE(name) +); + +-- Index for fast lookups +CREATE INDEX IF NOT EXISTS idx_lead_institutions_name ON lead_institutions(name); + +-- 2. Lead Contacts +CREATE TABLE IF NOT EXISTS lead_contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + institution_id UUID NOT NULL REFERENCES lead_institutions(id) ON DELETE CASCADE, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + position VARCHAR(255), + -- Extra fields for future extensions + extra_fields JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_lead_contacts_institution ON lead_contacts(institution_id); +CREATE INDEX IF NOT EXISTS idx_lead_contacts_name ON lead_contacts(last_name, first_name); + +-- 3. Flexible Contact Details (phones, emails, etc.) +CREATE TABLE IF NOT EXISTS lead_contact_details ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contact_id UUID NOT NULL REFERENCES lead_contacts(id) ON DELETE CASCADE, + detail_type VARCHAR(50) NOT NULL, -- 'phone', 'email', 'social', etc. + detail_value VARCHAR(255) NOT NULL, + detail_label VARCHAR(50), -- 'Mobil', 'Geschäftlich', 'Privat', etc. + is_primary BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for fast queries +CREATE INDEX IF NOT EXISTS idx_lead_details_contact_type ON lead_contact_details(contact_id, detail_type); +CREATE INDEX IF NOT EXISTS idx_lead_details_value ON lead_contact_details(detail_value); + +-- 4. Versioned Notes with History +CREATE TABLE IF NOT EXISTS lead_notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contact_id UUID NOT NULL REFERENCES lead_contacts(id) ON DELETE CASCADE, + note_text TEXT NOT NULL, + version INTEGER DEFAULT 1, + is_current BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100), + parent_note_id UUID REFERENCES lead_notes(id), + CHECK (note_text <> '') +); + +-- Indexes for note queries +CREATE INDEX IF NOT EXISTS idx_lead_notes_contact_current ON lead_notes(contact_id, is_current); +CREATE INDEX IF NOT EXISTS idx_lead_notes_created ON lead_notes(created_at DESC); + +-- Full text search preparation +CREATE INDEX IF NOT EXISTS idx_lead_contacts_search ON lead_contacts +USING gin(to_tsvector('german', + COALESCE(first_name, '') || ' ' || + COALESCE(last_name, '') || ' ' || + COALESCE(position, '') +)); + +-- Update timestamp trigger function +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Apply update trigger to tables with updated_at +CREATE TRIGGER update_lead_institutions_updated_at + BEFORE UPDATE ON lead_institutions + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_lead_contacts_updated_at + BEFORE UPDATE ON lead_contacts + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Add comments for documentation +COMMENT ON TABLE lead_institutions IS 'Organizations/Companies for lead management'; +COMMENT ON TABLE lead_contacts IS 'Contact persons within institutions'; +COMMENT ON TABLE lead_contact_details IS 'Flexible contact details (phone, email, etc.)'; +COMMENT ON TABLE lead_notes IS 'Versioned notes with full history'; + +COMMENT ON COLUMN lead_contact_details.detail_type IS 'Type of detail: phone, email, social, etc.'; +COMMENT ON COLUMN lead_notes.is_current IS 'Only current version is shown, old versions kept for history'; +COMMENT ON COLUMN lead_notes.parent_note_id IS 'References original note for version tracking'; \ 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 new file mode 100644 index 0000000..a043122 --- /dev/null +++ b/v2_adminpanel/migrations/create_license_heartbeats_table.sql @@ -0,0 +1,79 @@ +-- Migration: Create license_heartbeats partitioned table +-- Date: 2025-06-19 +-- Description: Creates the license_heartbeats table with monthly partitioning + +-- Create the partitioned table +CREATE TABLE IF NOT EXISTS license_heartbeats ( + id BIGSERIAL, + license_id INTEGER NOT NULL, + hardware_id VARCHAR(255) NOT NULL, + ip_address INET NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + session_data JSONB, + PRIMARY KEY (id, timestamp), + FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE +) PARTITION BY RANGE (timestamp); + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_license_heartbeats_license_id_timestamp + ON license_heartbeats (license_id, timestamp DESC); + +CREATE INDEX IF NOT EXISTS idx_license_heartbeats_timestamp + ON license_heartbeats (timestamp DESC); + +CREATE INDEX IF NOT EXISTS idx_license_heartbeats_hardware_id + ON license_heartbeats (hardware_id); + +CREATE INDEX IF NOT EXISTS idx_license_heartbeats_ip_address + ON license_heartbeats (ip_address); + +-- Create partitions for current and next month +DO $$ +DECLARE + current_year INTEGER; + current_month INTEGER; + next_year INTEGER; + next_month INTEGER; + partition_name TEXT; + start_date DATE; + end_date DATE; +BEGIN + -- Get current date info + current_year := EXTRACT(YEAR FROM CURRENT_DATE); + current_month := EXTRACT(MONTH FROM CURRENT_DATE); + + -- Calculate next month + IF current_month = 12 THEN + next_year := current_year + 1; + next_month := 1; + ELSE + next_year := current_year; + next_month := current_month + 1; + END IF; + + -- Create current month partition + partition_name := 'license_heartbeats_' || current_year || '_' || LPAD(current_month::TEXT, 2, '0'); + start_date := DATE_TRUNC('month', CURRENT_DATE); + end_date := DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'; + + EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)', + partition_name, start_date, end_date); + + -- Create next month partition + partition_name := 'license_heartbeats_' || next_year || '_' || LPAD(next_month::TEXT, 2, '0'); + start_date := end_date; + end_date := start_date + INTERVAL '1 month'; + + EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)', + partition_name, start_date, end_date); + + RAISE NOTICE 'Created partitions for current and next month'; +END $$; + +-- Add comment to the table +COMMENT ON TABLE license_heartbeats IS 'Stores heartbeat data from license validations for real-time monitoring'; +COMMENT ON COLUMN license_heartbeats.license_id IS 'Foreign key to licenses table'; +COMMENT ON COLUMN license_heartbeats.hardware_id IS 'Hardware identifier of the device'; +COMMENT ON COLUMN license_heartbeats.ip_address IS 'IP address from which the heartbeat was sent'; +COMMENT ON COLUMN license_heartbeats.timestamp IS 'Timestamp of the heartbeat'; +COMMENT ON COLUMN license_heartbeats.session_data IS 'Additional session data in JSON format'; \ 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 new file mode 100644 index 0000000..9c52187 --- /dev/null +++ b/v2_adminpanel/migrations/remove_duplicate_api_key.sql @@ -0,0 +1,9 @@ +-- Remove duplicate API key from client_configs table +-- Since admin panel is exclusively for Account Forger, we only need system_api_key + +-- Remove the api_key column from client_configs +ALTER TABLE client_configs DROP COLUMN IF EXISTS api_key; + +-- Update description +COMMENT ON TABLE client_configs IS 'Configuration for Account Forger client (versions, timeouts)'; +COMMENT ON TABLE system_api_key IS 'Single API key for Account Forger authentication'; \ 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 new file mode 100644 index 0000000..f813a0d --- /dev/null +++ b/v2_adminpanel/migrations/rename_test_to_fake.sql @@ -0,0 +1,48 @@ +-- Migration script to rename is_test columns to is_fake +-- This separates fake/demo data from test licenses + +-- 1. Rename columns in all tables +DO $$ +BEGIN + -- Rename is_test to is_fake in customers table + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'customers' AND column_name = 'is_test') THEN + ALTER TABLE customers RENAME COLUMN is_test TO is_fake; + END IF; + + -- Rename is_test to is_fake in licenses table + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'licenses' AND column_name = 'is_test') THEN + ALTER TABLE licenses RENAME COLUMN is_test TO is_fake; + END IF; + + -- Rename is_test to is_fake in resource_pools table + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'resource_pools' AND column_name = 'is_test') THEN + ALTER TABLE resource_pools RENAME COLUMN is_test TO is_fake; + END IF; +END $$; + +-- 2. Rename indexes +DO $$ +BEGIN + -- Rename index for customers + IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_customers_is_test') THEN + ALTER INDEX idx_customers_is_test RENAME TO idx_customers_is_fake; + END IF; + + -- Rename index for licenses + IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_licenses_is_test') THEN + ALTER INDEX idx_licenses_is_test RENAME TO idx_licenses_is_fake; + END IF; + + -- Rename index for resource_pools + IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_resource_pools_is_test') THEN + ALTER INDEX idx_resource_pools_is_test RENAME TO idx_resource_pools_is_fake; + END IF; +END $$; + +-- 3. Add comments to clarify the purpose +COMMENT ON COLUMN customers.is_fake IS 'Marks fake/demo data, not to be confused with test licenses'; +COMMENT ON COLUMN licenses.is_fake IS 'Marks fake/demo data, not to be confused with test license type'; +COMMENT ON COLUMN resource_pools.is_fake IS 'Marks fake/demo resources'; \ No newline at end of file diff --git a/v2_adminpanel/models.py b/v2_adminpanel/models.py new file mode 100644 index 0000000..264fbc5 --- /dev/null +++ b/v2_adminpanel/models.py @@ -0,0 +1,178 @@ +# Temporary models file - will be expanded in Phase 3 +from db import execute_query, get_db_connection, get_db_cursor +import logging + +logger = logging.getLogger(__name__) + + +def get_user_by_username(username): + """Get user from database by username""" + result = execute_query( + """ + SELECT id, username, password_hash, email, totp_secret, totp_enabled, + backup_codes, last_password_change, failed_2fa_attempts + FROM users WHERE username = %s + """, + (username,), + fetch_one=True + ) + + if result: + return { + 'id': result[0], + 'username': result[1], + 'password_hash': result[2], + 'email': result[3], + 'totp_secret': result[4], + 'totp_enabled': result[5], + 'backup_codes': result[6], + 'last_password_change': result[7], + 'failed_2fa_attempts': result[8] + } + return None + + +def get_licenses(show_fake=False): + """Get all licenses from database""" + try: + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + if show_fake: + cur.execute(""" + SELECT l.*, c.name as customer_name + FROM licenses l + LEFT JOIN customers c ON l.customer_id = c.id + ORDER BY l.created_at DESC + """) + else: + cur.execute(""" + SELECT l.*, c.name as customer_name + FROM licenses l + LEFT JOIN customers c ON l.customer_id = c.id + WHERE l.is_fake = false + ORDER BY l.created_at DESC + """) + + columns = [desc[0] for desc in cur.description] + licenses = [] + for row in cur.fetchall(): + license_dict = dict(zip(columns, row)) + licenses.append(license_dict) + return licenses + except Exception as e: + logger.error(f"Error fetching licenses: {str(e)}") + return [] + + +def get_license_by_id(license_id): + """Get a specific license by ID""" + try: + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute(""" + SELECT l.*, c.name as customer_name + FROM licenses l + LEFT JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + + row = cur.fetchone() + if row: + columns = [desc[0] for desc in cur.description] + return dict(zip(columns, row)) + return None + except Exception as e: + logger.error(f"Error fetching license {license_id}: {str(e)}") + return None + + +def get_customers(show_fake=False, search=None): + """Get all customers from database""" + try: + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + query = """ + SELECT c.*, + COUNT(DISTINCT l.id) as license_count, + COUNT(DISTINCT CASE WHEN l.is_active THEN l.id END) as active_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + """ + + where_clauses = [] + params = [] + + if not show_fake: + where_clauses.append("c.is_fake = false") + + if search: + where_clauses.append("(LOWER(c.name) LIKE LOWER(%s) OR LOWER(c.email) LIKE LOWER(%s))") + search_pattern = f'%{search}%' + params.extend([search_pattern, search_pattern]) + + if where_clauses: + query += " WHERE " + " AND ".join(where_clauses) + + query += " GROUP BY c.id ORDER BY c.name" + + cur.execute(query, params) + + columns = [desc[0] for desc in cur.description] + customers = [] + for row in cur.fetchall(): + customer_dict = dict(zip(columns, row)) + customers.append(customer_dict) + return customers + except Exception as e: + logger.error(f"Error fetching customers: {str(e)}") + return [] + + +def get_customer_by_id(customer_id): + """Get a specific customer by ID""" + try: + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute(""" + SELECT c.*, + COUNT(DISTINCT l.id) as license_count, + COUNT(DISTINCT CASE WHEN l.is_active THEN l.id END) as active_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.id = %s + GROUP BY c.id + """, (customer_id,)) + + row = cur.fetchone() + if row: + columns = [desc[0] for desc in cur.description] + return dict(zip(columns, row)) + return None + except Exception as e: + logger.error(f"Error fetching customer {customer_id}: {str(e)}") + return None + + +def get_active_sessions(): + """Get all is_active sessions""" + try: + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute(""" + SELECT s.*, l.license_key, c.name as customer_name + FROM sessions s + JOIN licenses l ON s.license_id = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = true + ORDER BY s.started_at DESC + """) + + columns = [desc[0] for desc in cur.description] + sessions = [] + for row in cur.fetchall(): + session_dict = dict(zip(columns, row)) + sessions.append(session_dict) + return sessions + except Exception as e: + logger.error(f"Error fetching is_active sessions: {str(e)}") + return [] \ No newline at end of file diff --git a/v2_adminpanel/requirements.txt b/v2_adminpanel/requirements.txt new file mode 100644 index 0000000..64d9b85 --- /dev/null +++ b/v2_adminpanel/requirements.txt @@ -0,0 +1,17 @@ +flask +flask-session +psycopg2-binary +python-dotenv +pyopenssl +pandas +openpyxl +cryptography +apscheduler +requests +python-dateutil +bcrypt +pyotp +qrcode[pil] +PyJWT +prometheus-flask-exporter +prometheus-client diff --git a/v2_adminpanel/routes/__init__.py b/v2_adminpanel/routes/__init__.py new file mode 100644 index 0000000..4f9ede3 --- /dev/null +++ b/v2_adminpanel/routes/__init__.py @@ -0,0 +1,2 @@ +# Routes module initialization +# This module contains all Flask blueprints organized by functionality \ No newline at end of file diff --git a/v2_adminpanel/routes/__pycache__/__init__.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a81c845 Binary files /dev/null and b/v2_adminpanel/routes/__pycache__/__init__.cpython-312.pyc differ diff --git a/v2_adminpanel/routes/__pycache__/admin_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/admin_routes.cpython-312.pyc new file mode 100644 index 0000000..90c7614 Binary files /dev/null and b/v2_adminpanel/routes/__pycache__/admin_routes.cpython-312.pyc differ diff --git a/v2_adminpanel/routes/__pycache__/api_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/api_routes.cpython-312.pyc new file mode 100644 index 0000000..1b4b531 Binary files /dev/null and b/v2_adminpanel/routes/__pycache__/api_routes.cpython-312.pyc differ diff --git a/v2_adminpanel/routes/__pycache__/auth_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/auth_routes.cpython-312.pyc new file mode 100644 index 0000000..e6d9a8c Binary files /dev/null and b/v2_adminpanel/routes/__pycache__/auth_routes.cpython-312.pyc differ diff --git a/v2_adminpanel/routes/__pycache__/batch_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/batch_routes.cpython-312.pyc new file mode 100644 index 0000000..753606e Binary files /dev/null and b/v2_adminpanel/routes/__pycache__/batch_routes.cpython-312.pyc differ diff --git a/v2_adminpanel/routes/__pycache__/customer_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/customer_routes.cpython-312.pyc new file mode 100644 index 0000000..7b5b6b0 Binary files /dev/null and b/v2_adminpanel/routes/__pycache__/customer_routes.cpython-312.pyc differ diff --git a/v2_adminpanel/routes/__pycache__/export_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/export_routes.cpython-312.pyc new file mode 100644 index 0000000..3f0aaae Binary files /dev/null and b/v2_adminpanel/routes/__pycache__/export_routes.cpython-312.pyc differ diff --git a/v2_adminpanel/routes/__pycache__/license_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/license_routes.cpython-312.pyc new file mode 100644 index 0000000..c0baaf7 Binary files /dev/null and b/v2_adminpanel/routes/__pycache__/license_routes.cpython-312.pyc differ diff --git a/v2_adminpanel/routes/__pycache__/resource_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/resource_routes.cpython-312.pyc new file mode 100644 index 0000000..34962e3 Binary files /dev/null and b/v2_adminpanel/routes/__pycache__/resource_routes.cpython-312.pyc differ diff --git a/v2_adminpanel/routes/__pycache__/session_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/session_routes.cpython-312.pyc new file mode 100644 index 0000000..d1abdb6 Binary files /dev/null and b/v2_adminpanel/routes/__pycache__/session_routes.cpython-312.pyc differ diff --git a/v2_adminpanel/routes/admin_routes.py b/v2_adminpanel/routes/admin_routes.py new file mode 100644 index 0000000..067726d --- /dev/null +++ b/v2_adminpanel/routes/admin_routes.py @@ -0,0 +1,1441 @@ +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 new file mode 100644 index 0000000..e4f442f --- /dev/null +++ b/v2_adminpanel/routes/api_routes.py @@ -0,0 +1,1021 @@ +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 new file mode 100644 index 0000000..69a5c7d --- /dev/null +++ b/v2_adminpanel/routes/auth_routes.py @@ -0,0 +1,377 @@ +import time +import json +from datetime import datetime +from zoneinfo import ZoneInfo +from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify + +import config +from auth.decorators import login_required +from auth.password import hash_password, verify_password +from auth.two_factor import ( + generate_totp_secret, generate_qr_code, verify_totp, + generate_backup_codes, hash_backup_code, verify_backup_code +) +from auth.rate_limiting import ( + check_ip_blocked, record_failed_attempt, + reset_login_attempts, get_login_attempts +) +from utils.network import get_client_ip +from utils.audit import log_audit +from models import get_user_by_username +from db import get_db_connection, get_db_cursor +from utils.recaptcha import verify_recaptcha + +# Create Blueprint +auth_bp = Blueprint('auth', __name__) + + +@auth_bp.route("/login", methods=["GET", "POST"]) +def login(): + # Timing-Attack Schutz - Start Zeit merken + start_time = time.time() + + # IP-Adresse ermitteln + ip_address = get_client_ip() + + # Prüfen ob IP gesperrt ist + is_blocked, blocked_until = check_ip_blocked(ip_address) + if is_blocked: + time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 + error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." + return render_template("login.html", error=error_msg, error_type="blocked") + + # Anzahl bisheriger Versuche + attempt_count = get_login_attempts(ip_address) + + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + captcha_response = request.form.get("g-recaptcha-response") + + # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind + recaptcha_site_key = config.RECAPTCHA_SITE_KEY + if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: + if not captcha_response: + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA ERFORDERLICH!", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # CAPTCHA validieren + if not verify_recaptcha(captcha_response): + # Timing-Attack Schutz + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + return render_template("login.html", + error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", + show_captcha=True, + error_type="captcha", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=recaptcha_site_key) + + # Check user in database first, fallback to env vars + user = get_user_by_username(username) + login_success = False + needs_2fa = False + + if user: + # Database user authentication + if verify_password(password, user['password_hash']): + login_success = True + needs_2fa = user['totp_enabled'] + else: + # Fallback to environment variables for backward compatibility + if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: + login_success = True + + # Timing-Attack Schutz - Mindestens 1 Sekunde warten + elapsed = time.time() - start_time + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + if login_success: + # Erfolgreicher Login + if needs_2fa: + # Store temporary session for 2FA verification + session['temp_username'] = username + session['temp_user_id'] = user['id'] + session['awaiting_2fa'] = True + return redirect(url_for('auth.verify_2fa')) + else: + # Complete login without 2FA + session.permanent = True # Aktiviert das Timeout + session['logged_in'] = True + session['username'] = username + session['user_id'] = user['id'] if user else None + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + reset_login_attempts(ip_address) + log_audit('LOGIN_SUCCESS', 'user', + additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") + return redirect(url_for('admin.dashboard')) + else: + # Fehlgeschlagener Login + error_message = record_failed_attempt(ip_address, username) + new_attempt_count = get_login_attempts(ip_address) + + # Prüfen ob jetzt gesperrt + is_now_blocked, _ = check_ip_blocked(ip_address) + if is_now_blocked: + log_audit('LOGIN_BLOCKED', 'security', + additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") + + return render_template("login.html", + error=error_message, + show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + error_type="failed", + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + + # GET Request + return render_template("login.html", + show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), + attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), + recaptcha_site_key=config.RECAPTCHA_SITE_KEY) + + +@auth_bp.route("/logout") +def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") + session.pop('logged_in', None) + session.pop('username', None) + session.pop('user_id', None) + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + return redirect(url_for('auth.login')) + + +@auth_bp.route("/verify-2fa", methods=["GET", "POST"]) +def verify_2fa(): + if not session.get('awaiting_2fa'): + return redirect(url_for('auth.login')) + + if request.method == "POST": + token = request.form.get('token', '').replace(' ', '') + username = session.get('temp_username') + user_id = session.get('temp_user_id') + + if not username or not user_id: + flash('Session expired. Please login again.', 'error') + return redirect(url_for('auth.login')) + + user = get_user_by_username(username) + if not user: + flash('User not found.', 'error') + return redirect(url_for('auth.login')) + + # Check if it's a backup code + if len(token) == 8 and token.isupper(): + # Try backup code + backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] + if verify_backup_code(token, backup_codes): + # Remove used backup code + code_hash = hash_backup_code(token) + backup_codes.remove(code_hash) + + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", + (json.dumps(backup_codes), user_id)) + + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + flash('Login successful using backup code. Please generate new backup codes.', 'warning') + log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") + return redirect(url_for('admin.dashboard')) + else: + # Try TOTP token + if verify_totp(user['totp_secret'], token): + # Complete login + session.permanent = True + session['logged_in'] = True + session['username'] = username + session['user_id'] = user_id + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + session.pop('temp_username', None) + session.pop('temp_user_id', None) + session.pop('awaiting_2fa', None) + + log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") + return redirect(url_for('admin.dashboard')) + + # Failed verification + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", + (datetime.now(), user_id)) + + flash('Invalid authentication code. Please try again.', 'error') + log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") + + return render_template('verify_2fa.html') + + +@auth_bp.route("/profile") +@login_required +def profile(): + user = get_user_by_username(session['username']) + if not user: + # For environment-based users, redirect with message + flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') + return redirect(url_for('admin.dashboard')) + return render_template('profile.html', user=user) + + +@auth_bp.route("/profile/change-password", methods=["POST"]) +@login_required +def change_password(): + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + user = get_user_by_username(session['username']) + + # Verify current password + if not verify_password(current_password, user['password_hash']): + flash('Current password is incorrect.', 'error') + return redirect(url_for('auth.profile')) + + # Check new password + if new_password != confirm_password: + flash('New passwords do not match.', 'error') + return redirect(url_for('auth.profile')) + + if len(new_password) < 8: + flash('Password must be at least 8 characters long.', 'error') + return redirect(url_for('auth.profile')) + + # Update password + new_hash = hash_password(new_password) + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", + (new_hash, datetime.now(), user['id'])) + + log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], + additional_info="Password changed successfully") + flash('Password changed successfully.', 'success') + return redirect(url_for('auth.profile')) + + +@auth_bp.route("/profile/setup-2fa") +@login_required +def setup_2fa(): + user = get_user_by_username(session['username']) + + if user['totp_enabled']: + flash('2FA is already enabled for your account.', 'info') + return redirect(url_for('auth.profile')) + + # Generate new TOTP secret + totp_secret = generate_totp_secret() + session['temp_totp_secret'] = totp_secret + + # Generate QR code + qr_code = generate_qr_code(user['username'], totp_secret) + + return render_template('setup_2fa.html', + totp_secret=totp_secret, + qr_code=qr_code) + + +@auth_bp.route("/profile/enable-2fa", methods=["POST"]) +@login_required +def enable_2fa(): + token = request.form.get('token', '').replace(' ', '') + totp_secret = session.get('temp_totp_secret') + + if not totp_secret: + flash('2FA setup session expired. Please try again.', 'error') + return redirect(url_for('auth.setup_2fa')) + + # Verify the token + if not verify_totp(totp_secret, token): + flash('Invalid authentication code. Please try again.', 'error') + return redirect(url_for('auth.setup_2fa')) + + # Generate backup codes + backup_codes = generate_backup_codes() + backup_codes_hashed = [hash_backup_code(code) for code in backup_codes] + + # Enable 2FA for user + user = get_user_by_username(session['username']) + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute(""" + UPDATE users + SET totp_secret = %s, totp_enabled = true, backup_codes = %s + WHERE id = %s + """, (totp_secret, json.dumps(backup_codes_hashed), user['id'])) + + # Clear temp secret + session.pop('temp_totp_secret', None) + + log_audit('2FA_ENABLED', 'user', entity_id=user['id'], + additional_info="2FA successfully enabled") + + # Show backup codes + return render_template('backup_codes.html', backup_codes=backup_codes) + + +@auth_bp.route("/profile/disable-2fa", methods=["POST"]) +@login_required +def disable_2fa(): + password = request.form.get('password') + + user = get_user_by_username(session['username']) + + # Verify password + if not verify_password(password, user['password_hash']): + flash('Incorrect password. 2FA was not disabled.', 'error') + return redirect(url_for('auth.profile')) + + # Disable 2FA + with get_db_connection() as conn: + with get_db_cursor(conn) as cur: + cur.execute(""" + UPDATE users + SET totp_enabled = false, totp_secret = NULL, backup_codes = NULL + WHERE id = %s + """, (user['id'],)) + + log_audit('2FA_DISABLED', 'user', entity_id=user['id'], + additional_info="2FA disabled by user") + flash('2FA has been disabled for your account.', 'success') + return redirect(url_for('auth.profile')) + + +@auth_bp.route("/heartbeat", methods=['POST']) +@login_required +def heartbeat(): + """Endpoint für Session Keep-Alive - aktualisiert last_activity""" + # Aktualisiere last_activity nur wenn explizit angefordert + session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() + # Force session save + session.modified = True + + return jsonify({ + 'status': 'ok', + 'last_activity': session['last_activity'], + 'username': session.get('username') + }) \ No newline at end of file diff --git a/v2_adminpanel/routes/batch_routes.py b/v2_adminpanel/routes/batch_routes.py new file mode 100644 index 0000000..266282f --- /dev/null +++ b/v2_adminpanel/routes/batch_routes.py @@ -0,0 +1,439 @@ +import os +import logging +import secrets +import string +from datetime import datetime, timedelta +from pathlib import Path +from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file + +import config +from auth.decorators import login_required +from utils.audit import log_audit +from utils.network import get_client_ip +from utils.export import create_batch_export +from db import get_connection, get_db_connection, get_db_cursor +from models import get_customers + +# Create Blueprint +batch_bp = Blueprint('batch', __name__) + + +def generate_license_key(): + """Generiert einen zufälligen Lizenzschlüssel""" + chars = string.ascii_uppercase + string.digits + return '-'.join([''.join(secrets.choice(chars) for _ in range(4)) for _ in range(4)]) + + +@batch_bp.route("/batch", methods=["GET", "POST"]) +@login_required +def batch_create(): + """Batch-Erstellung von Lizenzen""" + customers = get_customers() + + if request.method == "POST": + conn = get_connection() + cur = conn.cursor() + + try: + # Form data + customer_id = int(request.form['customer_id']) + license_type = request.form['license_type'] + count = int(request.form['quantity']) # Korrigiert von 'count' zu 'quantity' + valid_from = request.form['valid_from'] + valid_until = request.form['valid_until'] + device_limit = int(request.form['device_limit']) + + # Resource allocation parameters + domain_count = int(request.form.get('domain_count', 0)) + ipv4_count = int(request.form.get('ipv4_count', 0)) + phone_count = int(request.form.get('phone_count', 0)) + + # Validierung + if count < 1 or count > 100: + flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') + return redirect(url_for('batch.batch_create')) + + # Hole Kundendaten inkl. is_fake Status + cur.execute("SELECT name, email, is_fake FROM customers WHERE id = %s", (customer_id,)) + customer = cur.fetchone() + if not customer: + flash('Kunde nicht gefunden!', 'error') + return redirect(url_for('batch.batch_create')) + + # Lizenz erbt immer den is_fake Status vom Kunden + is_fake = customer[2] + + created_licenses = [] + + # Erstelle Lizenzen + for i in range(count): + license_key = generate_license_key() + + # Prüfe ob Schlüssel bereits existiert + while True: + cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,)) + if not cur.fetchone(): + break + license_key = generate_license_key() + + # Erstelle Lizenz + cur.execute(""" + INSERT INTO licenses ( + license_key, customer_id, + license_type, valid_from, valid_until, device_limit, + is_fake, created_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + license_key, customer_id, + license_type, valid_from, valid_until, device_limit, + is_fake, datetime.now() + )) + + license_id = cur.fetchone()[0] + created_licenses.append({ + 'id': license_id, + 'license_key': license_key + }) + + # Allocate resources if requested + if domain_count > 0 or ipv4_count > 0 or phone_count > 0: + # Allocate domains + if domain_count > 0: + cur.execute(""" + UPDATE resource_pool + SET status = 'allocated', + license_id = %s, + allocated_at = NOW() + WHERE id IN ( + SELECT id FROM resource_pool + WHERE type = 'domain' + AND status = 'available' + AND is_fake = %s + ORDER BY id + LIMIT %s + ) + """, (license_id, is_fake, domain_count)) + + # Allocate IPv4s + if ipv4_count > 0: + cur.execute(""" + UPDATE resource_pool + SET status = 'allocated', + license_id = %s, + allocated_at = NOW() + WHERE id IN ( + SELECT id FROM resource_pool + WHERE type = 'ipv4' + AND status = 'available' + AND is_fake = %s + ORDER BY id + LIMIT %s + ) + """, (license_id, is_fake, ipv4_count)) + + # Allocate phones + if phone_count > 0: + cur.execute(""" + UPDATE resource_pool + SET status = 'allocated', + license_id = %s, + allocated_at = NOW() + WHERE id IN ( + SELECT id FROM resource_pool + WHERE type = 'phone' + AND status = 'available' + AND is_fake = %s + ORDER BY id + LIMIT %s + ) + """, (license_id, is_fake, phone_count)) + + # Audit-Log + log_audit('CREATE', 'license', license_id, + new_values={ + 'license_key': license_key, + 'customer_name': customer[0], + 'batch_creation': True + }) + + conn.commit() + + # Speichere erstellte Lizenzen in Session für Export + session['batch_created_licenses'] = created_licenses + session['batch_customer_name'] = customer[0] + session['batch_customer_email'] = customer[1] + + flash(f'{count} Lizenzen erfolgreich erstellt!', 'success') + + # Weiterleitung zum Export + return redirect(url_for('batch.batch_export')) + + except Exception as e: + conn.rollback() + logging.error(f"Fehler bei Batch-Erstellung: {str(e)}") + flash('Fehler bei der Batch-Erstellung!', 'error') + finally: + cur.close() + conn.close() + + return render_template("batch_form.html", customers=customers) + + +@batch_bp.route("/batch/export") +@login_required +def batch_export(): + """Exportiert die zuletzt erstellten Batch-Lizenzen""" + created_licenses = session.get('batch_created_licenses', []) + + if not created_licenses: + flash('Keine Lizenzen zum Exportieren gefunden!', 'error') + return redirect(url_for('batch.batch_create')) + + conn = get_connection() + cur = conn.cursor() + + try: + # Hole vollständige Lizenzdaten + license_ids = [l['id'] for l in created_licenses] + + cur.execute(""" + SELECT + l.license_key, c.name, c.email, + l.license_type, l.valid_from, l.valid_until, + l.device_limit, l.is_fake, l.created_at + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = ANY(%s) + ORDER BY l.id + """, (license_ids,)) + + licenses = [] + for row in cur.fetchall(): + licenses.append({ + 'license_key': row[0], + 'customer_name': row[1], + 'customer_email': row[2], + 'license_type': row[3], + 'valid_from': row[4], + 'valid_until': row[5], + 'device_limit': row[6], + 'is_fake': row[7], + 'created_at': row[8] + }) + + # Lösche aus Session + session.pop('batch_created_licenses', None) + session.pop('batch_customer_name', None) + session.pop('batch_customer_email', None) + + # Erstelle und sende Excel-Export + return create_batch_export(licenses) + + except Exception as e: + logging.error(f"Fehler beim Export: {str(e)}") + flash('Fehler beim Exportieren der Lizenzen!', 'error') + return redirect(url_for('batch.batch_create')) + finally: + cur.close() + conn.close() + + +@batch_bp.route("/batch/update", methods=["GET", "POST"]) +@login_required +def batch_update(): + """Batch-Update von Lizenzen""" + if request.method == "POST": + conn = get_connection() + cur = conn.cursor() + + try: + # Form data + license_keys = request.form.get('license_keys', '').strip().split('\n') + license_keys = [key.strip() for key in license_keys if key.strip()] + + if not license_keys: + flash('Keine Lizenzschlüssel angegeben!', 'error') + return redirect(url_for('batch.batch_update')) + + # Update-Parameter + updates = [] + params = [] + + if 'update_valid_until' in request.form and request.form['valid_until']: + updates.append("valid_until = %s") + params.append(request.form['valid_until']) + + if 'update_device_limit' in request.form and request.form['device_limit']: + updates.append("device_limit = %s") + params.append(int(request.form['device_limit'])) + + if 'update_active' in request.form: + updates.append("is_active = %s") + params.append('is_active' in request.form) + + if not updates: + flash('Keine Änderungen angegeben!', 'error') + return redirect(url_for('batch.batch_update')) + + # Führe Updates aus + updated_count = 0 + not_found = [] + + for license_key in license_keys: + # Prüfe ob Lizenz existiert + cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,)) + result = cur.fetchone() + + if not result: + not_found.append(license_key) + continue + + license_id = result[0] + + # Update ausführen + update_params = params + [license_id] + cur.execute(f""" + UPDATE licenses + SET {', '.join(updates)} + WHERE id = %s + """, update_params) + + # Audit-Log + log_audit('BATCH_UPDATE', 'license', license_id, + additional_info=f"Batch-Update: {', '.join(updates)}") + + updated_count += 1 + + conn.commit() + + # Feedback + flash(f'{updated_count} Lizenzen erfolgreich aktualisiert!', 'success') + + if not_found: + flash(f'{len(not_found)} Lizenzen nicht gefunden: {", ".join(not_found[:5])}{"..." if len(not_found) > 5 else ""}', 'warning') + + except Exception as e: + conn.rollback() + logging.error(f"Fehler bei Batch-Update: {str(e)}") + flash('Fehler beim Batch-Update!', 'error') + finally: + cur.close() + conn.close() + + return render_template("batch_update.html") + + +@batch_bp.route("/batch/import", methods=["GET", "POST"]) +@login_required +def batch_import(): + """Import von Lizenzen aus CSV/Excel""" + if request.method == "POST": + if 'file' not in request.files: + flash('Keine Datei ausgewählt!', 'error') + return redirect(url_for('batch.batch_import')) + + file = request.files['file'] + if file.filename == '': + flash('Keine Datei ausgewählt!', 'error') + return redirect(url_for('batch.batch_import')) + + # Verarbeite Datei + try: + import pandas as pd + + # Lese Datei + if file.filename.endswith('.csv'): + df = pd.read_csv(file) + elif file.filename.endswith(('.xlsx', '.xls')): + df = pd.read_excel(file) + else: + flash('Ungültiges Dateiformat! Nur CSV und Excel erlaubt.', 'error') + return redirect(url_for('batch.batch_import')) + + # Validiere Spalten + required_columns = ['customer_email', 'license_type', 'valid_from', 'valid_until', 'device_limit'] + missing_columns = [col for col in required_columns if col not in df.columns] + + if missing_columns: + flash(f'Fehlende Spalten: {", ".join(missing_columns)}', 'error') + return redirect(url_for('batch.batch_import')) + + conn = get_connection() + cur = conn.cursor() + + imported_count = 0 + errors = [] + + for index, row in df.iterrows(): + try: + # Finde oder erstelle Kunde + email = row['customer_email'] + cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) + customer = cur.fetchone() + + if not customer: + # Erstelle neuen Kunden + name = row.get('customer_name', email.split('@')[0]) + # Neue Kunden werden immer als Fake erstellt in der Testphase + # TODO: Nach Testphase muss hier die Business-Logik angepasst werden + is_fake = True + cur.execute(""" + INSERT INTO customers (name, email, is_fake, created_at) + VALUES (%s, %s, %s, %s) + RETURNING id + """, (name, email, is_fake, datetime.now())) + customer_id = cur.fetchone()[0] + customer_name = name + else: + customer_id = customer[0] + customer_name = customer[1] + # Hole is_fake Status vom existierenden Kunden + cur.execute("SELECT is_fake FROM customers WHERE id = %s", (customer_id,)) + is_fake = cur.fetchone()[0] + + # Generiere Lizenzschlüssel + license_key = row.get('license_key', generate_license_key()) + + # Erstelle Lizenz - is_fake wird vom Kunden geerbt + cur.execute(""" + INSERT INTO licenses ( + license_key, customer_id, + license_type, valid_from, valid_until, device_limit, + is_fake, created_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + license_key, customer_id, + row['license_type'], row['valid_from'], row['valid_until'], + int(row['device_limit']), is_fake, + datetime.now() + )) + + license_id = cur.fetchone()[0] + imported_count += 1 + + # Audit-Log + log_audit('IMPORT', 'license', license_id, + additional_info=f"Importiert aus {file.filename}") + + except Exception as e: + errors.append(f"Zeile {index + 2}: {str(e)}") + + conn.commit() + + # Feedback + flash(f'{imported_count} Lizenzen erfolgreich importiert!', 'success') + + if errors: + flash(f'{len(errors)} Fehler aufgetreten. Erste Fehler: {"; ".join(errors[:3])}', 'warning') + + except Exception as e: + logging.error(f"Fehler beim Import: {str(e)}") + flash(f'Fehler beim Import: {str(e)}', 'error') + finally: + if 'conn' in locals(): + cur.close() + conn.close() + + return render_template("batch_import.html") \ No newline at end of file diff --git a/v2_adminpanel/routes/customer_routes.py b/v2_adminpanel/routes/customer_routes.py new file mode 100644 index 0000000..1d6dcfb --- /dev/null +++ b/v2_adminpanel/routes/customer_routes.py @@ -0,0 +1,466 @@ +import os +import logging +from datetime import datetime +from zoneinfo import ZoneInfo +from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify + +import config +from auth.decorators import login_required +from utils.audit import log_audit +from db import get_connection, get_db_connection, get_db_cursor +from models import get_customers, get_customer_by_id + +# Create Blueprint +customer_bp = Blueprint('customers', __name__) + +# Test route +@customer_bp.route("/test-customers") +def test_customers(): + return "Customer blueprint is working!" + + +@customer_bp.route("/customers") +@login_required +def customers(): + show_fake = request.args.get('show_fake', 'false').lower() == 'true' + search = request.args.get('search', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 20 + sort = request.args.get('sort', 'name') + order = request.args.get('order', 'asc') + + customers_list = get_customers(show_fake=show_fake, search=search) + + # Sortierung + if sort == 'name': + customers_list.sort(key=lambda x: x['name'].lower(), reverse=(order == 'desc')) + elif sort == 'email': + customers_list.sort(key=lambda x: x['email'].lower(), reverse=(order == 'desc')) + elif sort == 'created_at': + customers_list.sort(key=lambda x: x['created_at'], reverse=(order == 'desc')) + + # Paginierung + total_customers = len(customers_list) + total_pages = (total_customers + per_page - 1) // per_page + start = (page - 1) * per_page + end = start + per_page + paginated_customers = customers_list[start:end] + + return render_template("customers.html", + customers=paginated_customers, + show_fake=show_fake, + search=search, + page=page, + per_page=per_page, + total_pages=total_pages, + total_customers=total_customers, + sort=sort, + order=order, + current_order=order) + + +@customer_bp.route("/customer/edit/", 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 new file mode 100644 index 0000000..255674c --- /dev/null +++ b/v2_adminpanel/routes/export_routes.py @@ -0,0 +1,495 @@ +import logging +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from flask import Blueprint, request, send_file + +import config +from auth.decorators import login_required +from utils.export import create_excel_export, create_csv_export, prepare_audit_export_data, format_datetime_for_export +from db import get_connection + +# Create Blueprint +export_bp = Blueprint('export', __name__, url_prefix='/export') + + +@export_bp.route("/licenses") +@login_required +def export_licenses(): + """Exportiert Lizenzen als Excel-Datei""" + conn = get_connection() + cur = conn.cursor() + + try: + # Nur reale Daten exportieren - keine Fake-Daten + query = """ + SELECT + l.id, + l.license_key, + c.name as customer_name, + c.email as customer_email, + l.license_type, + l.valid_from, + l.valid_until, + l.is_active, + l.device_limit, + l.created_at, + l.is_fake, + CASE + WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' + WHEN l.is_active = false THEN 'Deaktiviert' + ELSE 'Aktiv' + END as status, + (SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.is_active = true) as active_sessions, + (SELECT COUNT(DISTINCT hardware_id) FROM sessions s WHERE s.license_key = l.license_key) as registered_devices + FROM licenses l + LEFT JOIN customers c ON l.customer_id = c.id + WHERE l.is_fake = false + ORDER BY l.created_at DESC + """ + + cur.execute(query) + + # Daten für Export vorbereiten + data = [] + columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', 'Gültig von', + 'Gültig bis', 'Aktiv', 'Gerätelimit', 'Erstellt am', 'Fake-Lizenz', + 'Status', 'Aktive Sessions', 'Registrierte Geräte'] + + for row in cur.fetchall(): + row_data = list(row) + # Format datetime fields + if row_data[5]: # valid_from + row_data[5] = format_datetime_for_export(row_data[5]) + if row_data[6]: # valid_until + row_data[6] = format_datetime_for_export(row_data[6]) + if row_data[9]: # created_at + row_data[9] = format_datetime_for_export(row_data[9]) + data.append(row_data) + + # Format prüfen + format_type = request.args.get('format', 'excel').lower() + + if format_type == 'csv': + # CSV-Datei erstellen + return create_csv_export(data, columns, 'lizenzen') + else: + # Excel-Datei erstellen + return create_excel_export(data, columns, 'lizenzen') + + except Exception as e: + logging.error(f"Fehler beim Export: {str(e)}") + return "Fehler beim Exportieren der Lizenzen", 500 + finally: + cur.close() + conn.close() + + +@export_bp.route("/audit") +@login_required +def export_audit(): + """Exportiert Audit-Logs als Excel-Datei""" + conn = get_connection() + cur = conn.cursor() + + try: + # Filter aus Request + days = int(request.args.get('days', 30)) + action_filter = request.args.get('action', '') + entity_type_filter = request.args.get('entity_type', '') + + # Query aufbauen + query = """ + SELECT + id, timestamp, username, action, entity_type, entity_id, + ip_address, user_agent, old_values, new_values, additional_info + FROM audit_log + WHERE timestamp >= CURRENT_TIMESTAMP - INTERVAL '%s days' + """ + params = [days] + + if action_filter: + query += " AND action = %s" + params.append(action_filter) + + if entity_type_filter: + query += " AND entity_type = %s" + params.append(entity_type_filter) + + query += " ORDER BY timestamp DESC" + + cur.execute(query, params) + + # Daten in Dictionary-Format umwandeln + audit_logs = [] + for row in cur.fetchall(): + audit_logs.append({ + 'id': row[0], + 'timestamp': row[1], + 'username': row[2], + 'action': row[3], + 'entity_type': row[4], + 'entity_id': row[5], + 'ip_address': row[6], + 'user_agent': row[7], + 'old_values': row[8], + 'new_values': row[9], + 'additional_info': row[10] + }) + + # Daten für Export vorbereiten + data = prepare_audit_export_data(audit_logs) + + # Excel-Datei erstellen + columns = ['ID', 'Zeitstempel', 'Benutzer', 'Aktion', 'Entität', 'Entität ID', + 'IP-Adresse', 'User Agent', 'Alte Werte', 'Neue Werte', 'Zusatzinfo'] + + # Format prüfen + format_type = request.args.get('format', 'excel').lower() + + if format_type == 'csv': + # CSV-Datei erstellen + return create_csv_export(data, columns, 'audit_log') + else: + # Excel-Datei erstellen + return create_excel_export(data, columns, 'audit_log') + + except Exception as e: + logging.error(f"Fehler beim Export: {str(e)}") + return "Fehler beim Exportieren der Audit-Logs", 500 + finally: + cur.close() + conn.close() + + +@export_bp.route("/customers") +@login_required +def export_customers(): + """Exportiert Kunden als Excel-Datei""" + conn = get_connection() + cur = conn.cursor() + + try: + # SQL Query - nur reale Kunden exportieren + cur.execute(""" + SELECT + c.id, + c.name, + c.email, + c.phone, + c.address, + c.created_at, + c.is_fake, + COUNT(l.id) as license_count, + COUNT(CASE WHEN l.is_active = true THEN 1 END) as active_licenses, + COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses + FROM customers c + LEFT JOIN licenses l ON c.id = l.customer_id + WHERE c.is_fake = false + GROUP BY c.id, c.name, c.email, c.phone, c.address, c.created_at, c.is_fake + ORDER BY c.name + """) + + # Daten für Export vorbereiten + data = [] + columns = ['ID', 'Name', 'E-Mail', 'Telefon', 'Adresse', 'Erstellt am', + 'Test-Kunde', 'Anzahl Lizenzen', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] + + for row in cur.fetchall(): + # Format datetime fields (created_at ist Spalte 5) + row_data = list(row) + if row_data[5]: # created_at + row_data[5] = format_datetime_for_export(row_data[5]) + data.append(row_data) + + # Format prüfen + format_type = request.args.get('format', 'excel').lower() + + if format_type == 'csv': + # CSV-Datei erstellen + return create_csv_export(data, columns, 'kunden') + else: + # Excel-Datei erstellen + return create_excel_export(data, columns, 'kunden') + + except Exception as e: + logging.error(f"Fehler beim Export: {str(e)}") + return "Fehler beim Exportieren der Kunden", 500 + finally: + cur.close() + conn.close() + + +@export_bp.route("/sessions") +@login_required +def export_sessions(): + """Exportiert Sessions als Excel-Datei""" + conn = get_connection() + cur = conn.cursor() + + try: + # Filter aus Request + days = int(request.args.get('days', 7)) + active_only = request.args.get('active_only', 'false') == 'true' + + # SQL Query + if active_only: + query = """ + SELECT + s.id, + s.license_key, + l.customer_name, + s.username, + s.hardware_id, + s.started_at, + s.ended_at, + s.last_heartbeat, + s.is_active, + l.license_type, + l.is_fake + FROM sessions s + LEFT JOIN licenses l ON s.license_key = l.license_key + WHERE s.is_active = true AND l.is_fake = false + ORDER BY s.started_at DESC + """ + cur.execute(query) + else: + query = """ + SELECT + s.id, + s.license_key, + l.customer_name, + s.username, + s.hardware_id, + s.started_at, + s.ended_at, + s.last_heartbeat, + s.is_active, + l.license_type, + l.is_fake + FROM sessions s + LEFT JOIN licenses l ON s.license_key = l.license_key + WHERE s.started_at >= CURRENT_TIMESTAMP - INTERVAL '%s days' AND l.is_fake = false + ORDER BY s.started_at DESC + """ + cur.execute(query, (days,)) + + # Daten für Export vorbereiten + data = [] + columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'Benutzer', 'Geräte-ID', + 'Login-Zeit', 'Logout-Zeit', 'Letzte Aktivität', 'Aktiv', + 'Lizenztyp', 'Fake-Lizenz'] + + for row in cur.fetchall(): + row_data = list(row) + # Format datetime fields + if row_data[5]: # started_at + row_data[5] = format_datetime_for_export(row_data[5]) + if row_data[6]: # ended_at + row_data[6] = format_datetime_for_export(row_data[6]) + if row_data[7]: # last_heartbeat + row_data[7] = format_datetime_for_export(row_data[7]) + data.append(row_data) + + # Format prüfen + format_type = request.args.get('format', 'excel').lower() + + if format_type == 'csv': + # CSV-Datei erstellen + return create_csv_export(data, columns, 'sessions') + else: + # Excel-Datei erstellen + return create_excel_export(data, columns, 'sessions') + + except Exception as e: + logging.error(f"Fehler beim Export: {str(e)}") + return "Fehler beim Exportieren der Sessions", 500 + finally: + cur.close() + conn.close() + + +@export_bp.route("/resources") +@login_required +def export_resources(): + """Exportiert Ressourcen als Excel-Datei""" + conn = get_connection() + cur = conn.cursor() + + try: + # Filter aus Request + resource_type = request.args.get('type', 'all') + status_filter = request.args.get('status', 'all') + + # SQL Query aufbauen + query = """ + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + rp.is_fake, + l.license_key, + c.name as customer_name, + rp.created_at, + rp.created_by, + rp.status_changed_at, + rp.status_changed_by, + rp.quarantine_reason + FROM resource_pools rp + LEFT JOIN licenses l ON rp.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE 1=1 + """ + + params = [] + + if resource_type != 'all': + query += " AND rp.resource_type = %s" + params.append(resource_type) + + if status_filter != 'all': + query += " AND rp.status = %s" + params.append(status_filter) + + # Immer nur reale Ressourcen exportieren + query += " AND rp.is_fake = false" + + query += " ORDER BY rp.resource_type, rp.resource_value" + + cur.execute(query, params) + + # Daten für Export vorbereiten + data = [] + columns = ['ID', 'Typ', 'Wert', 'Status', 'Test-Ressource', 'Lizenzschlüssel', + 'Kunde', 'Erstellt am', 'Erstellt von', 'Status geändert am', + 'Status geändert von', 'Quarantäne-Grund'] + + for row in cur.fetchall(): + row_data = list(row) + # Format datetime fields + if row_data[7]: # created_at + row_data[7] = format_datetime_for_export(row_data[7]) + if row_data[9]: # status_changed_at + row_data[9] = format_datetime_for_export(row_data[9]) + data.append(row_data) + + # Format prüfen + format_type = request.args.get('format', 'excel').lower() + + if format_type == 'csv': + # CSV-Datei erstellen + return create_csv_export(data, columns, 'ressourcen') + else: + # Excel-Datei erstellen + return create_excel_export(data, columns, 'ressourcen') + + except Exception as e: + logging.error(f"Fehler beim Export: {str(e)}") + return "Fehler beim Exportieren der Ressourcen", 500 + finally: + cur.close() + conn.close() + + +@export_bp.route("/monitoring") +@login_required +def export_monitoring(): + """Exportiert Monitoring-Daten als Excel/CSV-Datei""" + conn = get_connection() + cur = conn.cursor() + + try: + # Zeitraum aus Request + hours = int(request.args.get('hours', 24)) + + # Monitoring-Daten sammeln + data = [] + columns = ['Zeitstempel', 'Lizenz-ID', 'Lizenzschlüssel', 'Kunde', 'Hardware-ID', + 'IP-Adresse', 'Ereignis-Typ', 'Schweregrad', 'Beschreibung'] + + # Query für Heartbeats und optionale Anomalien + query = """ + WITH monitoring_data AS ( + -- Lizenz-Heartbeats + SELECT + lh.timestamp, + lh.license_id, + l.license_key, + c.name as customer_name, + lh.hardware_id, + lh.ip_address, + 'Heartbeat' as event_type, + 'Normal' as severity, + 'License validation' as description + FROM license_heartbeats lh + JOIN licenses l ON l.id = lh.license_id + JOIN customers c ON c.id = l.customer_id + WHERE lh.timestamp > CURRENT_TIMESTAMP - INTERVAL '%s hours' + AND l.is_fake = false + """ + + # Check if anomaly_detections table exists + cur.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'anomaly_detections' + ) + """) + has_anomalies = cur.fetchone()[0] + + if has_anomalies: + query += """ + UNION ALL + + -- Anomalien + SELECT + ad.detected_at as timestamp, + ad.license_id, + l.license_key, + c.name as customer_name, + ad.hardware_id, + ad.ip_address, + ad.anomaly_type as event_type, + ad.severity, + ad.description + FROM anomaly_detections ad + LEFT JOIN licenses l ON l.id = ad.license_id + LEFT JOIN customers c ON c.id = l.customer_id + WHERE ad.detected_at > CURRENT_TIMESTAMP - INTERVAL '%s hours' + AND (l.is_fake = false OR l.is_fake IS NULL) + """ + params = [hours, hours] + else: + params = [hours] + + query += """ + ) + SELECT * FROM monitoring_data + ORDER BY timestamp DESC + """ + + cur.execute(query, params) + + for row in cur.fetchall(): + row_data = list(row) + # Format datetime field (timestamp ist Spalte 0) + if row_data[0]: # timestamp + row_data[0] = format_datetime_for_export(row_data[0]) + data.append(row_data) + + # Format prüfen + format_type = request.args.get('format', 'excel').lower() + + if format_type == 'csv': + # CSV-Datei erstellen + return create_csv_export(data, columns, 'monitoring') + else: + # Excel-Datei erstellen + return create_excel_export(data, columns, 'monitoring') + + except Exception as e: + logging.error(f"Fehler beim Export: {str(e)}") + return "Fehler beim Exportieren der Monitoring-Daten", 500 + finally: + cur.close() + conn.close() \ No newline at end of file diff --git a/v2_adminpanel/routes/license_routes.py b/v2_adminpanel/routes/license_routes.py new file mode 100644 index 0000000..1e8e3c0 --- /dev/null +++ b/v2_adminpanel/routes/license_routes.py @@ -0,0 +1,506 @@ +import os +import logging +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from dateutil.relativedelta import relativedelta +from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify + +import config +from auth.decorators import login_required +from utils.audit import log_audit +from utils.network import get_client_ip +from utils.license import validate_license_key +from db import get_connection, get_db_connection, get_db_cursor +from models import get_licenses, get_license_by_id + +# Create Blueprint +license_bp = Blueprint('licenses', __name__) + + +@license_bp.route("/licenses") +@login_required +def licenses(): + from datetime import datetime, timedelta + + # Get filter parameters + search = request.args.get('search', '').strip() + data_source = request.args.get('data_source', 'real') # real, fake, all + license_type = request.args.get('license_type', '') # '', full, test + license_status = request.args.get('license_status', '') # '', active, expiring, expired, inactive + sort = request.args.get('sort', 'created_at') + order = request.args.get('order', 'desc') + page = request.args.get('page', 1, type=int) + per_page = 50 + + # Get licenses based on data source + if data_source == 'fake': + licenses_list = get_licenses(show_fake=True) + licenses_list = [l for l in licenses_list if l.get('is_fake')] + elif data_source == 'all': + licenses_list = get_licenses(show_fake=True) + else: # real + licenses_list = get_licenses(show_fake=False) + + # Type filtering + if license_type: + if license_type == 'full': + licenses_list = [l for l in licenses_list if l.get('license_type') == 'full'] + elif license_type == 'test': + licenses_list = [l for l in licenses_list if l.get('license_type') == 'test'] + + # Status filtering + if license_status: + now = datetime.now().date() + filtered_licenses = [] + + for license in licenses_list: + if license_status == 'active' and license.get('is_active'): + # Active means is_active=true, regardless of expiration date + filtered_licenses.append(license) + elif license_status == 'expired' and license.get('valid_until') and license.get('valid_until') <= now: + # Expired means past valid_until date, regardless of is_active + filtered_licenses.append(license) + elif license_status == 'inactive' and not license.get('is_active'): + # Inactive means is_active=false, regardless of date + filtered_licenses.append(license) + + licenses_list = filtered_licenses + + # Search filtering + if search: + search_lower = search.lower() + licenses_list = [l for l in licenses_list if + search_lower in str(l.get('license_key', '')).lower() or + search_lower in str(l.get('customer_name', '')).lower() or + search_lower in str(l.get('customer_email', '')).lower()] + + # Calculate pagination + total = len(licenses_list) + total_pages = (total + per_page - 1) // per_page + start = (page - 1) * per_page + end = start + per_page + licenses_list = licenses_list[start:end] + + return render_template("licenses.html", + licenses=licenses_list, + search=search, + data_source=data_source, + license_type=license_type, + license_status=license_status, + sort=sort, + order=order, + page=page, + total=total, + total_pages=total_pages, + per_page=per_page, + now=datetime.now, + timedelta=timedelta) + + +@license_bp.route("/license/edit/", 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 new file mode 100644 index 0000000..193eeb8 --- /dev/null +++ b/v2_adminpanel/routes/monitoring_routes.py @@ -0,0 +1,428 @@ +from flask import Blueprint, render_template, jsonify, request, session, redirect, url_for +from functools import wraps +import psycopg2 +from psycopg2.extras import RealDictCursor +import os +import requests +from datetime import datetime, timedelta +import logging +from utils.partition_helper import ensure_partition_exists, check_table_exists + +# Configure logging +logger = logging.getLogger(__name__) + +# Create a function to get database connection +def get_db_connection(): + return psycopg2.connect( + host=os.environ.get('POSTGRES_HOST', 'postgres'), + database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'), + user=os.environ.get('POSTGRES_USER', 'postgres'), + password=os.environ.get('POSTGRES_PASSWORD', 'postgres') + ) + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + return redirect(url_for('auth.login')) + return f(*args, **kwargs) + return decorated_function + +# Create Blueprint +monitoring_bp = Blueprint('monitoring', __name__) + +@monitoring_bp.route('/monitoring') +@login_required +def unified_monitoring(): + """Unified monitoring dashboard combining live activity and anomaly detection""" + try: + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + + # Initialize default values + system_status = 'normal' + status_color = 'success' + active_alerts = 0 + live_metrics = { + 'active_licenses': 0, + 'total_validations': 0, + 'unique_devices': 0, + 'unique_ips': 0, + 'avg_response_time': 0 + } + trend_data = [] + activity_stream = [] + geo_data = [] + top_licenses = [] + anomaly_distribution = [] + performance_data = [] + + # Check if tables exist before querying + has_heartbeats = check_table_exists(conn, 'license_heartbeats') + has_anomalies = check_table_exists(conn, 'anomaly_detections') + + if has_anomalies: + # Get active alerts count + cur.execute(""" + SELECT COUNT(*) as count + FROM anomaly_detections + WHERE resolved = false + AND detected_at > NOW() - INTERVAL '24 hours' + """) + active_alerts = cur.fetchone()['count'] or 0 + + # Determine system status based on alerts + if active_alerts == 0: + system_status = 'normal' + status_color = 'success' + elif active_alerts < 5: + system_status = 'warning' + status_color = 'warning' + else: + system_status = 'critical' + status_color = 'danger' + + if has_heartbeats: + # Ensure current month partition exists + ensure_partition_exists(conn, 'license_heartbeats', datetime.now()) + + # Executive summary metrics + cur.execute(""" + SELECT + COUNT(DISTINCT license_id) as active_licenses, + COUNT(*) as total_validations, + COUNT(DISTINCT hardware_id) as unique_devices, + COUNT(DISTINCT ip_address) as unique_ips, + 0 as avg_response_time + FROM license_heartbeats + WHERE timestamp > NOW() - INTERVAL '5 minutes' + """) + result = cur.fetchone() + if result: + live_metrics = result + + # Get 24h trend data for metrics + cur.execute(""" + SELECT + DATE_TRUNC('hour', timestamp) as hour, + COUNT(DISTINCT license_id) as licenses, + COUNT(*) as validations + FROM license_heartbeats + WHERE timestamp > NOW() - INTERVAL '24 hours' + GROUP BY hour + ORDER BY hour + """) + trend_data = cur.fetchall() + + # Activity stream - just validations if no anomalies table + if has_anomalies: + cur.execute(""" + WITH combined_events AS ( + -- Normal validations + SELECT + lh.timestamp, + 'validation' as event_type, + 'normal' as severity, + l.license_key, + c.name as customer_name, + lh.ip_address, + lh.hardware_id, + NULL as anomaly_type, + NULL as description + FROM license_heartbeats lh + JOIN licenses l ON l.id = lh.license_id + JOIN customers c ON c.id = l.customer_id + WHERE lh.timestamp > NOW() - INTERVAL '1 hour' + + UNION ALL + + -- Anomalies + SELECT + ad.detected_at as timestamp, + 'anomaly' as event_type, + ad.severity, + l.license_key, + c.name as customer_name, + ad.ip_address, + ad.hardware_id, + ad.anomaly_type, + ad.description + FROM anomaly_detections ad + LEFT JOIN licenses l ON l.id = ad.license_id + LEFT JOIN customers c ON c.id = l.customer_id + WHERE ad.detected_at > NOW() - INTERVAL '1 hour' + ) + SELECT * FROM combined_events + ORDER BY timestamp DESC + LIMIT 100 + """) + else: + # Just show validations + cur.execute(""" + SELECT + lh.timestamp, + 'validation' as event_type, + 'normal' as severity, + l.license_key, + c.name as customer_name, + lh.ip_address, + lh.hardware_id, + NULL as anomaly_type, + NULL as description + FROM license_heartbeats lh + JOIN licenses l ON l.id = lh.license_id + JOIN customers c ON c.id = l.customer_id + WHERE lh.timestamp > NOW() - INTERVAL '1 hour' + ORDER BY lh.timestamp DESC + LIMIT 100 + """) + activity_stream = cur.fetchall() + + # Geographic distribution + cur.execute(""" + SELECT + ip_address, + COUNT(*) as request_count, + COUNT(DISTINCT license_id) as license_count + FROM license_heartbeats + WHERE timestamp > NOW() - INTERVAL '1 hour' + GROUP BY ip_address + ORDER BY request_count DESC + LIMIT 20 + """) + geo_data = cur.fetchall() + + # Top active licenses + if has_anomalies: + cur.execute(""" + SELECT + l.id, + l.license_key, + c.name as customer_name, + COUNT(DISTINCT lh.hardware_id) as device_count, + COUNT(lh.*) as validation_count, + MAX(lh.timestamp) as last_seen, + COUNT(DISTINCT ad.id) as anomaly_count + FROM licenses l + JOIN customers c ON c.id = l.customer_id + LEFT JOIN license_heartbeats lh ON l.id = lh.license_id + AND lh.timestamp > NOW() - INTERVAL '1 hour' + LEFT JOIN anomaly_detections ad ON l.id = ad.license_id + AND ad.detected_at > NOW() - INTERVAL '24 hours' + WHERE lh.license_id IS NOT NULL + GROUP BY l.id, l.license_key, c.name + ORDER BY validation_count DESC + LIMIT 10 + """) + else: + cur.execute(""" + SELECT + l.id, + l.license_key, + c.name as customer_name, + COUNT(DISTINCT lh.hardware_id) as device_count, + COUNT(lh.*) as validation_count, + MAX(lh.timestamp) as last_seen, + 0 as anomaly_count + FROM licenses l + JOIN customers c ON c.id = l.customer_id + LEFT JOIN license_heartbeats lh ON l.id = lh.license_id + AND lh.timestamp > NOW() - INTERVAL '1 hour' + WHERE lh.license_id IS NOT NULL + GROUP BY l.id, l.license_key, c.name + ORDER BY validation_count DESC + LIMIT 10 + """) + top_licenses = cur.fetchall() + + # Performance metrics + cur.execute(""" + SELECT + DATE_TRUNC('minute', timestamp) as minute, + 0 as avg_response_time, + 0 as max_response_time, + COUNT(*) as request_count + FROM license_heartbeats + WHERE timestamp > NOW() - INTERVAL '30 minutes' + GROUP BY minute + ORDER BY minute DESC + """) + performance_data = cur.fetchall() + + if has_anomalies: + # Anomaly distribution + cur.execute(""" + SELECT + anomaly_type, + COUNT(*) as count, + MAX(severity) as max_severity + FROM anomaly_detections + WHERE detected_at > NOW() - INTERVAL '24 hours' + GROUP BY anomaly_type + ORDER BY count DESC + """) + anomaly_distribution = cur.fetchall() + + cur.close() + conn.close() + + return render_template('monitoring/unified_monitoring.html', + system_status=system_status, + status_color=status_color, + active_alerts=active_alerts, + live_metrics=live_metrics, + trend_data=trend_data, + activity_stream=activity_stream, + geo_data=geo_data, + top_licenses=top_licenses, + anomaly_distribution=anomaly_distribution, + performance_data=performance_data) + + except Exception as e: + logger.error(f"Error in unified monitoring: {str(e)}") + return render_template('error.html', + error='Fehler beim Laden des Monitorings', + details=str(e)) + +@monitoring_bp.route('/live-dashboard') +@login_required +def live_dashboard(): + """Redirect to unified monitoring dashboard""" + return redirect(url_for('monitoring.unified_monitoring')) + + +@monitoring_bp.route('/alerts') +@login_required +def alerts(): + """Show active alerts from Alertmanager""" + alerts = [] + + try: + # Get alerts from Alertmanager + response = requests.get('http://alertmanager:9093/api/v1/alerts', timeout=2) + if response.status_code == 200: + alerts = response.json() + except: + # Fallback to database anomalies if table exists + conn = get_db_connection() + if check_table_exists(conn, 'anomaly_detections'): + cur = conn.cursor(cursor_factory=RealDictCursor) + + cur.execute(""" + SELECT + ad.*, + l.license_key, + c.name as company_name + FROM anomaly_detections ad + LEFT JOIN licenses l ON l.id = ad.license_id + LEFT JOIN customers c ON c.id = l.customer_id + WHERE ad.resolved = false + ORDER BY ad.detected_at DESC + LIMIT 50 + """) + alerts = cur.fetchall() + + cur.close() + conn.close() + + return render_template('monitoring/alerts.html', alerts=alerts) + +@monitoring_bp.route('/analytics') +@login_required +def analytics(): + """Combined analytics and license server status page""" + try: + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + + # Initialize default values + live_stats = [0, 0, 0, 0] + validation_rates = [] + + if check_table_exists(conn, 'license_heartbeats'): + # Get live statistics for the top cards + cur.execute(""" + SELECT + COUNT(DISTINCT license_id) as active_licenses, + COUNT(*) as total_validations, + COUNT(DISTINCT hardware_id) as unique_devices, + COUNT(DISTINCT ip_address) as unique_ips + FROM license_heartbeats + WHERE timestamp > NOW() - INTERVAL '5 minutes' + """) + live_stats_data = cur.fetchone() + live_stats = [ + live_stats_data['active_licenses'] or 0, + live_stats_data['total_validations'] or 0, + live_stats_data['unique_devices'] or 0, + live_stats_data['unique_ips'] or 0 + ] + + # Get validation rates for the chart (last 30 minutes, aggregated by minute) + cur.execute(""" + SELECT + DATE_TRUNC('minute', timestamp) as minute, + COUNT(*) as count + FROM license_heartbeats + WHERE timestamp > NOW() - INTERVAL '30 minutes' + GROUP BY minute + ORDER BY minute DESC + LIMIT 30 + """) + validation_rates = [(row['minute'].isoformat(), row['count']) for row in cur.fetchall()] + + cur.close() + conn.close() + + return render_template('monitoring/analytics.html', + live_stats=live_stats, + validation_rates=validation_rates) + + except Exception as e: + logger.error(f"Error in analytics: {str(e)}") + return render_template('error.html', + error='Fehler beim Laden der Analytics', + details=str(e)) + + +@monitoring_bp.route('/analytics/stream') +@login_required +def analytics_stream(): + """Server-sent event stream for live analytics updates""" + def generate(): + while True: + try: + conn = get_db_connection() + cur = conn.cursor(cursor_factory=RealDictCursor) + + data = {'active_licenses': 0, 'total_validations': 0, + 'unique_devices': 0, 'unique_ips': 0} + + if check_table_exists(conn, 'license_heartbeats'): + cur.execute(""" + SELECT + COUNT(DISTINCT license_id) as active_licenses, + COUNT(*) as total_validations, + COUNT(DISTINCT hardware_id) as unique_devices, + COUNT(DISTINCT ip_address) as unique_ips + FROM license_heartbeats + WHERE timestamp > NOW() - INTERVAL '5 minutes' + """) + result = cur.fetchone() + if result: + data = dict(result) + + cur.close() + conn.close() + + yield f"data: {jsonify(data).get_data(as_text=True)}\n\n" + + except Exception as e: + logger.error(f"Error in analytics stream: {str(e)}") + yield f"data: {jsonify({'error': str(e)}).get_data(as_text=True)}\n\n" + + import time + time.sleep(5) # Update every 5 seconds + + from flask import Response + return Response(generate(), mimetype="text/event-stream") \ No newline at end of file diff --git a/v2_adminpanel/routes/resource_routes.py b/v2_adminpanel/routes/resource_routes.py new file mode 100644 index 0000000..baa70fd --- /dev/null +++ b/v2_adminpanel/routes/resource_routes.py @@ -0,0 +1,721 @@ +import logging +from datetime import datetime +from zoneinfo import ZoneInfo +from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify, send_file + +import config +from auth.decorators import login_required +from utils.audit import log_audit +from utils.network import get_client_ip +from db import get_connection, get_db_connection, get_db_cursor + +# Create Blueprint +resource_bp = Blueprint('resources', __name__) + + +@resource_bp.route('/resources') +@login_required +def resources(): + """Zeigt die Ressourcenpool-Übersicht""" + import logging + logging.info("=== RESOURCES ROUTE CALLED ===") + + conn = get_connection() + cur = conn.cursor() + + try: + # Filter aus Query-Parametern + resource_type = request.args.get('type', 'all') + status_filter = request.args.get('status', 'all') + search_query = request.args.get('search', '') + show_fake = request.args.get('show_fake', 'false') == 'true' + + logging.info(f"Filters: type={resource_type}, status={status_filter}, search={search_query}, show_fake={show_fake}") + + # Basis-Query + query = """ + SELECT + rp.id, + rp.resource_type, + rp.resource_value, + rp.status, + rp.is_fake, + rp.allocated_to_license, + rp.created_at, + rp.status_changed_at, + rp.status_changed_by, + c.name as customer_name, + l.license_type + FROM resource_pools rp + LEFT JOIN licenses l ON rp.allocated_to_license = l.id + LEFT JOIN customers c ON l.customer_id = c.id + WHERE 1=1 + """ + + params = [] + + # Filter anwenden + if resource_type != 'all': + query += " AND rp.resource_type = %s" + params.append(resource_type) + + if status_filter != 'all': + query += " AND rp.status = %s" + params.append(status_filter) + + if search_query: + query += " AND (rp.resource_value ILIKE %s OR c.name ILIKE %s)" + params.extend([f'%{search_query}%', f'%{search_query}%']) + + if not show_fake: + query += " AND rp.is_fake = false" + + query += " ORDER BY rp.resource_type, rp.resource_value" + + cur.execute(query, params) + + resources_list = [] + rows = cur.fetchall() + logging.info(f"Query returned {len(rows)} rows") + + for row in rows: + resources_list.append({ + 'id': row[0], + 'resource_type': row[1], + 'resource_value': row[2], + 'status': row[3], + 'is_fake': row[4], + 'allocated_to_license': row[5], + 'created_at': row[6], + 'status_changed_at': row[7], + 'status_changed_by': row[8], + 'customer_name': row[9], + 'license_type': row[10] + }) + + # Statistiken + stats_query = """ + SELECT + resource_type, + status, + is_fake, + COUNT(*) as count + FROM resource_pools + """ + + # Apply test filter to statistics as well + if not show_fake: + stats_query += " WHERE is_fake = false" + + stats_query += " GROUP BY resource_type, status, is_fake" + + cur.execute(stats_query) + + stats = {} + for row in cur.fetchall(): + res_type = row[0] + status = row[1] + is_fake = row[2] + count = row[3] + + if res_type not in stats: + stats[res_type] = { + 'total': 0, + 'available': 0, + 'allocated': 0, + 'quarantined': 0, + 'test': 0, + 'prod': 0, + 'available_percent': 0 + } + + stats[res_type]['total'] += count + stats[res_type][status] = stats[res_type].get(status, 0) + count + if is_fake: + stats[res_type]['test'] += count + else: + stats[res_type]['prod'] += count + + # Calculate percentages + for res_type in stats: + if stats[res_type]['total'] > 0: + stats[res_type]['available_percent'] = int((stats[res_type]['available'] / stats[res_type]['total']) * 100) + + # Pagination parameters (simple defaults for now) + try: + page = int(request.args.get('page', '1') or '1') + except (ValueError, TypeError): + page = 1 + per_page = 50 + total = len(resources_list) + total_pages = (total + per_page - 1) // per_page if total > 0 else 1 + + # Sort parameters + sort_by = request.args.get('sort', 'id') + sort_order = request.args.get('order', 'asc') + + return render_template('resources.html', + resources=resources_list, + stats=stats, + resource_type=resource_type, + status_filter=status_filter, + search=search_query, # Changed from search_query to search + show_fake=show_fake, + total=total, + page=page, + total_pages=total_pages, + sort_by=sort_by, + sort_order=sort_order, + recent_activities=[], # Empty for now + datetime=datetime) # For template datetime usage + + except Exception as e: + import traceback + logging.error(f"Fehler beim Laden der Ressourcen: {str(e)}") + logging.error(f"Traceback: {traceback.format_exc()}") + flash('Fehler beim Laden der Ressourcen!', 'error') + return redirect(url_for('admin.dashboard')) + finally: + cur.close() + conn.close() + + +# Old add_resource function removed - using add_resources instead + + +@resource_bp.route('/resources/quarantine/', 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 new file mode 100644 index 0000000..0a3afe8 --- /dev/null +++ b/v2_adminpanel/routes/session_routes.py @@ -0,0 +1,429 @@ +import logging +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from flask import Blueprint, render_template, request, redirect, session, url_for, flash + +import config +from auth.decorators import login_required +from utils.audit import log_audit +from utils.network import get_client_ip +from db import get_connection, get_db_connection, get_db_cursor +from models import get_active_sessions + +# Create Blueprint +session_bp = Blueprint('sessions', __name__) + + +@session_bp.route("/sessions") +@login_required +def sessions(): + conn = get_connection() + cur = conn.cursor() + + try: + # Get is_active sessions with calculated inactive time + cur.execute(""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.user_agent, s.started_at, s.last_heartbeat, + EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = TRUE + ORDER BY s.last_heartbeat DESC + """) + active_sessions = cur.fetchall() + + # Get recent ended sessions + cur.execute(""" + SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, + s.started_at, s.ended_at, + EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes + FROM sessions s + JOIN licenses l ON s.license_id = l.id + JOIN customers c ON l.customer_id = c.id + WHERE s.is_active = FALSE + AND s.ended_at > NOW() - INTERVAL '24 hours' + ORDER BY s.ended_at DESC + LIMIT 50 + """) + recent_sessions = cur.fetchall() + + return render_template("sessions.html", + active_sessions=active_sessions, + recent_sessions=recent_sessions) + + except Exception as e: + logging.error(f"Error loading sessions: {str(e)}") + flash('Fehler beim Laden der Sessions!', 'error') + return redirect(url_for('admin.dashboard')) + finally: + cur.close() + conn.close() + + +@session_bp.route("/sessions/history") +@login_required +def session_history(): + """Zeigt die Session-Historie""" + conn = get_connection() + cur = conn.cursor() + + try: + # Query parameters + license_key = request.args.get('license_key', '') + username = request.args.get('username', '') + days = int(request.args.get('days', 7)) + + # Base query + query = """ + SELECT + s.id, + s.license_key, + s.username, + s.hardware_id, + s.started_at, + s.ended_at, + s.last_heartbeat, + s.is_active, + l.customer_name, + l.license_type, + l.is_test + FROM sessions s + LEFT JOIN licenses l ON s.license_key = l.license_key + WHERE 1=1 + """ + + params = [] + + # Apply filters + if license_key: + query += " AND s.license_key = %s" + params.append(license_key) + + if username: + query += " AND s.username ILIKE %s" + params.append(f'%{username}%') + + # Time filter + query += " AND s.started_at >= CURRENT_TIMESTAMP - INTERVAL '%s days'" + params.append(days) + + query += " ORDER BY s.started_at DESC LIMIT 1000" + + cur.execute(query, params) + + sessions_list = [] + for row in cur.fetchall(): + session_duration = None + if row[4] and row[5]: # started_at and ended_at + duration = row[5] - row[4] + hours = int(duration.total_seconds() // 3600) + minutes = int((duration.total_seconds() % 3600) // 60) + session_duration = f"{hours}h {minutes}m" + elif row[4] and row[7]: # started_at and is_active + duration = datetime.now(ZoneInfo("UTC")) - row[4] + hours = int(duration.total_seconds() // 3600) + minutes = int((duration.total_seconds() % 3600) // 60) + session_duration = f"{hours}h {minutes}m (aktiv)" + + sessions_list.append({ + 'id': row[0], + 'license_key': row[1], + 'username': row[2], + 'hardware_id': row[3], + 'started_at': row[4], + 'ended_at': row[5], + 'last_heartbeat': row[6], + 'is_active': row[7], + 'customer_name': row[8], + 'license_type': row[9], + 'is_test': row[10], + 'duration': session_duration + }) + + # Get unique license keys for filter dropdown + cur.execute(""" + SELECT DISTINCT s.license_key, l.customer_name + FROM sessions s + LEFT JOIN licenses l ON s.license_key = l.license_key + WHERE s.started_at >= CURRENT_TIMESTAMP - INTERVAL '30 days' + ORDER BY l.customer_name, s.license_key + """) + + available_licenses = [] + for row in cur.fetchall(): + available_licenses.append({ + 'license_key': row[0], + 'customer_name': row[1] or 'Unbekannt' + }) + + return render_template("session_history.html", + sessions=sessions_list, + available_licenses=available_licenses, + filters={ + 'license_key': license_key, + 'username': username, + 'days': days + }) + + except Exception as e: + logging.error(f"Fehler beim Laden der Session-Historie: {str(e)}") + flash('Fehler beim Laden der Session-Historie!', 'error') + return redirect(url_for('sessions.sessions')) + finally: + cur.close() + conn.close() + + +@session_bp.route("/session/end/", 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 new file mode 100644 index 0000000..9626ed4 --- /dev/null +++ b/v2_adminpanel/scheduler.py @@ -0,0 +1,165 @@ +""" +Scheduler module for handling background tasks +""" +import logging +from apscheduler.schedulers.background import BackgroundScheduler +import config +from utils.backup import create_backup +from db import get_connection + + +def scheduled_backup(): + """Führt ein geplantes Backup aus""" + logging.info("Starte geplantes Backup...") + create_backup(backup_type="scheduled", created_by="scheduler") + + +def cleanup_expired_sessions(): + """Clean up expired license sessions""" + try: + conn = get_connection() + cur = conn.cursor() + + # Get client config for timeout value + cur.execute(""" + SELECT session_timeout + FROM client_configs + WHERE client_name = 'Account Forger' + """) + result = cur.fetchone() + timeout_seconds = result[0] if result else 60 + + # Find expired sessions + cur.execute(""" + SELECT id, license_id, hardware_id, ip_address, client_version, started_at + FROM license_sessions + WHERE last_heartbeat < CURRENT_TIMESTAMP - INTERVAL '%s seconds' + """, (timeout_seconds,)) + + expired_sessions = cur.fetchall() + + if expired_sessions: + logging.info(f"Found {len(expired_sessions)} expired sessions to clean up") + + for session in expired_sessions: + # Log to history + cur.execute(""" + INSERT INTO session_history + (license_id, hardware_id, ip_address, client_version, started_at, ended_at, end_reason) + VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP, 'timeout') + """, (session[1], session[2], session[3], session[4], session[5])) + + # Delete session + cur.execute("DELETE FROM license_sessions WHERE id = %s", (session[0],)) + + conn.commit() + logging.info(f"Cleaned up {len(expired_sessions)} expired sessions") + + cur.close() + conn.close() + + except Exception as e: + logging.error(f"Error cleaning up sessions: {str(e)}") + if 'conn' in locals(): + conn.rollback() + + +def deactivate_expired_licenses(): + """Deactivate licenses that have expired""" + try: + conn = get_connection() + cur = conn.cursor() + + # Find active licenses that have expired + # Check valid_until < today (at midnight) + cur.execute(""" + SELECT id, license_key, customer_id, valid_until + FROM licenses + WHERE is_active = true + AND valid_until IS NOT NULL + AND valid_until < CURRENT_DATE + AND is_fake = false + """) + + expired_licenses = cur.fetchall() + + if expired_licenses: + logging.info(f"Found {len(expired_licenses)} expired licenses to deactivate") + + for license in expired_licenses: + license_id, license_key, customer_id, valid_until = license + + # Deactivate the license + cur.execute(""" + UPDATE licenses + SET is_active = false, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, (license_id,)) + + # Log to audit trail + cur.execute(""" + INSERT INTO audit_log + (timestamp, username, action, entity_type, entity_id, + old_values, new_values, additional_info) + VALUES (CURRENT_TIMESTAMP, 'system', 'DEACTIVATE', 'license', %s, + jsonb_build_object('is_active', true), + jsonb_build_object('is_active', false), + %s) + """, (license_id, f"License automatically deactivated due to expiration. Valid until: {valid_until}")) + + logging.info(f"Deactivated expired license: {license_key} (ID: {license_id})") + + conn.commit() + logging.info(f"Successfully deactivated {len(expired_licenses)} expired licenses") + else: + logging.debug("No expired licenses found to deactivate") + + cur.close() + conn.close() + + except Exception as e: + logging.error(f"Error deactivating expired licenses: {str(e)}") + if 'conn' in locals(): + conn.rollback() + + +def init_scheduler(): + """Initialize and configure the scheduler""" + scheduler = BackgroundScheduler() + + # Configure daily backup job + scheduler.add_job( + scheduled_backup, + 'cron', + hour=config.SCHEDULER_CONFIG['backup_hour'], + minute=config.SCHEDULER_CONFIG['backup_minute'], + id='daily_backup', + replace_existing=True + ) + + # Configure session cleanup job - runs every 60 seconds + scheduler.add_job( + cleanup_expired_sessions, + 'interval', + seconds=60, + id='session_cleanup', + replace_existing=True + ) + + # Configure license expiration job - runs daily at midnight + scheduler.add_job( + deactivate_expired_licenses, + 'cron', + hour=0, + minute=0, + id='license_expiration_check', + replace_existing=True + ) + + scheduler.start() + logging.info(f"Scheduler started. Daily backup scheduled at {config.SCHEDULER_CONFIG['backup_hour']:02d}:{config.SCHEDULER_CONFIG['backup_minute']:02d}") + logging.info("Session cleanup job scheduled to run every 60 seconds") + logging.info("License expiration check scheduled to run daily at midnight") + + return scheduler \ No newline at end of file diff --git a/v2_adminpanel/templates/404.html b/v2_adminpanel/templates/404.html new file mode 100644 index 0000000..a500061 --- /dev/null +++ b/v2_adminpanel/templates/404.html @@ -0,0 +1,20 @@ +{% 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 new file mode 100644 index 0000000..11f70d1 --- /dev/null +++ b/v2_adminpanel/templates/500.html @@ -0,0 +1,47 @@ +{% 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 new file mode 100644 index 0000000..e60fe89 --- /dev/null +++ b/v2_adminpanel/templates/add_resources.html @@ -0,0 +1,439 @@ +{% 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 new file mode 100644 index 0000000..91fcfba --- /dev/null +++ b/v2_adminpanel/templates/audit_log.html @@ -0,0 +1,391 @@ +{% 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 new file mode 100644 index 0000000..17e7616 --- /dev/null +++ b/v2_adminpanel/templates/backup_codes.html @@ -0,0 +1,228 @@ +{% 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 new file mode 100644 index 0000000..983b0f2 --- /dev/null +++ b/v2_adminpanel/templates/backups.html @@ -0,0 +1,301 @@ +{% 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 new file mode 100644 index 0000000..3a77ef3 --- /dev/null +++ b/v2_adminpanel/templates/base.html @@ -0,0 +1,705 @@ + + + + + + {% 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 new file mode 100644 index 0000000..51f1d78 --- /dev/null +++ b/v2_adminpanel/templates/batch_form.html @@ -0,0 +1,514 @@ +{% 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 new file mode 100644 index 0000000..7a96174 --- /dev/null +++ b/v2_adminpanel/templates/batch_result.html @@ -0,0 +1,156 @@ +{% 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 new file mode 100644 index 0000000..35a9e85 --- /dev/null +++ b/v2_adminpanel/templates/blocked_ips.html @@ -0,0 +1,98 @@ +{% 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 new file mode 100644 index 0000000..03a7eff --- /dev/null +++ b/v2_adminpanel/templates/create_customer.html @@ -0,0 +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 %} +{% endblock %} \ No newline at end of file diff --git a/v2_adminpanel/templates/customers.html b/v2_adminpanel/templates/customers.html new file mode 100644 index 0000000..aaed406 --- /dev/null +++ b/v2_adminpanel/templates/customers.html @@ -0,0 +1,185 @@ +{% 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 new file mode 100644 index 0000000..a9fd5e7 --- /dev/null +++ b/v2_adminpanel/templates/customers_licenses.html @@ -0,0 +1,1153 @@ +{% 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 new file mode 100644 index 0000000..98787b1 --- /dev/null +++ b/v2_adminpanel/templates/dashboard.html @@ -0,0 +1,477 @@ +{% 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 new file mode 100644 index 0000000..e5db27d --- /dev/null +++ b/v2_adminpanel/templates/edit_customer.html @@ -0,0 +1,103 @@ +{% 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 new file mode 100644 index 0000000..4fcef2c --- /dev/null +++ b/v2_adminpanel/templates/edit_license.html @@ -0,0 +1,88 @@ +{% 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 new file mode 100644 index 0000000..f37ab0e --- /dev/null +++ b/v2_adminpanel/templates/error.html @@ -0,0 +1,55 @@ +{% 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 new file mode 100644 index 0000000..faa7c21 --- /dev/null +++ b/v2_adminpanel/templates/index.html @@ -0,0 +1,578 @@ +{% 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 new file mode 100644 index 0000000..d938a73 --- /dev/null +++ b/v2_adminpanel/templates/license_analytics.html @@ -0,0 +1,445 @@ +{% 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 new file mode 100644 index 0000000..dfa2741 --- /dev/null +++ b/v2_adminpanel/templates/license_anomalies.html @@ -0,0 +1,241 @@ +{% 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 new file mode 100644 index 0000000..82ce1c3 --- /dev/null +++ b/v2_adminpanel/templates/license_config.html @@ -0,0 +1,373 @@ +{% 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 new file mode 100644 index 0000000..1a9b311 --- /dev/null +++ b/v2_adminpanel/templates/license_sessions.html @@ -0,0 +1,151 @@ +{% 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 new file mode 100644 index 0000000..cd54f5c --- /dev/null +++ b/v2_adminpanel/templates/licenses.html @@ -0,0 +1,455 @@ +{% 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 new file mode 100644 index 0000000..f98cd96 --- /dev/null +++ b/v2_adminpanel/templates/login.html @@ -0,0 +1,125 @@ + + + + + + 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 new file mode 100644 index 0000000..197185a --- /dev/null +++ b/v2_adminpanel/templates/monitoring/alerts.html @@ -0,0 +1,322 @@ +{% 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 new file mode 100644 index 0000000..ae6ad47 --- /dev/null +++ b/v2_adminpanel/templates/monitoring/analytics.html @@ -0,0 +1,453 @@ +{% 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 new file mode 100644 index 0000000..66485b0 --- /dev/null +++ b/v2_adminpanel/templates/monitoring/live_dashboard.html @@ -0,0 +1,698 @@ +{% 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 new file mode 100644 index 0000000..ac91889 --- /dev/null +++ b/v2_adminpanel/templates/monitoring/unified_monitoring.html @@ -0,0 +1,609 @@ +{% 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 new file mode 100644 index 0000000..eb5282f --- /dev/null +++ b/v2_adminpanel/templates/profile.html @@ -0,0 +1,216 @@ +{% 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 new file mode 100644 index 0000000..3ae1eee --- /dev/null +++ b/v2_adminpanel/templates/resource_history.html @@ -0,0 +1,365 @@ +{% 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 new file mode 100644 index 0000000..5f0812f --- /dev/null +++ b/v2_adminpanel/templates/resource_metrics.html @@ -0,0 +1,559 @@ +{% 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 new file mode 100644 index 0000000..1704575 --- /dev/null +++ b/v2_adminpanel/templates/resource_report.html @@ -0,0 +1,212 @@ +{% 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 new file mode 100644 index 0000000..8253652 --- /dev/null +++ b/v2_adminpanel/templates/resources.html @@ -0,0 +1,896 @@ +{% 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 new file mode 100644 index 0000000..2a524a6 --- /dev/null +++ b/v2_adminpanel/templates/sessions.html @@ -0,0 +1,183 @@ +{% 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 new file mode 100644 index 0000000..a7ccf35 --- /dev/null +++ b/v2_adminpanel/templates/setup_2fa.html @@ -0,0 +1,210 @@ +{% 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 new file mode 100644 index 0000000..24d4ce5 --- /dev/null +++ b/v2_adminpanel/templates/verify_2fa.html @@ -0,0 +1,131 @@ + + + + + + 2FA Verifizierung - Admin Panel + + + + + + + + + + \ No newline at end of file diff --git a/v2_adminpanel/tests/__init__.py b/v2_adminpanel/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/v2_adminpanel/tests/test_error_handling.py b/v2_adminpanel/tests/test_error_handling.py new file mode 100644 index 0000000..2489f70 --- /dev/null +++ b/v2_adminpanel/tests/test_error_handling.py @@ -0,0 +1,350 @@ +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