Initial commit
Dieser Commit ist enthalten in:
86
.claude/settings.local.json
Normale Datei
86
.claude/settings.local.json
Normale Datei
@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
714
API_REFERENCE.md
Normale Datei
714
API_REFERENCE.md
Normale Datei
@ -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/`
|
||||||
154
CLAUDE.md
Normale Datei
154
CLAUDE.md
Normale Datei
@ -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
|
||||||
405
CLAUDE_PROJECT_README.md
Normale Datei
405
CLAUDE_PROJECT_README.md
Normale Datei
@ -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
|
||||||
3217
JOURNAL.md
Normale Datei
3217
JOURNAL.md
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
376
OPERATIONS_GUIDE.md
Normale Datei
376
OPERATIONS_GUIDE.md
Normale Datei
@ -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: <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 <container_name>`
|
||||||
|
- 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
|
||||||
121
PRODUCTION_DEPLOYMENT.md
Normale Datei
121
PRODUCTION_DEPLOYMENT.md
Normale Datei
@ -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`
|
||||||
14
SSL/.claude/settings.local.json
Normale Datei
14
SSL/.claude/settings.local.json
Normale Datei
@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
130
SSL/SSL_Wichtig.md
Normale Datei
130
SSL/SSL_Wichtig.md
Normale Datei
@ -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
|
||||||
|
```
|
||||||
23
SSL/cert.pem
Normale Datei
23
SSL/cert.pem
Normale Datei
@ -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-----
|
||||||
26
SSL/chain.pem
Normale Datei
26
SSL/chain.pem
Normale Datei
@ -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-----
|
||||||
49
SSL/fullchain.pem
Normale Datei
49
SSL/fullchain.pem
Normale Datei
@ -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-----
|
||||||
5
SSL/privkey.pem
Normale Datei
5
SSL/privkey.pem
Normale Datei
@ -0,0 +1,5 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgi8/a6iwFCHSbBe/I
|
||||||
|
2Zo6exFpcLL4icRgotOF605ZrY6hRANCAATEQD6vfDoXM7YziT75OmB/kvxoEebM
|
||||||
|
FRBCzpTOdUZpThlFmLijjCsYnxc8DeWDn8/eLltrBWhuM4YxgX8tseO0
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
263
SYSTEM_DOCUMENTATION.md
Normale Datei
263
SYSTEM_DOCUMENTATION.md
Normale Datei
@ -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
|
||||||
36
Start.bat
Normale Datei
36
Start.bat
Normale Datei
@ -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
|
||||||
69
backup_before_cleanup.sh
Normale Datei
69
backup_before_cleanup.sh
Normale Datei
@ -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"
|
||||||
1
backups/.backup_key
Normale Datei
1
backups/.backup_key
Normale Datei
@ -0,0 +1 @@
|
|||||||
|
vJgDckVjr3cSictLNFLGl8QIfqSXVD5skPU7kVhkyfc=
|
||||||
1
backups/backup_v2docker_20250607_174645_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250607_174645_encrypted.sql.gz.enc
Normale Datei
@ -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
|
||||||
1
backups/backup_v2docker_20250607_232845_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250607_232845_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250608_075834_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250608_075834_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250608_174930_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250608_174930_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250608_200224_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250608_200224_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250618_020559_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250618_020559_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250618_021107_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250618_021107_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250618_024414_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250618_024414_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250618_195853_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250618_195853_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250619_154355_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250619_154355_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250621_030000_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250621_030000_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250621_174144_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250621_174144_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250622_141119_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250622_141119_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250622_172034_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250622_172034_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250623_030000_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250623_030000_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
4475
backups/refactoring_20250616_223724/app.py.backup_20250616_223724
Normale Datei
4475
backups/refactoring_20250616_223724/app.py.backup_20250616_223724
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
25
backups/refactoring_20250616_223724/blueprint_overview.txt
Normale Datei
25
backups/refactoring_20250616_223724/blueprint_overview.txt
Normale Datei
@ -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
|
||||||
60
backups/refactoring_20250616_223724/commented_routes.txt
Normale Datei
60
backups/refactoring_20250616_223724/commented_routes.txt
Normale Datei
@ -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/<int:license_id>", methods=["GET", "POST"])
|
||||||
|
1515:# @app.route("/license/delete/<int:license_id>", methods=["POST"])
|
||||||
|
1548:# @app.route("/customers")
|
||||||
|
1554:# @app.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
|
||||||
|
1638:# @app.route("/customer/create", methods=["GET", "POST"])
|
||||||
|
1693:# @app.route("/customer/delete/<int:customer_id>", methods=["POST"])
|
||||||
|
1731:# @app.route("/customers-licenses")
|
||||||
|
1824:# @app.route("/api/customer/<int:customer_id>/licenses")
|
||||||
|
1927:# @app.route("/api/customer/<int:customer_id>/quick-stats")
|
||||||
|
1960:# @app.route("/api/license/<int:license_id>/quick-edit", methods=['POST'])
|
||||||
|
2030:# @app.route("/api/license/<int:license_id>/resources")
|
||||||
|
2080:# @app.route("/sessions")
|
||||||
|
2162:# @app.route("/session/end/<int:session_id>", 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/<int:backup_id>", methods=["POST"])
|
||||||
|
2953:# @app.route("/backup/download/<int:backup_id>")
|
||||||
|
2985:# @app.route("/backup/delete/<int:backup_id>", 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/<int:license_id>/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/<int:license_id>/devices")
|
||||||
|
3283:# @app.route("/api/license/<int:license_id>/register-device", methods=["POST"])
|
||||||
|
3398:# @app.route("/api/license/<int:license_id>/deactivate-device/<int:device_id>", 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/<int:resource_id>', 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/<int:resource_id>')
|
||||||
|
4155:# @app.route('/resources/metrics')
|
||||||
|
4319:# @app.route('/resources/report', methods=['GET'])
|
||||||
29251
backups/refactoring_20250616_223724/git_diff.txt
Normale Datei
29251
backups/refactoring_20250616_223724/git_diff.txt
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
10
backups/refactoring_20250616_223724/git_log.txt
Normale Datei
10
backups/refactoring_20250616_223724/git_log.txt
Normale Datei
@ -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
|
||||||
38
backups/refactoring_20250616_223724/git_status.txt
Normale Datei
38
backups/refactoring_20250616_223724/git_status.txt
Normale Datei
@ -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 <file>..." to update what will be committed)
|
||||||
|
(use "git restore <file>..." 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 <file>..." 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")
|
||||||
33
backups/refactoring_20250616_223724/v2_adminpanel_backup/Dockerfile
Normale Datei
33
backups/refactoring_20250616_223724/v2_adminpanel_backup/Dockerfile
Normale Datei
@ -0,0 +1,33 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Locale für deutsche Sprache und UTF-8 setzen
|
||||||
|
ENV LANG=de_DE.UTF-8
|
||||||
|
ENV LC_ALL=de_DE.UTF-8
|
||||||
|
ENV PYTHONIOENCODING=utf-8
|
||||||
|
|
||||||
|
# Zeitzone auf Europe/Berlin setzen
|
||||||
|
ENV TZ=Europe/Berlin
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# System-Dependencies inkl. PostgreSQL-Tools installieren
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
locales \
|
||||||
|
postgresql-client \
|
||||||
|
tzdata \
|
||||||
|
&& sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \
|
||||||
|
&& locale-gen \
|
||||||
|
&& update-locale LANG=de_DE.UTF-8 \
|
||||||
|
&& ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \
|
||||||
|
&& echo "Europe/Berlin" > /etc/timezone \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
4475
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py
Normale Datei
4475
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
5032
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup
Normale Datei
5032
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
5021
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.old
Normale Datei
5021
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.old
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
124
backups/refactoring_20250616_223724/v2_adminpanel_backup/app_new.py
Normale Datei
124
backups/refactoring_20250616_223724/v2_adminpanel_backup/app_new.py
Normale Datei
@ -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...
|
||||||
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@ -0,0 +1 @@
|
|||||||
|
# Auth module initialization
|
||||||
@ -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
|
||||||
@ -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'))
|
||||||
@ -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}")
|
||||||
@ -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
|
||||||
64
backups/refactoring_20250616_223724/v2_adminpanel_backup/config.py
Normale Datei
64
backups/refactoring_20250616_223724/v2_adminpanel_backup/config.py
Normale Datei
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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;
|
||||||
84
backups/refactoring_20250616_223724/v2_adminpanel_backup/db.py
Normale Datei
84
backups/refactoring_20250616_223724/v2_adminpanel_backup/db.py
Normale Datei
@ -0,0 +1,84 @@
|
|||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import Json, RealDictCursor
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from config import DATABASE_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection():
|
||||||
|
"""Create and return a new database connection"""
|
||||||
|
conn = psycopg2.connect(**DATABASE_CONFIG)
|
||||||
|
conn.set_client_encoding('UTF8')
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db_connection():
|
||||||
|
"""Context manager for database connections"""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db_cursor(conn=None):
|
||||||
|
"""Context manager for database cursors"""
|
||||||
|
if conn is None:
|
||||||
|
with get_db_connection() as connection:
|
||||||
|
cur = connection.cursor()
|
||||||
|
try:
|
||||||
|
yield cur
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
else:
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
yield cur
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_dict_cursor(conn=None):
|
||||||
|
"""Context manager for dictionary cursors"""
|
||||||
|
if conn is None:
|
||||||
|
with get_db_connection() as connection:
|
||||||
|
cur = connection.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
yield cur
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
else:
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
yield cur
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
def execute_query(query, params=None, fetch_one=False, fetch_all=False, as_dict=False):
|
||||||
|
"""Execute a query and optionally fetch results"""
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
cursor_func = get_dict_cursor if as_dict else get_db_cursor
|
||||||
|
with cursor_func(conn) as cur:
|
||||||
|
cur.execute(query, params)
|
||||||
|
|
||||||
|
if fetch_one:
|
||||||
|
return cur.fetchone()
|
||||||
|
elif fetch_all:
|
||||||
|
return cur.fetchall()
|
||||||
|
else:
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def execute_many(query, params_list):
|
||||||
|
"""Execute a query multiple times with different parameters"""
|
||||||
|
with get_db_connection() as conn:
|
||||||
|
with get_db_cursor(conn) as cur:
|
||||||
|
cur.executemany(query, params_list)
|
||||||
|
return cur.rowcount
|
||||||
@ -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;
|
||||||
282
backups/refactoring_20250616_223724/v2_adminpanel_backup/init.sql
Normale Datei
282
backups/refactoring_20250616_223724/v2_adminpanel_backup/init.sql
Normale Datei
@ -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 $$;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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()
|
||||||
29
backups/refactoring_20250616_223724/v2_adminpanel_backup/models.py
Normale Datei
29
backups/refactoring_20250616_223724/v2_adminpanel_backup/models.py
Normale Datei
@ -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
|
||||||
@ -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")
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
flask
|
||||||
|
flask-session
|
||||||
|
psycopg2-binary
|
||||||
|
python-dotenv
|
||||||
|
pyopenssl
|
||||||
|
pandas
|
||||||
|
openpyxl
|
||||||
|
cryptography
|
||||||
|
apscheduler
|
||||||
|
requests
|
||||||
|
python-dateutil
|
||||||
|
bcrypt
|
||||||
|
pyotp
|
||||||
|
qrcode[pil]
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
# Routes module initialization
|
||||||
|
# This module contains all Flask blueprints organized by functionality
|
||||||
@ -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/<int:backup_id>", 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/<int:backup_id>")
|
||||||
|
@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/<int:backup_id>", 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'))
|
||||||
@ -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/<int:license_id>/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/<int:license_id>/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/<int:license_id>/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/<int:license_id>/deactivate-device/<int:device_id>", 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/<int:license_id>/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/<int:license_id>/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
|
||||||
@ -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')
|
||||||
|
})
|
||||||
@ -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")
|
||||||
@ -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/<int:customer_id>", 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/<int:customer_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def delete_customer(customer_id):
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get customer data before deletion
|
||||||
|
customer_data = get_customer_by_id(customer_id)
|
||||||
|
if not customer_data:
|
||||||
|
flash('Kunde nicht gefunden!', 'error')
|
||||||
|
return redirect(url_for('customers.customers'))
|
||||||
|
|
||||||
|
# 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/<int:customer_id>/licenses")
|
||||||
|
@login_required
|
||||||
|
def api_customer_licenses(customer_id):
|
||||||
|
"""API-Endpunkt für die Lizenzen eines Kunden"""
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Hole Kundeninformationen
|
||||||
|
customer = get_customer_by_id(customer_id)
|
||||||
|
if not customer:
|
||||||
|
return jsonify({'error': 'Kunde nicht gefunden'}), 404
|
||||||
|
|
||||||
|
# Hole alle Lizenzen des Kunden
|
||||||
|
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/<int:customer_id>/quick-stats")
|
||||||
|
@login_required
|
||||||
|
def api_customer_quick_stats(customer_id):
|
||||||
|
"""Schnelle Statistiken für einen Kunden"""
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(l.id) as total_licenses,
|
||||||
|
COUNT(CASE WHEN l.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()
|
||||||
@ -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()
|
||||||
@ -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/<int:license_id>", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def edit_license(license_id):
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
# Get current license data for comparison
|
||||||
|
current_license = get_license_by_id(license_id)
|
||||||
|
if not current_license:
|
||||||
|
flash('Lizenz nicht gefunden!', 'error')
|
||||||
|
return redirect(url_for('licenses.licenses'))
|
||||||
|
|
||||||
|
# Update license data
|
||||||
|
new_values = {
|
||||||
|
'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/<int:license_id>", 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)
|
||||||
@ -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/<int:resource_id>', 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/<int:resource_id>')
|
||||||
|
@login_required
|
||||||
|
def resource_history(resource_id):
|
||||||
|
"""Zeigt die Historie einer Ressource"""
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Hole Ressourcen-Info
|
||||||
|
cur.execute("""
|
||||||
|
SELECT resource_type, resource_value, status, is_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()
|
||||||
@ -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/<int:session_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def terminate_session(session_id):
|
||||||
|
"""Beendet eine aktive Session"""
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session info
|
||||||
|
cur.execute("""
|
||||||
|
SELECT license_key, username, 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/<license_key>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def terminate_all_sessions(license_key):
|
||||||
|
"""Beendet alle aktiven Sessions einer Lizenz"""
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Count 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()
|
||||||
@ -0,0 +1,439 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Ressourcen hinzufügen{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
/* Card Styling */
|
||||||
|
.main-card {
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview Section */
|
||||||
|
.preview-card {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 2px dashed #dee2e6;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.preview-card.active {
|
||||||
|
border-color: #28a745;
|
||||||
|
background-color: #e8f5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Format Examples */
|
||||||
|
.example-card {
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.example-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.example-code {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resource Type Selector */
|
||||||
|
.resource-type-selector {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.resource-type-option {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
.resource-type-option:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.resource-type-option.selected {
|
||||||
|
border-color: #007bff;
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
}
|
||||||
|
.resource-type-option .icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea Styling */
|
||||||
|
.resource-input {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
.resource-input:focus {
|
||||||
|
background-color: #fff;
|
||||||
|
border-color: #80bdff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Display */
|
||||||
|
.stats-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-10">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-0">Ressourcen hinzufügen</h1>
|
||||||
|
<p class="text-muted mb-0">Fügen Sie neue Domains, IPs oder Telefonnummern zum Pool hinzu</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('resources', show_test=show_test) }}" class="btn btn-secondary">
|
||||||
|
← Zurück zur Übersicht
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('add_resources', show_test=show_test) }}" id="addResourceForm">
|
||||||
|
<!-- Resource Type Selection -->
|
||||||
|
<div class="card main-card mb-4">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">1️⃣ Ressourcentyp wählen</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<input type="hidden" name="resource_type" id="resource_type" required>
|
||||||
|
<div class="resource-type-selector">
|
||||||
|
<div class="resource-type-option" data-type="domain">
|
||||||
|
<div class="icon">🌐</div>
|
||||||
|
<h6 class="mb-0">Domain</h6>
|
||||||
|
<small class="text-muted">Webseiten-Adressen</small>
|
||||||
|
</div>
|
||||||
|
<div class="resource-type-option" data-type="ipv4">
|
||||||
|
<div class="icon">🖥️</div>
|
||||||
|
<h6 class="mb-0">IPv4</h6>
|
||||||
|
<small class="text-muted">IP-Adressen</small>
|
||||||
|
</div>
|
||||||
|
<div class="resource-type-option" data-type="phone">
|
||||||
|
<div class="icon">📱</div>
|
||||||
|
<h6 class="mb-0">Telefon</h6>
|
||||||
|
<small class="text-muted">Telefonnummern</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resource Input -->
|
||||||
|
<div class="card main-card mb-4">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">2️⃣ Ressourcen eingeben</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="resources_text" class="form-label">
|
||||||
|
Ressourcen (eine pro Zeile)
|
||||||
|
</label>
|
||||||
|
<textarea name="resources_text"
|
||||||
|
id="resources_text"
|
||||||
|
class="form-control resource-input"
|
||||||
|
rows="12"
|
||||||
|
required
|
||||||
|
placeholder="Bitte wählen Sie zuerst einen Ressourcentyp aus..."></textarea>
|
||||||
|
<div class="form-text">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
Geben Sie jede Ressource in eine neue Zeile ein. Duplikate werden automatisch übersprungen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Preview -->
|
||||||
|
<div class="preview-card p-3" id="preview">
|
||||||
|
<h6 class="mb-3">📊 Live-Vorschau</h6>
|
||||||
|
<div class="stats-display">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value" id="validCount">0</div>
|
||||||
|
<div class="stat-label">Gültig</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value text-warning" id="duplicateCount">0</div>
|
||||||
|
<div class="stat-label">Duplikate</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value text-danger" id="invalidCount">0</div>
|
||||||
|
<div class="stat-label">Ungültig</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="errorList" class="mt-3" style="display: none;">
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<strong>Fehler gefunden:</strong>
|
||||||
|
<ul id="errorMessages" class="mb-0"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Format Examples -->
|
||||||
|
<div class="card main-card mb-4">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">💡 Format-Beispiele</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card example-card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">
|
||||||
|
<span class="text-primary">🌐</span> Domains
|
||||||
|
</h6>
|
||||||
|
<pre class="example-code">example.com
|
||||||
|
test-domain.net
|
||||||
|
meine-seite.de
|
||||||
|
subdomain.example.org
|
||||||
|
my-website.io</pre>
|
||||||
|
<div class="alert alert-info mt-3 mb-0">
|
||||||
|
<small>
|
||||||
|
<strong>Format:</strong> Ohne http(s)://<br>
|
||||||
|
<strong>Erlaubt:</strong> Buchstaben, Zahlen, Punkt, Bindestrich
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card example-card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">
|
||||||
|
<span class="text-primary">🖥️</span> IPv4-Adressen
|
||||||
|
</h6>
|
||||||
|
<pre class="example-code">192.168.1.10
|
||||||
|
192.168.1.11
|
||||||
|
10.0.0.1
|
||||||
|
172.16.0.5
|
||||||
|
8.8.8.8</pre>
|
||||||
|
<div class="alert alert-info mt-3 mb-0">
|
||||||
|
<small>
|
||||||
|
<strong>Format:</strong> xxx.xxx.xxx.xxx<br>
|
||||||
|
<strong>Bereich:</strong> 0-255 pro Oktett
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card example-card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">
|
||||||
|
<span class="text-primary">📱</span> Telefonnummern
|
||||||
|
</h6>
|
||||||
|
<pre class="example-code">+491701234567
|
||||||
|
+493012345678
|
||||||
|
+33123456789
|
||||||
|
+441234567890
|
||||||
|
+12125551234</pre>
|
||||||
|
<div class="alert alert-info mt-3 mb-0">
|
||||||
|
<small>
|
||||||
|
<strong>Format:</strong> Mit Ländervorwahl<br>
|
||||||
|
<strong>Start:</strong> Immer mit +
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Data Option -->
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="is_test" name="is_test" {% if show_test %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="is_test">
|
||||||
|
Als Testdaten markieren
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="window.location.href='{{ url_for('resources', show_test=show_test) }}'">
|
||||||
|
<i class="fas fa-times"></i> Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-success btn-lg" id="submitBtn" disabled>
|
||||||
|
<i class="fas fa-plus-circle"></i> Ressourcen hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const typeOptions = document.querySelectorAll('.resource-type-option');
|
||||||
|
const typeInput = document.getElementById('resource_type');
|
||||||
|
const textArea = document.getElementById('resources_text');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const form = document.getElementById('addResourceForm');
|
||||||
|
|
||||||
|
// Preview elements
|
||||||
|
const validCount = document.getElementById('validCount');
|
||||||
|
const duplicateCount = document.getElementById('duplicateCount');
|
||||||
|
const invalidCount = document.getElementById('invalidCount');
|
||||||
|
const errorList = document.getElementById('errorList');
|
||||||
|
const errorMessages = document.getElementById('errorMessages');
|
||||||
|
const preview = document.getElementById('preview');
|
||||||
|
|
||||||
|
let selectedType = null;
|
||||||
|
|
||||||
|
// Placeholder texts for different types
|
||||||
|
const placeholders = {
|
||||||
|
domain: `example.com
|
||||||
|
test-site.net
|
||||||
|
my-domain.org
|
||||||
|
subdomain.example.com`,
|
||||||
|
ipv4: `192.168.1.1
|
||||||
|
10.0.0.1
|
||||||
|
172.16.0.1
|
||||||
|
8.8.8.8`,
|
||||||
|
phone: `+491234567890
|
||||||
|
+4930123456
|
||||||
|
+33123456789
|
||||||
|
+12125551234`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resource type selection
|
||||||
|
typeOptions.forEach(option => {
|
||||||
|
option.addEventListener('click', function() {
|
||||||
|
typeOptions.forEach(opt => opt.classList.remove('selected'));
|
||||||
|
this.classList.add('selected');
|
||||||
|
selectedType = this.dataset.type;
|
||||||
|
typeInput.value = selectedType;
|
||||||
|
textArea.placeholder = placeholders[selectedType] || '';
|
||||||
|
textArea.disabled = false;
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validation functions
|
||||||
|
function validateDomain(domain) {
|
||||||
|
const domainRegex = /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9][a-zA-Z0-9-_]+\.[a-zA-Z]{2,}$/;
|
||||||
|
return domainRegex.test(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateIPv4(ip) {
|
||||||
|
const ipRegex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
|
return ipRegex.test(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePhone(phone) {
|
||||||
|
const phoneRegex = /^\+[1-9]\d{6,14}$/;
|
||||||
|
return phoneRegex.test(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update preview function
|
||||||
|
function updatePreview() {
|
||||||
|
if (!selectedType) {
|
||||||
|
preview.classList.remove('active');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = textArea.value.split('\n').filter(line => line.trim() !== '');
|
||||||
|
const uniqueResources = new Set();
|
||||||
|
const errors = [];
|
||||||
|
let valid = 0;
|
||||||
|
let duplicates = 0;
|
||||||
|
let invalid = 0;
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (uniqueResources.has(trimmed)) {
|
||||||
|
duplicates++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isValid = false;
|
||||||
|
switch(selectedType) {
|
||||||
|
case 'domain':
|
||||||
|
isValid = validateDomain(trimmed);
|
||||||
|
break;
|
||||||
|
case 'ipv4':
|
||||||
|
isValid = validateIPv4(trimmed);
|
||||||
|
break;
|
||||||
|
case 'phone':
|
||||||
|
isValid = validatePhone(trimmed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
valid++;
|
||||||
|
uniqueResources.add(trimmed);
|
||||||
|
} else {
|
||||||
|
invalid++;
|
||||||
|
errors.push(`Zeile ${index + 1}: "${trimmed}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
validCount.textContent = valid;
|
||||||
|
duplicateCount.textContent = duplicates;
|
||||||
|
invalidCount.textContent = invalid;
|
||||||
|
|
||||||
|
// Show/hide error list
|
||||||
|
if (errors.length > 0) {
|
||||||
|
errorList.style.display = 'block';
|
||||||
|
errorMessages.innerHTML = errors.map(err => `<li>${err}</li>`).join('');
|
||||||
|
} else {
|
||||||
|
errorList.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable/disable submit button
|
||||||
|
submitBtn.disabled = valid === 0 || invalid > 0;
|
||||||
|
|
||||||
|
// Update preview appearance
|
||||||
|
if (lines.length > 0) {
|
||||||
|
preview.classList.add('active');
|
||||||
|
} else {
|
||||||
|
preview.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live validation
|
||||||
|
textArea.addEventListener('input', updatePreview);
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
if (submitBtn.disabled) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Bitte beheben Sie alle Fehler bevor Sie fortfahren.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
textArea.disabled = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,318 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Log{% endblock %}
|
||||||
|
|
||||||
|
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||||
|
<th>
|
||||||
|
{% if current_sort == field %}
|
||||||
|
<a href="{{ url_for('audit_log', sort=field, order='desc' if current_order == 'asc' else 'asc', user=filter_user, action=filter_action, entity=filter_entity, page=1) }}"
|
||||||
|
class="server-sortable">
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('audit_log', sort=field, order='asc', user=filter_user, action=filter_action, entity=filter_entity, page=1) }}"
|
||||||
|
class="server-sortable">
|
||||||
|
{% endif %}
|
||||||
|
{{ label }}
|
||||||
|
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||||
|
{% if current_sort == field %}
|
||||||
|
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||||
|
{% else %}
|
||||||
|
↕
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.audit-details {
|
||||||
|
font-size: 0.85em;
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.json-display {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8em;
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.action-CREATE { color: #28a745; }
|
||||||
|
.action-UPDATE { color: #007bff; }
|
||||||
|
.action-DELETE { color: #dc3545; }
|
||||||
|
.action-LOGIN { color: #17a2b8; }
|
||||||
|
.action-LOGOUT { color: #6c757d; }
|
||||||
|
.action-AUTO_LOGOUT { color: #fd7e14; }
|
||||||
|
.action-EXPORT { color: #ffc107; }
|
||||||
|
.action-GENERATE_KEY { color: #20c997; }
|
||||||
|
.action-CREATE_BATCH { color: #6610f2; }
|
||||||
|
.action-BACKUP { color: #5a67d8; }
|
||||||
|
.action-LOGIN_2FA_SUCCESS { color: #00a86b; }
|
||||||
|
.action-LOGIN_2FA_BACKUP { color: #059862; }
|
||||||
|
.action-LOGIN_2FA_FAILED { color: #e53e3e; }
|
||||||
|
.action-LOGIN_BLOCKED { color: #b91c1c; }
|
||||||
|
.action-RESTORE { color: #4299e1; }
|
||||||
|
.action-PASSWORD_CHANGE { color: #805ad5; }
|
||||||
|
.action-2FA_ENABLED { color: #38a169; }
|
||||||
|
.action-2FA_DISABLED { color: #e53e3e; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2>📝 Log</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" action="/audit" id="auditFilterForm">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="user" class="form-label">Benutzer</label>
|
||||||
|
<input type="text" class="form-control" id="user" name="user"
|
||||||
|
placeholder="Benutzername..." value="{{ filter_user }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="action" class="form-label">Aktion</label>
|
||||||
|
<select class="form-select" id="action" name="action">
|
||||||
|
<option value="">Alle Aktionen</option>
|
||||||
|
<option value="CREATE" {% if filter_action == 'CREATE' %}selected{% endif %}>➕ Erstellt</option>
|
||||||
|
<option value="UPDATE" {% if filter_action == 'UPDATE' %}selected{% endif %}>✏️ Bearbeitet</option>
|
||||||
|
<option value="DELETE" {% if filter_action == 'DELETE' %}selected{% endif %}>🗑️ Gelöscht</option>
|
||||||
|
<option value="LOGIN" {% if filter_action == 'LOGIN' %}selected{% endif %}>🔑 Anmeldung</option>
|
||||||
|
<option value="LOGOUT" {% if filter_action == 'LOGOUT' %}selected{% endif %}>🚪 Abmeldung</option>
|
||||||
|
<option value="AUTO_LOGOUT" {% if filter_action == 'AUTO_LOGOUT' %}selected{% endif %}>⏰ Auto-Logout</option>
|
||||||
|
<option value="EXPORT" {% if filter_action == 'EXPORT' %}selected{% endif %}>📥 Export</option>
|
||||||
|
<option value="GENERATE_KEY" {% if filter_action == 'GENERATE_KEY' %}selected{% endif %}>🔑 Key generiert</option>
|
||||||
|
<option value="CREATE_BATCH" {% if filter_action == 'CREATE_BATCH' %}selected{% endif %}>🔑 Batch erstellt</option>
|
||||||
|
<option value="BACKUP" {% if filter_action == 'BACKUP' %}selected{% endif %}>💾 Backup</option>
|
||||||
|
<option value="LOGIN_2FA_SUCCESS" {% if filter_action == 'LOGIN_2FA_SUCCESS' %}selected{% endif %}>🔐 2FA-Anmeldung</option>
|
||||||
|
<option value="LOGIN_2FA_BACKUP" {% if filter_action == 'LOGIN_2FA_BACKUP' %}selected{% endif %}>🔒 2FA-Backup-Code</option>
|
||||||
|
<option value="LOGIN_2FA_FAILED" {% if filter_action == 'LOGIN_2FA_FAILED' %}selected{% endif %}>⛔ 2FA-Fehlgeschlagen</option>
|
||||||
|
<option value="LOGIN_BLOCKED" {% if filter_action == 'LOGIN_BLOCKED' %}selected{% endif %}>🚫 Login-Blockiert</option>
|
||||||
|
<option value="RESTORE" {% if filter_action == 'RESTORE' %}selected{% endif %}>🔄 Wiederhergestellt</option>
|
||||||
|
<option value="PASSWORD_CHANGE" {% if filter_action == 'PASSWORD_CHANGE' %}selected{% endif %}>🔐 Passwort geändert</option>
|
||||||
|
<option value="2FA_ENABLED" {% if filter_action == '2FA_ENABLED' %}selected{% endif %}>✅ 2FA aktiviert</option>
|
||||||
|
<option value="2FA_DISABLED" {% if filter_action == '2FA_DISABLED' %}selected{% endif %}>❌ 2FA deaktiviert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="entity" class="form-label">Entität</label>
|
||||||
|
<select class="form-select" id="entity" name="entity">
|
||||||
|
<option value="">Alle Entitäten</option>
|
||||||
|
<option value="license" {% if filter_entity == 'license' %}selected{% endif %}>Lizenz</option>
|
||||||
|
<option value="customer" {% if filter_entity == 'customer' %}selected{% endif %}>Kunde</option>
|
||||||
|
<option value="user" {% if filter_entity == 'user' %}selected{% endif %}>Benutzer</option>
|
||||||
|
<option value="session" {% if filter_entity == 'session' %}selected{% endif %}>Session</option>
|
||||||
|
<option value="database" {% if filter_entity == 'database' %}selected{% endif %}>Datenbank</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/audit" class="btn btn-outline-secondary">Zurücksetzen</a>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-download"></i> Export
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="/export/audit?format=excel&user={{ filter_user }}&action={{ filter_action }}&entity={{ filter_entity }}">
|
||||||
|
<i class="bi bi-file-earmark-excel text-success"></i> Excel Export</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/export/audit?format=csv&user={{ filter_user }}&action={{ filter_action }}&entity={{ filter_entity }}">
|
||||||
|
<i class="bi bi-file-earmark-text"></i> CSV Export</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audit Log Tabelle -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{{ 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) }}
|
||||||
|
<th>Details</th>
|
||||||
|
{{ sortable_header('IP-Adresse', 'ip', sort, order) }}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log[1].strftime('%d.%m.%Y %H:%M:%S') }}</td>
|
||||||
|
<td><strong>{{ log[2] }}</strong></td>
|
||||||
|
<td>
|
||||||
|
<span class="action-{{ log[3] }}">
|
||||||
|
{% 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 %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ log[4] }}
|
||||||
|
{% if log[5] %}
|
||||||
|
<small class="text-muted">#{{ log[5] }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="audit-details">
|
||||||
|
{% if log[10] %}
|
||||||
|
<div class="mb-1"><small class="text-muted">{{ log[10] }}</small></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if log[6] and log[3] == 'DELETE' %}
|
||||||
|
<details>
|
||||||
|
<summary>Gelöschte Werte</summary>
|
||||||
|
<div class="json-display">
|
||||||
|
{% for key, value in log[6].items() %}
|
||||||
|
<strong>{{ key }}:</strong> {{ value }}<br>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% elif log[6] and log[7] and log[3] == 'UPDATE' %}
|
||||||
|
<details>
|
||||||
|
<summary>Änderungen anzeigen</summary>
|
||||||
|
<div class="json-display">
|
||||||
|
<strong>Vorher:</strong><br>
|
||||||
|
{% for key, value in log[6].items() %}
|
||||||
|
{% if log[7][key] != value %}
|
||||||
|
{{ key }}: {{ value }}<br>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<hr class="my-1">
|
||||||
|
<strong>Nachher:</strong><br>
|
||||||
|
{% for key, value in log[7].items() %}
|
||||||
|
{% if log[6][key] != value %}
|
||||||
|
{{ key }}: {{ value }}<br>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% elif log[7] and log[3] == 'CREATE' %}
|
||||||
|
<details>
|
||||||
|
<summary>Erstellte Werte</summary>
|
||||||
|
<div class="json-display">
|
||||||
|
{% for key, value in log[7].items() %}
|
||||||
|
<strong>{{ key }}:</strong> {{ value }}<br>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<small class="text-muted">{{ log[8] or '-' }}</small>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if not logs %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<p class="text-muted">Keine Audit-Log-Einträge gefunden.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<nav aria-label="Seitennavigation" class="mt-3">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
<!-- Erste Seite -->
|
||||||
|
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('audit_log', page=1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">Erste</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Vorherige Seite -->
|
||||||
|
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('audit_log', page=page-1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">←</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Seitenzahlen -->
|
||||||
|
{% for p in range(1, total_pages + 1) %}
|
||||||
|
{% if p >= page - 2 and p <= page + 2 %}
|
||||||
|
<li class="page-item {% if p == page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('audit_log', page=p, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Nächste Seite -->
|
||||||
|
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('audit_log', page=page+1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">→</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Letzte Seite -->
|
||||||
|
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('audit_log', page=total_pages, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">Letzte</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-center text-muted">
|
||||||
|
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Einträge
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Live Filtering für Audit Log
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const filterForm = document.getElementById('auditFilterForm');
|
||||||
|
const userInput = document.getElementById('user');
|
||||||
|
const actionSelect = document.getElementById('action');
|
||||||
|
const entitySelect = document.getElementById('entity');
|
||||||
|
|
||||||
|
// Debounce timer für Textfelder
|
||||||
|
let searchTimeout;
|
||||||
|
|
||||||
|
// Live-Filter für Benutzer-Textfeld (mit 300ms Verzögerung)
|
||||||
|
userInput.addEventListener('input', function() {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
filterForm.submit();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live-Filter für Dropdowns (sofort)
|
||||||
|
actionSelect.addEventListener('change', function() {
|
||||||
|
filterForm.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
entitySelect.addEventListener('change', function() {
|
||||||
|
filterForm.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,228 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Backup-Codes{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.success-icon {
|
||||||
|
font-size: 5rem;
|
||||||
|
animation: bounce 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-20px); }
|
||||||
|
}
|
||||||
|
.backup-code {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
padding: 8px 15px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 2px dashed #dee2e6;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
.backup-codes-container {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.action-buttons .btn {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.backup-codes-container {
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<!-- Success Message -->
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<div class="success-icon text-success">✅</div>
|
||||||
|
<h1 class="mt-3">2FA erfolgreich aktiviert!</h1>
|
||||||
|
<p class="lead text-muted">Ihre Zwei-Faktor-Authentifizierung ist jetzt aktiv.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup Codes Card -->
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header bg-warning text-dark">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<span style="font-size: 1.5rem; vertical-align: middle;">⚠️</span>
|
||||||
|
Wichtig: Ihre Backup-Codes
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
<strong>Was sind Backup-Codes?</strong><br>
|
||||||
|
Diese Codes ermöglichen Ihnen den Zugang zu Ihrem Account, falls Sie keinen Zugriff auf Ihre Authenticator-App haben.
|
||||||
|
<strong>Jeder Code kann nur einmal verwendet werden.</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup Codes Display -->
|
||||||
|
<div class="backup-codes-container text-center">
|
||||||
|
<h5 class="mb-4">Ihre 8 Backup-Codes:</h5>
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
{% for code in backup_codes %}
|
||||||
|
<div class="col-md-6 col-lg-4 mb-3">
|
||||||
|
<div class="backup-code">{{ code }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="text-center action-buttons no-print">
|
||||||
|
<button type="button" class="btn btn-primary btn-lg" onclick="downloadCodes()">
|
||||||
|
💾 Als Datei speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-lg" onclick="printCodes()">
|
||||||
|
🖨️ Drucken
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-info btn-lg" onclick="copyCodes()">
|
||||||
|
📋 Alle kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- Security Tips -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<h6>❌ Nicht empfohlen:</h6>
|
||||||
|
<ul class="mb-0 small">
|
||||||
|
<li>Im selben Passwort-Manager wie Ihr Passwort</li>
|
||||||
|
<li>Als Foto auf Ihrem Handy</li>
|
||||||
|
<li>In einer unverschlüsselten Datei</li>
|
||||||
|
<li>Per E-Mail an sich selbst</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h6>✅ Empfohlen:</h6>
|
||||||
|
<ul class="mb-0 small">
|
||||||
|
<li>Ausgedruckt in einem Safe</li>
|
||||||
|
<li>In einem separaten Passwort-Manager</li>
|
||||||
|
<li>Verschlüsselt auf einem USB-Stick</li>
|
||||||
|
<li>An einem sicheren Ort zu Hause</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirmation -->
|
||||||
|
<div class="text-center mt-4 no-print">
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="confirmSaved" onchange="checkConfirmation()">
|
||||||
|
<label class="form-check-label" for="confirmSaved">
|
||||||
|
Ich habe die Backup-Codes sicher gespeichert
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('profile') }}" class="btn btn-lg btn-success" id="continueBtn" style="display: none;">
|
||||||
|
✅ Weiter zum Profil
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const backupCodes = {{ backup_codes | tojson }};
|
||||||
|
|
||||||
|
function checkConfirmation() {
|
||||||
|
const checkbox = document.getElementById('confirmSaved');
|
||||||
|
const continueBtn = document.getElementById('continueBtn');
|
||||||
|
continueBtn.style.display = checkbox.checked ? 'inline-block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCodes() {
|
||||||
|
const content = `V2 Admin Panel - Backup Codes
|
||||||
|
=====================================
|
||||||
|
Generiert am: ${new Date().toLocaleString('de-DE')}
|
||||||
|
|
||||||
|
WICHTIG: Bewahren Sie diese Codes sicher auf!
|
||||||
|
Jeder Code kann nur einmal verwendet werden.
|
||||||
|
|
||||||
|
Ihre 8 Backup-Codes:
|
||||||
|
--------------------
|
||||||
|
${backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}
|
||||||
|
|
||||||
|
Sicherheitshinweise:
|
||||||
|
-------------------
|
||||||
|
✓ Bewahren Sie diese Codes getrennt von Ihrem Passwort auf
|
||||||
|
✓ Speichern Sie sie an einem sicheren physischen Ort
|
||||||
|
✓ Teilen Sie diese Codes niemals mit anderen
|
||||||
|
✓ Jeder Code funktioniert nur einmal
|
||||||
|
|
||||||
|
Bei Verlust Ihrer Authenticator-App:
|
||||||
|
------------------------------------
|
||||||
|
1. Gehen Sie zur Login-Seite
|
||||||
|
2. Geben Sie Benutzername und Passwort ein
|
||||||
|
3. Klicken Sie auf "Backup-Code verwenden"
|
||||||
|
4. Geben Sie einen dieser Codes ein
|
||||||
|
|
||||||
|
Nach Verwendung eines Codes sollten Sie neue Backup-Codes generieren.`;
|
||||||
|
|
||||||
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `v2-backup-codes-${new Date().toISOString().split('T')[0]}.txt`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
const btn = event.target;
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '✅ Heruntergeladen!';
|
||||||
|
btn.classList.remove('btn-primary');
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.classList.remove('btn-success');
|
||||||
|
btn.classList.add('btn-primary');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printCodes() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyCodes() {
|
||||||
|
const codesText = backupCodes.join('\n');
|
||||||
|
navigator.clipboard.writeText(codesText).then(function() {
|
||||||
|
// Visual feedback
|
||||||
|
const btn = event.target;
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '✅ Kopiert!';
|
||||||
|
btn.classList.remove('btn-info');
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.classList.remove('btn-success');
|
||||||
|
btn.classList.add('btn-info');
|
||||||
|
}, 2000);
|
||||||
|
}).catch(function(err) {
|
||||||
|
alert('Fehler beim Kopieren. Bitte manuell kopieren.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,301 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Backup-Verwaltung{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.status-success { color: #28a745; }
|
||||||
|
.status-failed { color: #dc3545; }
|
||||||
|
.status-in_progress { color: #ffc107; }
|
||||||
|
.backup-actions { white-space: nowrap; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>💾 Backup-Verwaltung</h2>
|
||||||
|
<div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup-Info -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">📅 Letztes erfolgreiches Backup</h5>
|
||||||
|
{% if last_backup %}
|
||||||
|
<p class="mb-1"><strong>Zeitpunkt:</strong> {{ last_backup[0].strftime('%d.%m.%Y %H:%M:%S') }}</p>
|
||||||
|
<p class="mb-1"><strong>Größe:</strong> {{ (last_backup[1] / 1024 / 1024)|round(2) }} MB</p>
|
||||||
|
<p class="mb-0"><strong>Dauer:</strong> {{ last_backup[2]|round(1) }} Sekunden</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">🔧 Backup-Aktionen</h5>
|
||||||
|
<button id="createBackupBtn" class="btn btn-primary" onclick="createBackup()">
|
||||||
|
💾 Backup jetzt erstellen
|
||||||
|
</button>
|
||||||
|
<p class="text-muted mt-2 mb-0">
|
||||||
|
<small>Automatische Backups: Täglich um 03:00 Uhr</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup-Historie -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">📋 Backup-Historie</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover sortable-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sortable" data-type="date">Zeitstempel</th>
|
||||||
|
<th class="sortable">Dateiname</th>
|
||||||
|
<th class="sortable" data-type="numeric">Größe</th>
|
||||||
|
<th class="sortable">Typ</th>
|
||||||
|
<th class="sortable">Status</th>
|
||||||
|
<th class="sortable">Erstellt von</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for backup in backups %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ backup[6].strftime('%d.%m.%Y %H:%M:%S') }}</td>
|
||||||
|
<td>
|
||||||
|
<small>{{ backup[1] }}</small>
|
||||||
|
{% if backup[11] %}
|
||||||
|
<span class="badge bg-info ms-1">🔒 Verschlüsselt</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if backup[2] %}
|
||||||
|
{{ (backup[2] / 1024 / 1024)|round(2) }} MB
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if backup[3] == 'manual' %}
|
||||||
|
<span class="badge bg-primary">Manuell</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Automatisch</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if backup[4] == 'success' %}
|
||||||
|
<span class="status-success">✅ Erfolgreich</span>
|
||||||
|
{% elif backup[4] == 'failed' %}
|
||||||
|
<span class="status-failed" title="{{ backup[5] }}">❌ Fehlgeschlagen</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-in_progress">⏳ In Bearbeitung</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ backup[7] }}</td>
|
||||||
|
<td>
|
||||||
|
{% if backup[8] and backup[9] %}
|
||||||
|
<small>
|
||||||
|
{{ backup[8] }} Tabellen<br>
|
||||||
|
{{ backup[9] }} Datensätze<br>
|
||||||
|
{% if backup[10] %}
|
||||||
|
{{ backup[10]|round(1) }}s
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="backup-actions">
|
||||||
|
{% if backup[4] == 'success' %}
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<a href="/backup/download/{{ backup[0] }}"
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
title="Backup herunterladen">
|
||||||
|
📥 Download
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-outline-success"
|
||||||
|
onclick="restoreBackup({{ backup[0] }}, '{{ backup[1] }}')"
|
||||||
|
title="Backup wiederherstellen">
|
||||||
|
🔄 Wiederherstellen
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger"
|
||||||
|
onclick="deleteBackup({{ backup[0] }}, '{{ backup[1] }}')"
|
||||||
|
title="Backup löschen">
|
||||||
|
🗑️ Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if not backups %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<p class="text-muted">Noch keine Backups vorhanden.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wiederherstellungs-Modal -->
|
||||||
|
<div class="modal fade" id="restoreModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">🔄 Backup wiederherstellen</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<strong>⚠️ Warnung:</strong> Bei der Wiederherstellung werden alle aktuellen Daten überschrieben!
|
||||||
|
</div>
|
||||||
|
<p>Backup: <strong id="restoreFilename"></strong></p>
|
||||||
|
<form id="restoreForm">
|
||||||
|
<input type="hidden" id="restoreBackupId">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="encryptionKey" class="form-label">Verschlüsselungs-Passwort (optional)</label>
|
||||||
|
<input type="password" class="form-control" id="encryptionKey"
|
||||||
|
placeholder="Leer lassen für Standard-Passwort">
|
||||||
|
<small class="text-muted">
|
||||||
|
Falls das Backup mit einem anderen Passwort verschlüsselt wurde
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmRestore()">
|
||||||
|
⚠️ Wiederherstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function createBackup() {
|
||||||
|
const btn = document.getElementById('createBackupBtn');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '⏳ Backup wird erstellt...';
|
||||||
|
|
||||||
|
fetch('/backup/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert('✅ ' + data.message);
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('❌ ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('❌ Fehler beim Erstellen des Backups: ' + error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreBackup(backupId, filename) {
|
||||||
|
document.getElementById('restoreBackupId').value = backupId;
|
||||||
|
document.getElementById('restoreFilename').textContent = filename;
|
||||||
|
document.getElementById('encryptionKey').value = '';
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('restoreModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRestore() {
|
||||||
|
if (!confirm('Wirklich wiederherstellen? Alle aktuellen Daten werden überschrieben!')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupId = document.getElementById('restoreBackupId').value;
|
||||||
|
const encryptionKey = document.getElementById('encryptionKey').value;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
if (encryptionKey) {
|
||||||
|
formData.append('encryption_key', encryptionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal schließen
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('restoreModal')).hide();
|
||||||
|
|
||||||
|
// Loading anzeigen
|
||||||
|
const loadingDiv = document.createElement('div');
|
||||||
|
loadingDiv.className = 'position-fixed top-50 start-50 translate-middle';
|
||||||
|
loadingDiv.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
|
||||||
|
document.body.appendChild(loadingDiv);
|
||||||
|
|
||||||
|
fetch(`/backup/restore/${backupId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert('✅ ' + data.message + '\n\nDie Seite wird neu geladen...');
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
alert('❌ ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('❌ Fehler bei der Wiederherstellung: ' + error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
document.body.removeChild(loadingDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBackup(backupId, filename) {
|
||||||
|
if (!confirm(`Soll das Backup "${filename}" wirklich gelöscht werden?\n\nDieser Vorgang kann nicht rückgängig gemacht werden!`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/backup/delete/${backupId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert('✅ ' + data.message);
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('❌ ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('❌ Fehler beim Löschen des Backups: ' + error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,679 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Admin Panel{% endblock %} - Lizenzverwaltung</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
<style>
|
||||||
|
/* Global Status Colors */
|
||||||
|
:root {
|
||||||
|
--status-active: #28a745;
|
||||||
|
--status-warning: #ffc107;
|
||||||
|
--status-danger: #dc3545;
|
||||||
|
--status-inactive: #6c757d;
|
||||||
|
--status-info: #17a2b8;
|
||||||
|
--sidebar-width: 250px;
|
||||||
|
--sidebar-collapsed: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Classes - Global */
|
||||||
|
.status-aktiv { color: var(--status-active) !important; }
|
||||||
|
.status-ablaufend { color: var(--status-warning) !important; }
|
||||||
|
.status-abgelaufen { color: var(--status-danger) !important; }
|
||||||
|
.status-deaktiviert { color: var(--status-inactive) !important; }
|
||||||
|
|
||||||
|
/* Badge Variants */
|
||||||
|
.badge-aktiv { background-color: var(--status-active) !important; }
|
||||||
|
.badge-ablaufend { background-color: var(--status-warning) !important; color: #000 !important; }
|
||||||
|
.badge-abgelaufen { background-color: var(--status-danger) !important; }
|
||||||
|
.badge-deaktiviert { background-color: var(--status-inactive) !important; }
|
||||||
|
|
||||||
|
/* Session Timer Styles */
|
||||||
|
#session-timer {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-normal {
|
||||||
|
background-color: var(--status-active);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-warning {
|
||||||
|
background-color: var(--status-warning);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-danger {
|
||||||
|
background-color: var(--status-danger);
|
||||||
|
color: white;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-critical {
|
||||||
|
background-color: var(--status-danger);
|
||||||
|
color: white;
|
||||||
|
animation: blink 0.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 49% { opacity: 1; }
|
||||||
|
50%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-warning-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Improvements */
|
||||||
|
.table-container {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-sticky thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: #fff;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-sticky thead th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline Actions */
|
||||||
|
.btn-copy {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy.copied {
|
||||||
|
background-color: var(--status-active);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.form-switch-custom {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-switch-custom .form-check-input {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 3em;
|
||||||
|
height: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-switch-custom .form-check-input:checked {
|
||||||
|
background-color: var(--status-active);
|
||||||
|
border-color: var(--status-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bulk Actions Bar */
|
||||||
|
.bulk-actions {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #212529;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
display: none;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 -4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-actions.show {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox Styling */
|
||||||
|
.checkbox-cell {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check-input-custom {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 1.2em;
|
||||||
|
height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sortable Table Styles */
|
||||||
|
.sortable-table th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
padding-right: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-table th.sortable:hover {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-table th.sortable::after {
|
||||||
|
content: '↕';
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-table th.sortable.asc::after {
|
||||||
|
content: '↑';
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--status-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-table th.sortable.desc::after {
|
||||||
|
content: '↓';
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--status-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server-side sortable styles */
|
||||||
|
.server-sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-sortable:hover {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-indicator {
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-indicator.active {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Navigation */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 56px;
|
||||||
|
left: 0;
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-right: 1px solid #dee2e6;
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: all 0.3s;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: var(--sidebar-collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav .nav-item {
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav .nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: #495057;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav .nav-link:hover {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav .nav-link.active {
|
||||||
|
background-color: var(--bs-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav .nav-link i {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-nav .nav-link span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-submenu {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background-color: #f1f3f4;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-submenu .nav-link {
|
||||||
|
padding-left: 2.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Arrow indicator for items with submenus */
|
||||||
|
.nav-link.has-submenu::after {
|
||||||
|
content: '▾';
|
||||||
|
float: right;
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content with Sidebar */
|
||||||
|
.main-content {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
transition: all 0.3s;
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content.expanded {
|
||||||
|
margin-left: var(--sidebar-collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Responsive Adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.show {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<nav class="navbar navbar-dark bg-dark navbar-expand-lg">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a href="/" class="navbar-brand text-decoration-none">🎛️ AccountForger - Admin Panel</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<!-- Navigation removed - access via sidebar -->
|
||||||
|
</ul>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div id="session-timer" class="timer-normal me-3">
|
||||||
|
⏱️ <span id="timer-display">5:00</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-white me-3">Angemeldet als: {{ username }}</span>
|
||||||
|
<a href="/profile" class="btn btn-outline-light btn-sm me-2">👤 Profil</a>
|
||||||
|
<a href="/logout" class="btn btn-outline-light btn-sm">Abmelden</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Sidebar Navigation -->
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li class="nav-item {% if request.endpoint in ['customers_licenses', 'edit_customer', 'create_customer', 'edit_license', 'create_license', 'batch_licenses'] %}has-active-child{% endif %}">
|
||||||
|
<a class="nav-link has-submenu {% if request.endpoint == 'customers_licenses' %}active{% endif %}" href="/customers-licenses">
|
||||||
|
<i class="bi bi-people"></i>
|
||||||
|
<span>Kunden & Lizenzen</span>
|
||||||
|
</a>
|
||||||
|
<ul class="sidebar-submenu">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'create_customer' %}active{% endif %}" href="/customer/create">
|
||||||
|
<i class="bi bi-person-plus"></i>
|
||||||
|
<span>Neuer Kunde</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'create_license' %}active{% endif %}" href="/create">
|
||||||
|
<i class="bi bi-plus-circle"></i>
|
||||||
|
<span>Neue Lizenz</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'batch_licenses' %}active{% endif %}" href="/batch">
|
||||||
|
<i class="bi bi-stack"></i>
|
||||||
|
<span>Batch-Erstellung</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item {% if request.endpoint in ['resources', 'add_resources'] %}has-active-child{% endif %}">
|
||||||
|
<a class="nav-link has-submenu {% if request.endpoint == 'resources' %}active{% endif %}" href="/resources">
|
||||||
|
<i class="bi bi-box-seam"></i>
|
||||||
|
<span>Resource Pool</span>
|
||||||
|
</a>
|
||||||
|
<ul class="sidebar-submenu">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'add_resources' %}active{% endif %}" href="/resources/add">
|
||||||
|
<i class="bi bi-plus-square"></i>
|
||||||
|
<span>Ressourcen hinzufügen</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}" href="/audit">
|
||||||
|
<i class="bi bi-journal-text"></i>
|
||||||
|
<span>Audit-Log</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'sessions' %}active{% endif %}" href="/sessions">
|
||||||
|
<i class="bi bi-people"></i>
|
||||||
|
<span>Sitzungen</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'backups' %}active{% endif %}" href="/backups">
|
||||||
|
<i class="bi bi-cloud-download"></i>
|
||||||
|
<span>Backups</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'blocked_ips' %}active{% endif %}" href="/security/blocked-ips">
|
||||||
|
<i class="bi bi-shield-lock"></i>
|
||||||
|
<span>Sicherheit</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="main-content" id="main-content">
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session Warning Modal -->
|
||||||
|
<div id="session-warning" class="session-warning-modal" style="display: none;">
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<strong>⚠️ Session läuft ab!</strong><br>
|
||||||
|
Ihre Session läuft in weniger als 1 Minute ab.<br>
|
||||||
|
<button type="button" class="btn btn-sm btn-success mt-2" onclick="extendSession()">
|
||||||
|
Session verlängern
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Session-Timer Konfiguration
|
||||||
|
const SESSION_TIMEOUT = 5 * 60; // 5 Minuten in Sekunden
|
||||||
|
let timeRemaining = SESSION_TIMEOUT;
|
||||||
|
let timerInterval;
|
||||||
|
let warningShown = false;
|
||||||
|
let lastActivity = Date.now();
|
||||||
|
|
||||||
|
// Timer Display Update
|
||||||
|
function updateTimerDisplay() {
|
||||||
|
const minutes = Math.floor(timeRemaining / 60);
|
||||||
|
const seconds = timeRemaining % 60;
|
||||||
|
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
document.getElementById('timer-display').textContent = display;
|
||||||
|
|
||||||
|
// Timer-Farbe ändern
|
||||||
|
const timerElement = document.getElementById('session-timer');
|
||||||
|
timerElement.className = timerElement.className.replace(/timer-\w+/, '');
|
||||||
|
|
||||||
|
if (timeRemaining <= 30) {
|
||||||
|
timerElement.classList.add('timer-critical');
|
||||||
|
} else if (timeRemaining <= 60) {
|
||||||
|
timerElement.classList.add('timer-danger');
|
||||||
|
if (!warningShown) {
|
||||||
|
showSessionWarning();
|
||||||
|
warningShown = true;
|
||||||
|
}
|
||||||
|
} else if (timeRemaining <= 120) {
|
||||||
|
timerElement.classList.add('timer-warning');
|
||||||
|
} else {
|
||||||
|
timerElement.classList.add('timer-normal');
|
||||||
|
warningShown = false;
|
||||||
|
hideSessionWarning();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session Warning anzeigen
|
||||||
|
function showSessionWarning() {
|
||||||
|
document.getElementById('session-warning').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session Warning verstecken
|
||||||
|
function hideSessionWarning() {
|
||||||
|
document.getElementById('session-warning').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer zurücksetzen
|
||||||
|
function resetTimer() {
|
||||||
|
timeRemaining = SESSION_TIMEOUT;
|
||||||
|
lastActivity = Date.now();
|
||||||
|
updateTimerDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session verlängern
|
||||||
|
function extendSession() {
|
||||||
|
fetch('/heartbeat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
resetTimer();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Heartbeat error:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer Countdown
|
||||||
|
function countdown() {
|
||||||
|
timeRemaining--;
|
||||||
|
updateTimerDisplay();
|
||||||
|
|
||||||
|
if (timeRemaining <= 0) {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
window.location.href = '/logout';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktivitäts-Tracking
|
||||||
|
function trackActivity() {
|
||||||
|
const now = Date.now();
|
||||||
|
// Nur wenn mehr als 5 Sekunden seit letzter Aktivität
|
||||||
|
if (now - lastActivity > 5000) {
|
||||||
|
lastActivity = now;
|
||||||
|
extendSession(); // resetTimer() wird in extendSession nach erfolgreicher Response aufgerufen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listeners für Benutzeraktivität
|
||||||
|
document.addEventListener('click', trackActivity);
|
||||||
|
document.addEventListener('keypress', trackActivity);
|
||||||
|
document.addEventListener('mousemove', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
// Mausbewegung nur alle 30 Sekunden tracken
|
||||||
|
if (now - lastActivity > 30000) {
|
||||||
|
trackActivity();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// AJAX Interceptor für automatische Session-Verlängerung
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
window.fetch = function(...args) {
|
||||||
|
// Nur für non-heartbeat requests den Timer verlängern
|
||||||
|
if (!args[0].includes('/heartbeat')) {
|
||||||
|
trackActivity();
|
||||||
|
}
|
||||||
|
return originalFetch.apply(this, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timer starten
|
||||||
|
timerInterval = setInterval(countdown, 1000);
|
||||||
|
updateTimerDisplay();
|
||||||
|
|
||||||
|
// Initial Heartbeat
|
||||||
|
extendSession();
|
||||||
|
|
||||||
|
// Preserve show_test parameter across navigation
|
||||||
|
function preserveShowTestParameter() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const showTest = urlParams.get('show_test');
|
||||||
|
|
||||||
|
if (showTest === 'true') {
|
||||||
|
// Update all internal links to include show_test parameter
|
||||||
|
document.querySelectorAll('a[href^="/"]').forEach(link => {
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
// Skip if already has parameters or is just a fragment
|
||||||
|
if (!href.includes('?') && !href.startsWith('#')) {
|
||||||
|
link.setAttribute('href', href + '?show_test=true');
|
||||||
|
} else if (href.includes('?') && !href.includes('show_test=')) {
|
||||||
|
link.setAttribute('href', href + '&show_test=true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side table sorting
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Preserve show_test parameter on page load
|
||||||
|
preserveShowTestParameter();
|
||||||
|
|
||||||
|
// Initialize all sortable tables
|
||||||
|
const sortableTables = document.querySelectorAll('.sortable-table');
|
||||||
|
|
||||||
|
sortableTables.forEach(table => {
|
||||||
|
const headers = table.querySelectorAll('th.sortable');
|
||||||
|
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
header.addEventListener('click', function() {
|
||||||
|
sortTable(table, index, header);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function sortTable(table, columnIndex, header) {
|
||||||
|
const tbody = table.querySelector('tbody');
|
||||||
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||||
|
const isNumeric = header.dataset.type === 'numeric';
|
||||||
|
const isDate = header.dataset.type === 'date';
|
||||||
|
|
||||||
|
// Determine sort direction
|
||||||
|
let direction = 'asc';
|
||||||
|
if (header.classList.contains('asc')) {
|
||||||
|
direction = 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all sort classes from headers
|
||||||
|
table.querySelectorAll('th.sortable').forEach(th => {
|
||||||
|
th.classList.remove('asc', 'desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add appropriate class to clicked header
|
||||||
|
header.classList.add(direction);
|
||||||
|
|
||||||
|
// Sort rows
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
let aValue = a.cells[columnIndex].textContent.trim();
|
||||||
|
let bValue = b.cells[columnIndex].textContent.trim();
|
||||||
|
|
||||||
|
// Handle different data types
|
||||||
|
if (isNumeric) {
|
||||||
|
aValue = parseFloat(aValue.replace(/[^0-9.-]/g, '')) || 0;
|
||||||
|
bValue = parseFloat(bValue.replace(/[^0-9.-]/g, '')) || 0;
|
||||||
|
} else if (isDate) {
|
||||||
|
// Parse German date format (DD.MM.YYYY HH:MM)
|
||||||
|
aValue = parseGermanDate(aValue);
|
||||||
|
bValue = parseGermanDate(bValue);
|
||||||
|
} else {
|
||||||
|
// Text comparison with locale support for umlauts
|
||||||
|
aValue = aValue.toLowerCase();
|
||||||
|
bValue = bValue.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aValue < bValue) return direction === 'asc' ? -1 : 1;
|
||||||
|
if (aValue > bValue) return direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reorder rows in DOM
|
||||||
|
rows.forEach(row => tbody.appendChild(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGermanDate(dateStr) {
|
||||||
|
// Handle DD.MM.YYYY HH:MM format
|
||||||
|
const parts = dateStr.match(/(\d{2})\.(\d{2})\.(\d{4})\s*(\d{2}:\d{2})?/);
|
||||||
|
if (parts) {
|
||||||
|
const [_, day, month, year, time] = parts;
|
||||||
|
const timeStr = time || '00:00';
|
||||||
|
return new Date(`${year}-${month}-${day}T${timeStr}`);
|
||||||
|
}
|
||||||
|
return new Date(0);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,464 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Batch-Lizenzen erstellen{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>🔑 Batch-Lizenzen erstellen</h2>
|
||||||
|
<a href="/customers-licenses" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>ℹ️ Batch-Generierung:</strong> Erstellen Sie mehrere Lizenzen auf einmal für einen Kunden.
|
||||||
|
Die Lizenzen werden automatisch generiert und können anschließend als CSV exportiert werden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="post" action="/batch" accept-charset="UTF-8">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="customerSelect" class="form-label">Kunde auswählen</label>
|
||||||
|
<select class="form-select" id="customerSelect" name="customer_id" required>
|
||||||
|
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
|
||||||
|
<option value="new">➕ Neuer Kunde</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6" id="customerNameDiv" style="display: none;">
|
||||||
|
<label for="customerName" class="form-label">Kundenname</label>
|
||||||
|
<input type="text" class="form-control" id="customerName" name="customer_name"
|
||||||
|
placeholder="Firma GmbH" accept-charset="UTF-8">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6" id="emailDiv" style="display: none;">
|
||||||
|
<label for="email" class="form-label">E-Mail</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email"
|
||||||
|
placeholder="kontakt@firma.de" accept-charset="UTF-8">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="quantity" class="form-label">Anzahl Lizenzen</label>
|
||||||
|
<input type="number" class="form-control" id="quantity" name="quantity"
|
||||||
|
min="1" max="100" value="10" required>
|
||||||
|
<div class="form-text">Max. 100 Lizenzen pro Batch</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||||
|
<select class="form-select" id="licenseType" name="license_type" required>
|
||||||
|
<option value="full">Vollversion</option>
|
||||||
|
<option value="test">Testversion</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label for="validFrom" class="form-label">Gültig ab</label>
|
||||||
|
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label for="duration" class="form-label">Laufzeit</label>
|
||||||
|
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label for="durationType" class="form-label">Einheit</label>
|
||||||
|
<select class="form-select" id="durationType" name="duration_type" required>
|
||||||
|
<option value="days">Tage</option>
|
||||||
|
<option value="months">Monate</option>
|
||||||
|
<option value="years" selected>Jahre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label for="validUntil" class="form-label">Gültig bis</label>
|
||||||
|
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resource Pool Allocation -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-server"></i> Ressourcen-Zuweisung pro Lizenz
|
||||||
|
<small class="text-muted float-end" id="resourceStatus"></small>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="domainCount" class="form-label">
|
||||||
|
<i class="fas fa-globe"></i> Domains
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="domainCount" name="domain_count" required>
|
||||||
|
{% for i in range(11) %}
|
||||||
|
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
|
||||||
|
| Benötigt: <span id="domainsNeeded" class="fw-bold">-</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="ipv4Count" class="form-label">
|
||||||
|
<i class="fas fa-network-wired"></i> IPv4-Adressen
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
|
||||||
|
{% for i in range(11) %}
|
||||||
|
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
|
||||||
|
| Benötigt: <span id="ipv4Needed" class="fw-bold">-</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="phoneCount" class="form-label">
|
||||||
|
<i class="fas fa-phone"></i> Telefonnummern
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="phoneCount" name="phone_count" required>
|
||||||
|
{% for i in range(11) %}
|
||||||
|
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
|
||||||
|
| Benötigt: <span id="phoneNeeded" class="fw-bold">-</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning mt-3 mb-0" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<strong>Batch-Ressourcen:</strong> Jede Lizenz erhält die angegebene Anzahl an Ressourcen.
|
||||||
|
Bei 10 Lizenzen mit je 2 Domains werden insgesamt 20 Domains benötigt.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Limit -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-laptop"></i> Gerätelimit pro Lizenz
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="deviceLimit" class="form-label">
|
||||||
|
Maximale Anzahl Geräte pro Lizenz
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||||
|
{% for i in range(1, 11) %}
|
||||||
|
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Jede generierte Lizenz kann auf maximal dieser Anzahl von Geräten gleichzeitig aktiviert werden.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Data Checkbox -->
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="isTest" name="is_test">
|
||||||
|
<label class="form-check-label" for="isTest">
|
||||||
|
<i class="fas fa-flask"></i> Als Testdaten markieren
|
||||||
|
<small class="text-muted">(wird von der Software ignoriert)</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
🔑 Batch generieren
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="previewKeys()">
|
||||||
|
👁️ Vorschau
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Vorschau Modal -->
|
||||||
|
<div class="modal fade" id="previewModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Vorschau der generierten Keys</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Es werden <strong id="previewQuantity">10</strong> Lizenzen im folgenden Format generiert:</p>
|
||||||
|
<div class="bg-light p-3 rounded font-monospace" id="previewFormat">
|
||||||
|
AF-F-YYYYMM-XXXX-YYYY-ZZZZ
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 mb-0">Die Keys werden automatisch eindeutig generiert und in der Datenbank gespeichert.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Funktion zur Berechnung des Ablaufdatums
|
||||||
|
function calculateValidUntil() {
|
||||||
|
const validFrom = document.getElementById('validFrom').value;
|
||||||
|
const duration = parseInt(document.getElementById('duration').value) || 1;
|
||||||
|
const durationType = document.getElementById('durationType').value;
|
||||||
|
|
||||||
|
if (!validFrom) return;
|
||||||
|
|
||||||
|
const startDate = new Date(validFrom);
|
||||||
|
let endDate = new Date(startDate);
|
||||||
|
|
||||||
|
switch(durationType) {
|
||||||
|
case 'days':
|
||||||
|
endDate.setDate(endDate.getDate() + duration);
|
||||||
|
break;
|
||||||
|
case 'months':
|
||||||
|
endDate.setMonth(endDate.getMonth() + duration);
|
||||||
|
break;
|
||||||
|
case 'years':
|
||||||
|
endDate.setFullYear(endDate.getFullYear() + duration);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Tag abziehen, da der Starttag mitgezählt wird
|
||||||
|
endDate.setDate(endDate.getDate() - 1);
|
||||||
|
|
||||||
|
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listener für Änderungen
|
||||||
|
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
|
||||||
|
document.getElementById('duration').addEventListener('input', calculateValidUntil);
|
||||||
|
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
|
||||||
|
|
||||||
|
// Setze heutiges Datum als Standard
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
document.getElementById('validFrom').value = today;
|
||||||
|
|
||||||
|
// Berechne initiales Ablaufdatum
|
||||||
|
calculateValidUntil();
|
||||||
|
|
||||||
|
// Initialisiere Select2 für Kundenauswahl
|
||||||
|
$('#customerSelect').select2({
|
||||||
|
theme: 'bootstrap-5',
|
||||||
|
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
|
||||||
|
allowClear: true,
|
||||||
|
ajax: {
|
||||||
|
url: '/api/customers',
|
||||||
|
dataType: 'json',
|
||||||
|
delay: 250,
|
||||||
|
data: function (params) {
|
||||||
|
return {
|
||||||
|
q: params.term,
|
||||||
|
page: params.page || 1
|
||||||
|
};
|
||||||
|
},
|
||||||
|
processResults: function (data, params) {
|
||||||
|
params.page = params.page || 1;
|
||||||
|
|
||||||
|
// "Neuer Kunde" Option immer oben anzeigen
|
||||||
|
const results = data.results || [];
|
||||||
|
if (params.page === 1) {
|
||||||
|
results.unshift({
|
||||||
|
id: 'new',
|
||||||
|
text: '➕ Neuer Kunde',
|
||||||
|
isNew: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: results,
|
||||||
|
pagination: data.pagination
|
||||||
|
};
|
||||||
|
},
|
||||||
|
cache: true
|
||||||
|
},
|
||||||
|
minimumInputLength: 0,
|
||||||
|
language: {
|
||||||
|
inputTooShort: function() { return ''; },
|
||||||
|
noResults: function() { return 'Keine Kunden gefunden'; },
|
||||||
|
searching: function() { return 'Suche...'; },
|
||||||
|
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event Handler für Kundenauswahl
|
||||||
|
$('#customerSelect').on('select2:select', function (e) {
|
||||||
|
const selectedValue = e.params.data.id;
|
||||||
|
const nameDiv = document.getElementById('customerNameDiv');
|
||||||
|
const emailDiv = document.getElementById('emailDiv');
|
||||||
|
const nameInput = document.getElementById('customerName');
|
||||||
|
const emailInput = document.getElementById('email');
|
||||||
|
|
||||||
|
if (selectedValue === 'new') {
|
||||||
|
// Zeige Eingabefelder für neuen Kunden
|
||||||
|
nameDiv.style.display = 'block';
|
||||||
|
emailDiv.style.display = 'block';
|
||||||
|
nameInput.required = true;
|
||||||
|
emailInput.required = true;
|
||||||
|
} else {
|
||||||
|
// Verstecke Eingabefelder bei bestehendem Kunden
|
||||||
|
nameDiv.style.display = 'none';
|
||||||
|
emailDiv.style.display = 'none';
|
||||||
|
nameInput.required = false;
|
||||||
|
emailInput.required = false;
|
||||||
|
nameInput.value = '';
|
||||||
|
emailInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear handler
|
||||||
|
$('#customerSelect').on('select2:clear', function (e) {
|
||||||
|
document.getElementById('customerNameDiv').style.display = 'none';
|
||||||
|
document.getElementById('emailDiv').style.display = 'none';
|
||||||
|
document.getElementById('customerName').required = false;
|
||||||
|
document.getElementById('email').required = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resource Availability Check
|
||||||
|
checkResourceAvailability();
|
||||||
|
|
||||||
|
// Event Listener für Resource Count und Quantity Änderungen
|
||||||
|
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
|
||||||
|
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
|
||||||
|
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
|
||||||
|
document.getElementById('quantity').addEventListener('input', checkResourceAvailability);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vorschau-Funktion
|
||||||
|
function previewKeys() {
|
||||||
|
const quantity = document.getElementById('quantity').value;
|
||||||
|
const type = document.getElementById('licenseType').value;
|
||||||
|
const typeChar = type === 'full' ? 'F' : 'T';
|
||||||
|
const date = new Date();
|
||||||
|
const dateStr = date.getFullYear() + ('0' + (date.getMonth() + 1)).slice(-2);
|
||||||
|
|
||||||
|
document.getElementById('previewQuantity').textContent = quantity;
|
||||||
|
document.getElementById('previewFormat').textContent = `AF-${typeChar}-${dateStr}-XXXX-YYYY-ZZZZ`;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validierung
|
||||||
|
document.getElementById('quantity').addEventListener('input', function(e) {
|
||||||
|
if (e.target.value > 100) {
|
||||||
|
e.target.value = 100;
|
||||||
|
}
|
||||||
|
if (e.target.value < 1) {
|
||||||
|
e.target.value = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit für Batch
|
||||||
|
function checkResourceAvailability() {
|
||||||
|
const quantity = parseInt(document.getElementById('quantity').value) || 1;
|
||||||
|
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
|
||||||
|
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
|
||||||
|
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
|
||||||
|
|
||||||
|
// Berechne Gesamtbedarf
|
||||||
|
const totalDomains = domainCount * quantity;
|
||||||
|
const totalIpv4 = ipv4Count * quantity;
|
||||||
|
const totalPhones = phoneCount * quantity;
|
||||||
|
|
||||||
|
// Update "Benötigt" Anzeigen
|
||||||
|
document.getElementById('domainsNeeded').textContent = totalDomains;
|
||||||
|
document.getElementById('ipv4Needed').textContent = totalIpv4;
|
||||||
|
document.getElementById('phoneNeeded').textContent = totalPhones;
|
||||||
|
|
||||||
|
// API-Call zur Verfügbarkeitsprüfung
|
||||||
|
fetch(`/api/resources/check-availability?domain=${totalDomains}&ipv4=${totalIpv4}&phone=${totalPhones}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Update der Verfügbarkeitsanzeigen
|
||||||
|
updateAvailabilityDisplay('domainsAvailable', data.domain_available, totalDomains);
|
||||||
|
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, totalIpv4);
|
||||||
|
updateAvailabilityDisplay('phoneAvailable', data.phone_available, totalPhones);
|
||||||
|
|
||||||
|
// Gesamtstatus aktualisieren
|
||||||
|
updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfsfunktion zur Anzeige der Verfügbarkeit
|
||||||
|
function updateAvailabilityDisplay(elementId, available, requested) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
element.textContent = available;
|
||||||
|
|
||||||
|
const neededElement = element.parentElement.querySelector('.fw-bold:last-child');
|
||||||
|
|
||||||
|
if (requested > 0 && available < requested) {
|
||||||
|
element.classList.remove('text-success');
|
||||||
|
element.classList.add('text-danger');
|
||||||
|
neededElement.classList.add('text-danger');
|
||||||
|
neededElement.classList.remove('text-success');
|
||||||
|
} else if (available < 50) {
|
||||||
|
element.classList.remove('text-success', 'text-danger');
|
||||||
|
element.classList.add('text-warning');
|
||||||
|
} else {
|
||||||
|
element.classList.remove('text-danger', 'text-warning');
|
||||||
|
element.classList.add('text-success');
|
||||||
|
neededElement.classList.remove('text-danger');
|
||||||
|
neededElement.classList.add('text-success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gesamtstatus der Ressourcen-Verfügbarkeit für Batch
|
||||||
|
function updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity) {
|
||||||
|
const statusElement = document.getElementById('resourceStatus');
|
||||||
|
let hasIssue = false;
|
||||||
|
let message = '';
|
||||||
|
|
||||||
|
if (totalDomains > 0 && data.domain_available < totalDomains) {
|
||||||
|
hasIssue = true;
|
||||||
|
message = `⚠️ Nicht genügend Domains (${data.domain_available}/${totalDomains})`;
|
||||||
|
} else if (totalIpv4 > 0 && data.ipv4_available < totalIpv4) {
|
||||||
|
hasIssue = true;
|
||||||
|
message = `⚠️ Nicht genügend IPv4-Adressen (${data.ipv4_available}/${totalIpv4})`;
|
||||||
|
} else if (totalPhones > 0 && data.phone_available < totalPhones) {
|
||||||
|
hasIssue = true;
|
||||||
|
message = `⚠️ Nicht genügend Telefonnummern (${data.phone_available}/${totalPhones})`;
|
||||||
|
} else {
|
||||||
|
message = `✅ Ressourcen für ${quantity} Lizenzen verfügbar`;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusElement.textContent = message;
|
||||||
|
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
|
||||||
|
|
||||||
|
// Disable submit button if not enough resources
|
||||||
|
const submitButton = document.querySelector('button[type="submit"]');
|
||||||
|
submitButton.disabled = hasIssue;
|
||||||
|
if (hasIssue) {
|
||||||
|
submitButton.classList.add('btn-secondary');
|
||||||
|
submitButton.classList.remove('btn-primary');
|
||||||
|
} else {
|
||||||
|
submitButton.classList.add('btn-primary');
|
||||||
|
submitButton.classList.remove('btn-secondary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Batch-Lizenzen generiert{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>✅ Batch-Lizenzen erfolgreich generiert</h2>
|
||||||
|
<div>
|
||||||
|
<a href="/batch" class="btn btn-primary">🔑 Weitere Batch erstellen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h5 class="alert-heading">🎉 {{ licenses|length }} Lizenzen wurden erfolgreich generiert!</h5>
|
||||||
|
<p class="mb-0">Die Lizenzen wurden in der Datenbank gespeichert und dem Kunden zugeordnet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kunden-Info -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">📋 Kundeninformationen</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p><strong>Kunde:</strong> {{ customer }}</p>
|
||||||
|
<p><strong>E-Mail:</strong> {{ email or 'Nicht angegeben' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p><strong>Gültig von:</strong> {{ valid_from }}</p>
|
||||||
|
<p><strong>Gültig bis:</strong> {{ valid_until }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export-Optionen -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">📥 Export-Optionen</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Exportieren Sie die generierten Lizenzen für den Kunden:</p>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/batch/export" class="btn btn-success">
|
||||||
|
📄 Als CSV exportieren
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-outline-primary" onclick="copyAllKeys()">
|
||||||
|
📋 Alle Keys kopieren
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="window.print()">
|
||||||
|
🖨️ Drucken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generierte Lizenzen -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">🔑 Generierte Lizenzen</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="50">#</th>
|
||||||
|
<th>Lizenzschlüssel</th>
|
||||||
|
<th width="120">Typ</th>
|
||||||
|
<th width="100">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for license in licenses %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td class="font-monospace">{{ license.key }}</td>
|
||||||
|
<td>
|
||||||
|
{% if license.type == 'full' %}
|
||||||
|
<span class="badge bg-success">Vollversion</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-info">Testversion</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="copyKey('{{ license.key }}')"
|
||||||
|
title="Kopieren">
|
||||||
|
📋
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hinweis -->
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
<strong>💡 Tipp:</strong> Die generierten Lizenzen sind sofort aktiv und können verwendet werden.
|
||||||
|
Sie finden alle Lizenzen auch in der <a href="/licenses?search={{ customer }}">Lizenzübersicht</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Textarea für Kopieren (unsichtbar) -->
|
||||||
|
<textarea id="copyArea" style="position: absolute; left: -9999px;"></textarea>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
.btn, .alert-info, .card-header h5 { display: none !important; }
|
||||||
|
.card { border: 1px solid #000 !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Einzelnen Key kopieren
|
||||||
|
function copyKey(key) {
|
||||||
|
navigator.clipboard.writeText(key).then(function() {
|
||||||
|
// Visuelles Feedback
|
||||||
|
event.target.textContent = '✓';
|
||||||
|
setTimeout(() => {
|
||||||
|
event.target.textContent = '📋';
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Keys kopieren
|
||||||
|
function copyAllKeys() {
|
||||||
|
const keys = [];
|
||||||
|
{% for license in licenses %}
|
||||||
|
keys.push('{{ license.key }}');
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
const text = keys.join('\n');
|
||||||
|
const textarea = document.getElementById('copyArea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
// Visuelles Feedback
|
||||||
|
event.target.textContent = '✓ Kopiert!';
|
||||||
|
setTimeout(() => {
|
||||||
|
event.target.textContent = '📋 Alle Keys kopieren';
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback für moderne Browser
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Gesperrte IPs{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>🔒 Gesperrte IPs</h1>
|
||||||
|
<div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">IP-Sperrverwaltung</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if blocked_ips %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover sortable-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sortable">IP-Adresse</th>
|
||||||
|
<th class="sortable" data-type="numeric">Versuche</th>
|
||||||
|
<th class="sortable" data-type="date">Erster Versuch</th>
|
||||||
|
<th class="sortable" data-type="date">Letzter Versuch</th>
|
||||||
|
<th class="sortable" data-type="date">Gesperrt bis</th>
|
||||||
|
<th class="sortable">Letzter User</th>
|
||||||
|
<th class="sortable">Letzte Meldung</th>
|
||||||
|
<th class="sortable">Status</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ip in blocked_ips %}
|
||||||
|
<tr class="{% if ip.is_active %}table-danger{% else %}table-secondary{% endif %}">
|
||||||
|
<td><code>{{ ip.ip_address }}</code></td>
|
||||||
|
<td><span class="badge bg-danger">{{ ip.attempt_count }}</span></td>
|
||||||
|
<td>{{ ip.first_attempt }}</td>
|
||||||
|
<td>{{ ip.last_attempt }}</td>
|
||||||
|
<td>{{ ip.blocked_until }}</td>
|
||||||
|
<td>{{ ip.last_username or '-' }}</td>
|
||||||
|
<td><strong>{{ ip.last_error or '-' }}</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if ip.is_active %}
|
||||||
|
<span class="badge bg-danger">GESPERRT</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">ABGELAUFEN</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
{% if ip.is_active %}
|
||||||
|
<form method="post" action="/security/unblock-ip" class="d-inline">
|
||||||
|
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
|
||||||
|
<button type="submit" class="btn btn-success"
|
||||||
|
onclick="return confirm('IP {{ ip.ip_address }} wirklich entsperren?')">
|
||||||
|
🔓 Entsperren
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/security/clear-attempts" class="d-inline ms-1">
|
||||||
|
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
|
||||||
|
<button type="submit" class="btn btn-warning"
|
||||||
|
onclick="return confirm('Alle Versuche für IP {{ ip.ip_address }} zurücksetzen?')">
|
||||||
|
🗑️ Reset
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Keine gesperrten IPs vorhanden.</strong>
|
||||||
|
Das System läuft ohne Sicherheitsvorfälle.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">ℹ️ Informationen</h5>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li>IPs werden nach <strong>{{ 5 }} fehlgeschlagenen Login-Versuchen</strong> für <strong>24 Stunden</strong> gesperrt.</li>
|
||||||
|
<li>Nach <strong>2 Versuchen</strong> wird ein CAPTCHA angezeigt.</li>
|
||||||
|
<li>Bei <strong>5 Versuchen</strong> wird eine E-Mail-Benachrichtigung gesendet (wenn aktiviert).</li>
|
||||||
|
<li>Gesperrte IPs können manuell entsperrt werden.</li>
|
||||||
|
<li>Die Fehlermeldungen werden zufällig ausgewählt für zusätzliche Verwirrung.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Neuer Kunde{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>👤 Neuer Kunde anlegen</h2>
|
||||||
|
<a href="/customers-licenses" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/customer/create" accept-charset="UTF-8">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="name" class="form-label">Kundenname <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name"
|
||||||
|
placeholder="Firmenname oder Vor- und Nachname"
|
||||||
|
accept-charset="UTF-8" required autofocus>
|
||||||
|
<div class="form-text">Der Name des Kunden oder der Firma</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="email" class="form-label">E-Mail <span class="text-danger">*</span></label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email"
|
||||||
|
placeholder="kunde@beispiel.de"
|
||||||
|
accept-charset="UTF-8" required>
|
||||||
|
<div class="form-text">Kontakt-E-Mail-Adresse des Kunden</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="isTest" name="is_test">
|
||||||
|
<label class="form-check-label" for="isTest">
|
||||||
|
<i class="fas fa-flask"></i> Als Testdaten markieren
|
||||||
|
<small class="text-muted">(Kunde wird von der Software ignoriert)</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-4" role="alert">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<strong>Hinweis:</strong> Nach dem Anlegen des Kunden können Sie direkt Lizenzen für diesen Kunden erstellen.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">💾 Kunde anlegen</button>
|
||||||
|
<a href="/customers-licenses" class="btn btn-secondary">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Kundenverwaltung{% endblock %}
|
||||||
|
|
||||||
|
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||||
|
<th>
|
||||||
|
{% if current_sort == field %}
|
||||||
|
<a href="{{ url_for('customers', sort=field, order='desc' if current_order == 'asc' else 'asc', search=search, page=1) }}"
|
||||||
|
class="server-sortable">
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('customers', sort=field, order='asc', search=search, page=1) }}"
|
||||||
|
class="server-sortable">
|
||||||
|
{% endif %}
|
||||||
|
{{ label }}
|
||||||
|
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||||
|
{% if current_sort == field %}
|
||||||
|
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||||
|
{% else %}
|
||||||
|
↕
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2>Kundenverwaltung</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suchformular -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" action="/customers" id="customerSearchForm" class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-10">
|
||||||
|
<label for="search" class="form-label">🔍 Suchen</label>
|
||||||
|
<input type="text" class="form-control" id="search" name="search"
|
||||||
|
placeholder="Kundenname oder E-Mail..."
|
||||||
|
value="{{ search }}" autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<a href="/customers" class="btn btn-outline-secondary w-100">Zurücksetzen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if search %}
|
||||||
|
<div class="mt-2">
|
||||||
|
<small class="text-muted">Suchergebnisse für: <strong>{{ search }}</strong></small>
|
||||||
|
<a href="/customers" class="btn btn-sm btn-outline-secondary ms-2">✖ Suche zurücksetzen</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{{ sortable_header('ID', 'id', sort, order) }}
|
||||||
|
{{ sortable_header('Name', 'name', sort, order) }}
|
||||||
|
{{ sortable_header('E-Mail', 'email', sort, order) }}
|
||||||
|
{{ sortable_header('Erstellt am', 'created_at', sort, order) }}
|
||||||
|
{{ sortable_header('Lizenzen (Aktiv/Gesamt)', 'licenses', sort, order) }}
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for customer in customers %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ customer[0] }}</td>
|
||||||
|
<td>
|
||||||
|
{{ customer[1] }}
|
||||||
|
{% if customer[4] %}
|
||||||
|
<span class="badge bg-secondary ms-1" title="Testdaten">🧪</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ customer[2] or '-' }}</td>
|
||||||
|
<td>{{ customer[3].strftime('%d.%m.%Y %H:%M') }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-info">{{ customer[6] }}/{{ customer[5] }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<a href="/customer/edit/{{ customer[0] }}" class="btn btn-outline-primary">✏️ Bearbeiten</a>
|
||||||
|
{% if customer[5] == 0 %}
|
||||||
|
<form method="post" action="/customer/delete/{{ customer[0] }}" style="display: inline;" onsubmit="return confirm('Kunde wirklich löschen?');">
|
||||||
|
<button type="submit" class="btn btn-outline-danger">🗑️ Löschen</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-outline-danger" disabled title="Kunde hat Lizenzen">🗑️ Löschen</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if not customers %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
{% if search %}
|
||||||
|
<p class="text-muted">Keine Kunden gefunden für: <strong>{{ search }}</strong></p>
|
||||||
|
<a href="/customers" class="btn btn-secondary">Alle Kunden anzeigen</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">Noch keine Kunden vorhanden.</p>
|
||||||
|
<a href="/create" class="btn btn-primary">Erste Lizenz erstellen</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<nav aria-label="Seitennavigation" class="mt-3">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
<!-- Erste Seite -->
|
||||||
|
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('customers', page=1, search=search, sort=sort, order=order) }}">Erste</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Vorherige Seite -->
|
||||||
|
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('customers', page=page-1, search=search, sort=sort, order=order) }}">←</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Seitenzahlen -->
|
||||||
|
{% for p in range(1, total_pages + 1) %}
|
||||||
|
{% if p >= page - 2 and p <= page + 2 %}
|
||||||
|
<li class="page-item {% if p == page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('customers', page=p, search=search, sort=sort, order=order) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Nächste Seite -->
|
||||||
|
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('customers', page=page+1, search=search, sort=sort, order=order) }}">→</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Letzte Seite -->
|
||||||
|
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('customers', page=total_pages, search=search, sort=sort, order=order) }}">Letzte</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-center text-muted">
|
||||||
|
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Kunden
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Live Search für Kunden
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchForm = document.getElementById('customerSearchForm');
|
||||||
|
const searchInput = document.getElementById('search');
|
||||||
|
|
||||||
|
// Debounce timer für Suchfeld
|
||||||
|
let searchTimeout;
|
||||||
|
|
||||||
|
// Live-Suche mit 300ms Verzögerung
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
searchForm.submit();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@ -0,0 +1,488 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Kunden & Lizenzen - AccountForger Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Kunden & Lizenzen</h2>
|
||||||
|
<div>
|
||||||
|
<a href="/create" class="btn btn-success">
|
||||||
|
<i class="bi bi-plus-circle"></i> Neue Lizenz
|
||||||
|
</a>
|
||||||
|
<a href="/batch" class="btn btn-primary">
|
||||||
|
<i class="bi bi-stack"></i> Batch-Lizenzen
|
||||||
|
</a>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-info dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-download"></i> Export
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="/export/licenses?format=excel"><i class="bi bi-file-earmark-excel"></i> Excel</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/export/licenses?format=csv"><i class="bi bi-file-earmark-csv"></i> CSV</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Kundenliste (Links) -->
|
||||||
|
<div class="col-md-4 col-lg-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-people"></i> Kunden
|
||||||
|
<span class="badge bg-secondary float-end">{{ customers|length if customers else 0 }}</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<!-- Suchfeld -->
|
||||||
|
<div class="p-3 border-bottom">
|
||||||
|
<input type="text" class="form-control mb-2" id="customerSearch"
|
||||||
|
placeholder="Kunde suchen..." autocomplete="off">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="showTestCustomers"
|
||||||
|
{% if request.args.get('show_test', 'false').lower() == 'true' %}checked{% endif %}
|
||||||
|
onchange="toggleTestCustomers()">
|
||||||
|
<label class="form-check-label" for="showTestCustomers">
|
||||||
|
<small class="text-muted">Testkunden anzeigen</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kundenliste -->
|
||||||
|
<div class="customer-list" style="max-height: 600px; overflow-y: auto;">
|
||||||
|
{% if customers %}
|
||||||
|
{% for customer in customers %}
|
||||||
|
<div class="customer-item p-3 border-bottom {% if customer[0] == selected_customer_id %}active{% endif %}"
|
||||||
|
data-customer-id="{{ customer[0] }}"
|
||||||
|
data-customer-name="{{ customer[1]|lower }}"
|
||||||
|
data-customer-email="{{ customer[2]|lower }}"
|
||||||
|
onclick="loadCustomerLicenses({{ customer[0] }})"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-1">{{ customer[1] }}</h6>
|
||||||
|
<small class="text-muted">{{ customer[2] }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span class="badge bg-primary">{{ customer[4] }}</span>
|
||||||
|
{% if customer[5] > 0 %}
|
||||||
|
<span class="badge bg-success">{{ customer[5] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if customer[6] > 0 %}
|
||||||
|
<span class="badge bg-danger">{{ customer[6] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="p-4 text-center text-muted">
|
||||||
|
<i class="bi bi-inbox" style="font-size: 3rem; opacity: 0.3;"></i>
|
||||||
|
<p class="mt-3 mb-2">Keine Kunden vorhanden</p>
|
||||||
|
<small class="d-block mb-3">Erstellen Sie eine neue Lizenz, um automatisch einen Kunden anzulegen.</small>
|
||||||
|
<a href="/create" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Neue Lizenz erstellen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lizenzdetails (Rechts) -->
|
||||||
|
<div class="col-md-8 col-lg-9">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
{% if selected_customer %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">{{ selected_customer[1] }}</h5>
|
||||||
|
<small class="text-muted">{{ selected_customer[2] }}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/customer/edit/{{ selected_customer[0] }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-pencil"></i> Bearbeiten
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-success" onclick="showNewLicenseModal({{ selected_customer[0] }})">
|
||||||
|
<i class="bi bi-plus"></i> Neue Lizenz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<h5 class="mb-0">Wählen Sie einen Kunden aus</h5>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="licenseContainer">
|
||||||
|
{% if selected_customer %}
|
||||||
|
{% if licenses %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Lizenzschlüssel</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Gültig von</th>
|
||||||
|
<th>Gültig bis</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Ressourcen</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for license in licenses %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>{{ license[1] }}</code>
|
||||||
|
<button class="btn btn-sm btn-link" onclick="copyToClipboard('{{ license[1] }}')">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {% if license[2] == 'full' %}bg-primary{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ license[2]|upper }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ license[3].strftime('%d.%m.%Y') if license[3] else '-' }}</td>
|
||||||
|
<td>{{ license[4].strftime('%d.%m.%Y') if license[4] else '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge
|
||||||
|
{% if license[6] == 'aktiv' %}bg-success
|
||||||
|
{% elif license[6] == 'läuft bald ab' %}bg-warning
|
||||||
|
{% elif license[6] == 'abgelaufen' %}bg-danger
|
||||||
|
{% else %}bg-secondary{% endif %}">
|
||||||
|
{{ license[6] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if license[7] > 0 %}🌐 {{ license[7] }}{% endif %}
|
||||||
|
{% if license[8] > 0 %}📡 {{ license[8] }}{% endif %}
|
||||||
|
{% if license[9] > 0 %}📱 {{ license[9] }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-primary" onclick="toggleLicenseStatus({{ license[0] }}, {{ license[5] }})">
|
||||||
|
<i class="bi bi-power"></i>
|
||||||
|
</button>
|
||||||
|
<a href="/license/edit/{{ license[0] }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
||||||
|
<p class="text-muted mt-3">Keine Lizenzen für diesen Kunden vorhanden</p>
|
||||||
|
<button class="btn btn-success" onclick="showNewLicenseModal({{ selected_customer[0] }})">
|
||||||
|
<i class="bi bi-plus"></i> Erste Lizenz erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-arrow-left text-muted" style="font-size: 3rem;"></i>
|
||||||
|
<p class="text-muted mt-3">Wählen Sie einen Kunden aus der Liste aus</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal für neue Lizenz -->
|
||||||
|
<div class="modal fade" id="newLicenseModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Neue Lizenz erstellen</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Möchten Sie eine neue Lizenz für <strong id="modalCustomerName"></strong> erstellen?</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-success" id="createLicenseBtn">Zur Lizenzerstellung</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.customer-item {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.customer-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-left-color: #dee2e6;
|
||||||
|
}
|
||||||
|
.customer-item.active {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border-left-color: #0d6efd;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
|
||||||
|
}
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Globale Variablen und Funktionen
|
||||||
|
let currentCustomerId = {{ selected_customer_id or 'null' }};
|
||||||
|
|
||||||
|
// Lade Lizenzen eines Kunden
|
||||||
|
function loadCustomerLicenses(customerId) {
|
||||||
|
const searchTerm = e.target.value.toLowerCase();
|
||||||
|
const customerItems = document.querySelectorAll('.customer-item');
|
||||||
|
|
||||||
|
customerItems.forEach(item => {
|
||||||
|
const name = item.dataset.customerName;
|
||||||
|
const email = item.dataset.customerEmail;
|
||||||
|
if (name.includes(searchTerm) || email.includes(searchTerm)) {
|
||||||
|
item.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
item.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Aktiven Status aktualisieren
|
||||||
|
document.querySelectorAll('.customer-item').forEach(item => {
|
||||||
|
item.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.querySelector(`[data-customer-id="${customerId}"]`).classList.add('active');
|
||||||
|
|
||||||
|
// URL aktualisieren ohne Reload (behalte show_test Parameter)
|
||||||
|
const currentUrl = new URL(window.location);
|
||||||
|
currentUrl.searchParams.set('customer_id', customerId);
|
||||||
|
window.history.pushState({}, '', currentUrl.toString());
|
||||||
|
|
||||||
|
// Lade Lizenzen via AJAX
|
||||||
|
const container = document.getElementById('licenseContainer');
|
||||||
|
const cardHeader = document.querySelector('.card-header.bg-light');
|
||||||
|
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary" role="status"></div></div>';
|
||||||
|
|
||||||
|
fetch(`/api/customer/${customerId}/licenses`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Update header with customer info
|
||||||
|
const customerItem = document.querySelector(`[data-customer-id="${customerId}"]`);
|
||||||
|
const customerName = customerItem.querySelector('h6').textContent;
|
||||||
|
const customerEmail = customerItem.querySelector('small').textContent;
|
||||||
|
|
||||||
|
cardHeader.innerHTML = `
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">${customerName}</h5>
|
||||||
|
<small class="text-muted">${customerEmail}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/customer/edit/${customerId}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-pencil"></i> Bearbeiten
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-sm btn-success" onclick="showNewLicenseModal(${customerId})">
|
||||||
|
<i class="bi bi-plus"></i> Neue Lizenz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
updateLicenseView(customerId, data.licenses);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
container.innerHTML = '<div class="alert alert-danger">Fehler beim Laden der Lizenzen</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktualisiere Lizenzansicht
|
||||||
|
function updateLicenseView(customerId, licenses) {
|
||||||
|
currentCustomerId = customerId;
|
||||||
|
const container = document.getElementById('licenseContainer');
|
||||||
|
|
||||||
|
if (licenses.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
||||||
|
<p class="text-muted mt-3">Keine Lizenzen für diesen Kunden vorhanden</p>
|
||||||
|
<button class="btn btn-success" onclick="showNewLicenseModal(${customerId})">
|
||||||
|
<i class="bi bi-plus"></i> Erste Lizenz erstellen
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Lizenzschlüssel</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Gültig von</th>
|
||||||
|
<th>Gültig bis</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Ressourcen</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
licenses.forEach(license => {
|
||||||
|
const statusClass = license.status === 'aktiv' ? 'bg-success' :
|
||||||
|
license.status === 'läuft bald ab' ? 'bg-warning' :
|
||||||
|
license.status === 'abgelaufen' ? 'bg-danger' : 'bg-secondary';
|
||||||
|
|
||||||
|
const typeClass = license.license_type === 'full' ? 'bg-primary' : 'bg-secondary';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>${license.license_key}</code>
|
||||||
|
<button class="btn btn-sm btn-link" onclick="copyToClipboard('${license.license_key}')">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge ${typeClass}">${license.license_type.toUpperCase()}</span></td>
|
||||||
|
<td>${license.valid_from || '-'}</td>
|
||||||
|
<td>${license.valid_until || '-'}</td>
|
||||||
|
<td><span class="badge ${statusClass}">${license.status}</span></td>
|
||||||
|
<td>
|
||||||
|
${license.domain_count > 0 ? '🌐 ' + license.domain_count : ''}
|
||||||
|
${license.ipv4_count > 0 ? '📡 ' + license.ipv4_count : ''}
|
||||||
|
${license.phone_count > 0 ? '📱 ' + license.phone_count : ''}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-primary" onclick="toggleLicenseStatus(${license.id}, ${license.is_active})">
|
||||||
|
<i class="bi bi-power"></i>
|
||||||
|
</button>
|
||||||
|
<a href="/license/edit/${license.id}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle Lizenzstatus
|
||||||
|
function toggleLicenseStatus(licenseId, currentStatus) {
|
||||||
|
const newStatus = !currentStatus;
|
||||||
|
|
||||||
|
fetch(`/api/license/${licenseId}/toggle`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ is_active: newStatus })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Reload current customer licenses
|
||||||
|
if (currentCustomerId) {
|
||||||
|
loadCustomerLicenses(currentCustomerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeige Modal für neue Lizenz
|
||||||
|
function showNewLicenseModal(customerId) {
|
||||||
|
const customerItem = document.querySelector(`[data-customer-id="${customerId}"]`);
|
||||||
|
if (!customerItem) {
|
||||||
|
console.error('Kunde nicht gefunden:', customerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerName = customerItem.querySelector('h6').textContent;
|
||||||
|
|
||||||
|
document.getElementById('modalCustomerName').textContent = customerName;
|
||||||
|
document.getElementById('createLicenseBtn').onclick = function() {
|
||||||
|
window.location.href = `/create?customer_id=${customerId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if bootstrap is loaded
|
||||||
|
if (typeof bootstrap === 'undefined') {
|
||||||
|
console.error('Bootstrap nicht geladen!');
|
||||||
|
// Fallback: Direkt zur Erstellung
|
||||||
|
if (confirm(`Neue Lizenz für ${customerName} erstellen?`)) {
|
||||||
|
window.location.href = `/create?customer_id=${customerId}`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalElement = document.getElementById('newLicenseModal');
|
||||||
|
const modal = new bootstrap.Modal(modalElement);
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
const button = event.currentTarget;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
// Zeige kurz Feedback
|
||||||
|
button.innerHTML = '<i class="bi bi-check"></i>';
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = '<i class="bi bi-clipboard"></i>';
|
||||||
|
}, 1000);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Fehler beim Kopieren:', err);
|
||||||
|
alert('Konnte nicht in die Zwischenablage kopieren');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle Testkunden
|
||||||
|
function toggleTestCustomers() {
|
||||||
|
const showTest = document.getElementById('showTestCustomers').checked;
|
||||||
|
const currentUrl = new URL(window.location);
|
||||||
|
currentUrl.searchParams.set('show_test', showTest);
|
||||||
|
window.location.href = currentUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.target.id === 'customerSearch') return; // Nicht bei Suche
|
||||||
|
|
||||||
|
const activeItem = document.querySelector('.customer-item.active');
|
||||||
|
if (!activeItem) return;
|
||||||
|
|
||||||
|
let targetItem = null;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
targetItem = activeItem.previousElementSibling;
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
targetItem = activeItem.nextElementSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetItem && targetItem.classList.contains('customer-item')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const customerId = parseInt(targetItem.dataset.customerId);
|
||||||
|
loadCustomerLicenses(customerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}); // Ende DOMContentLoaded
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,433 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.stat-card {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.stat-card .card-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.stat-card .card-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.stat-card .card-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
a:hover .stat-card {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.text-decoration-none:hover {
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Session pulse effect */
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { transform: scale(1); opacity: 1; }
|
||||||
|
50% { transform: scale(1.05); opacity: 0.8; }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
.pulse-effect {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar styles */
|
||||||
|
.progress-custom {
|
||||||
|
height: 8px;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-bar-custom {
|
||||||
|
background-color: #28a745;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<h1 class="mb-4">Dashboard</h1>
|
||||||
|
|
||||||
|
<!-- Statistik-Karten -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<a href="/customers-licenses" class="text-decoration-none">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="card-icon text-primary">👥</div>
|
||||||
|
<div class="card-value text-primary">{{ stats.total_customers }}</div>
|
||||||
|
<div class="card-label text-muted">Kunden Gesamt</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<a href="/customers-licenses" class="text-decoration-none">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="card-icon text-info">📋</div>
|
||||||
|
<div class="card-value text-info">{{ stats.total_licenses }}</div>
|
||||||
|
<div class="card-label text-muted">Lizenzen Gesamt</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<a href="/sessions" class="text-decoration-none">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="card-icon text-success{% if stats.active_sessions > 0 %} pulse-effect{% endif %}">🟢</div>
|
||||||
|
<div class="card-value text-success">{{ stats.active_sessions }}</div>
|
||||||
|
<div class="card-label text-muted">Aktive Sessions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lizenztypen -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Lizenztypen</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6 text-center">
|
||||||
|
<h3 class="text-success">{{ stats.full_licenses }}</h3>
|
||||||
|
<p class="text-muted">Vollversionen</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 text-center">
|
||||||
|
<h3 class="text-warning">{{ stats.test_licenses }}</h3>
|
||||||
|
<p class="text-muted">Testversionen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if stats.test_data_count > 0 or stats.test_customers_count > 0 or stats.test_resources_count > 0 %}
|
||||||
|
<div class="alert alert-info mt-3 mb-0">
|
||||||
|
<small>
|
||||||
|
<i class="fas fa-flask"></i> Testdaten:
|
||||||
|
{{ stats.test_data_count }} Lizenzen,
|
||||||
|
{{ stats.test_customers_count }} Kunden,
|
||||||
|
{{ stats.test_resources_count }} Ressourcen
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Lizenzstatus</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4 text-center">
|
||||||
|
<h3 class="text-success">{{ stats.active_licenses }}</h3>
|
||||||
|
<p class="text-muted">Aktiv</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-4 text-center">
|
||||||
|
<h3 class="text-danger">{{ stats.expired_licenses }}</h3>
|
||||||
|
<p class="text-muted">Abgelaufen</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-4 text-center">
|
||||||
|
<h3 class="text-secondary">{{ stats.inactive_licenses }}</h3>
|
||||||
|
<p class="text-muted">Deaktiviert</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup-Status und Sicherheit nebeneinander -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<a href="/backups" class="text-decoration-none">
|
||||||
|
<div class="card stat-card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">💾 Backup-Status</h5>
|
||||||
|
{% if stats.last_backup %}
|
||||||
|
{% if stats.last_backup[4] == 'success' %}
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="text-success me-2">✅</span>
|
||||||
|
<small>{{ stats.last_backup[0].strftime('%d.%m.%Y %H:%M') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="progress-custom">
|
||||||
|
<div class="progress-bar-custom" style="width: 100%;"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted mt-1 d-block">
|
||||||
|
{{ (stats.last_backup[1] / 1024 / 1024)|round(1) }} MB • {{ stats.last_backup[2]|round(0)|int }}s
|
||||||
|
</small>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="text-danger me-2">❌</span>
|
||||||
|
<small>Backup fehlgeschlagen</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sicherheitsstatus -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">🔒 Sicherheitsstatus</h5>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span>Sicherheitslevel:</span>
|
||||||
|
<span class="badge bg-{{ stats.security_level }} fs-6">{{ stats.security_level_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-6">
|
||||||
|
<h4 class="text-danger mb-0">{{ stats.blocked_ips_count }}</h4>
|
||||||
|
<small class="text-muted">Gesperrte IPs</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<h4 class="text-warning mb-0">{{ stats.failed_attempts_today }}</h4>
|
||||||
|
<small class="text-muted">Fehlversuche heute</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/security/blocked-ips" class="btn btn-sm btn-outline-danger mt-3">IP-Verwaltung →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sicherheitsereignisse -->
|
||||||
|
{% if stats.recent_security_events %}
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-dark text-white">
|
||||||
|
<h6 class="mb-0">🚨 Letzte Sicherheitsereignisse</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0 sortable-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sortable" data-type="date">Zeit</th>
|
||||||
|
<th class="sortable">IP-Adresse</th>
|
||||||
|
<th class="sortable" data-type="numeric">Versuche</th>
|
||||||
|
<th class="sortable">Fehlermeldung</th>
|
||||||
|
<th class="sortable">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event in stats.recent_security_events %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ event.last_attempt }}</td>
|
||||||
|
<td><code>{{ event.ip_address }}</code></td>
|
||||||
|
<td><span class="badge bg-secondary">{{ event.attempt_count }}</span></td>
|
||||||
|
<td><strong class="text-danger">{{ event.error_message }}</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if event.blocked_until %}
|
||||||
|
<span class="badge bg-danger">Gesperrt bis {{ event.blocked_until }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning">Aktiv</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Resource Pool Status -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-server"></i> Resource Pool Status
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
{% if resource_stats %}
|
||||||
|
{% for type, data in resource_stats.items() %}
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="me-3">
|
||||||
|
<a href="/resources?type={{ type }}{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
|
||||||
|
class="text-decoration-none">
|
||||||
|
<i class="fas fa-{{ 'globe' if type == 'domain' else ('network-wired' if type == 'ipv4' else 'phone') }} fa-2x text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-1">
|
||||||
|
<a href="/resources?type={{ type }}{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
|
||||||
|
class="text-decoration-none text-dark">{{ type|upper }}</a>
|
||||||
|
</h6>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span>
|
||||||
|
<strong class="text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">{{ data.available }}</strong> / {{ data.total }} verfügbar
|
||||||
|
</span>
|
||||||
|
<span class="badge bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">
|
||||||
|
{{ data.available_percent }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress mt-1" style="height: 8px;">
|
||||||
|
<div class="progress-bar bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"
|
||||||
|
style="width: {{ data.available_percent }}%"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
title="{{ data.available }} von {{ data.total }} verfügbar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mt-1">
|
||||||
|
<small class="text-muted">
|
||||||
|
<a href="/resources?type={{ type }}&status=allocated{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
|
||||||
|
class="text-decoration-none text-muted">
|
||||||
|
{{ data.allocated }} zugeteilt
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
{% if data.quarantine > 0 %}
|
||||||
|
<small>
|
||||||
|
<a href="/resources?type={{ type }}&status=quarantine{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
|
||||||
|
class="text-decoration-none text-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> {{ data.quarantine }} in Quarantäne
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12 text-center text-muted">
|
||||||
|
<i class="fas fa-inbox fa-3x mb-3"></i>
|
||||||
|
<p>Keine Ressourcen im Pool vorhanden.</p>
|
||||||
|
<a href="/resources/add{{ '?show_test=true' if request.args.get('show_test') == 'true' else '' }}" class="btn btn-primary">
|
||||||
|
<i class="fas fa-plus"></i> Ressourcen hinzufügen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if resource_warning %}
|
||||||
|
<div class="alert alert-danger mt-3 mb-0 d-flex justify-content-between align-items-center" role="alert">
|
||||||
|
<div>
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<strong>Kritisch:</strong> {{ resource_warning }}
|
||||||
|
</div>
|
||||||
|
<a href="/resources/add{{ '?show_test=true' if request.args.get('show_test') == 'true' else '' }}"
|
||||||
|
class="btn btn-sm btn-danger">
|
||||||
|
<i class="bi bi-plus"></i> Ressourcen auffüllen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Bald ablaufende Lizenzen -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-warning text-dark">
|
||||||
|
<h5 class="mb-0">⏰ Bald ablaufende Lizenzen</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if stats.expiring_licenses %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm sortable-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sortable">Kunde</th>
|
||||||
|
<th class="sortable">Lizenz</th>
|
||||||
|
<th class="sortable" data-type="numeric">Tage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for license in stats.expiring_licenses %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ license[2] }}</td>
|
||||||
|
<td><small><code>{{ license[1][:8] }}...</code></small></td>
|
||||||
|
<td><span class="badge bg-warning">{{ license[4] }} Tage</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Keine Lizenzen laufen in den nächsten 30 Tagen ab.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Letzte Lizenzen -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-info text-white">
|
||||||
|
<h5 class="mb-0">🆕 Zuletzt erstellte Lizenzen</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if stats.recent_licenses %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm sortable-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sortable">Kunde</th>
|
||||||
|
<th class="sortable">Lizenz</th>
|
||||||
|
<th class="sortable">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for license in stats.recent_licenses %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ license[2] }}</td>
|
||||||
|
<td><small><code>{{ license[1][:8] }}...</code></small></td>
|
||||||
|
<td>
|
||||||
|
{% if license[4] == 'deaktiviert' %}
|
||||||
|
<span class="status-deaktiviert">🚫 Deaktiviert</span>
|
||||||
|
{% elif license[4] == 'abgelaufen' %}
|
||||||
|
<span class="status-abgelaufen">⚠️ Abgelaufen</span>
|
||||||
|
{% elif license[4] == 'läuft bald ab' %}
|
||||||
|
<span class="status-ablaufend">⏰ Läuft bald ab</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-aktiv">✅ Aktiv</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Noch keine Lizenzen erstellt.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Kunde bearbeiten{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Kunde bearbeiten</h2>
|
||||||
|
<div>
|
||||||
|
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">👥 Zurück zur Übersicht</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/customer/edit/{{ customer[0] }}" accept-charset="UTF-8">
|
||||||
|
{% if request.args.get('show_test') == 'true' %}
|
||||||
|
<input type="hidden" name="show_test" value="true">
|
||||||
|
{% endif %}
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="name" class="form-label">Kundenname</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" value="{{ customer[1] }}" accept-charset="UTF-8" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="email" class="form-label">E-Mail</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" value="{{ customer[2] or '' }}" accept-charset="UTF-8">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label text-muted">Erstellt am</label>
|
||||||
|
<p class="form-control-plaintext">{{ customer[3].strftime('%d.%m.%Y %H:%M') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="isTest" name="is_test" {% if customer[4] %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="isTest">
|
||||||
|
<i class="fas fa-flask"></i> Als Testdaten markieren
|
||||||
|
<small class="text-muted">(Kunde und seine Lizenzen werden von der Software ignoriert)</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
|
||||||
|
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Lizenzen des Kunden</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if licenses %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Lizenzschlüssel</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Gültig von</th>
|
||||||
|
<th>Gültig bis</th>
|
||||||
|
<th>Aktiv</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for license in licenses %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ license[1] }}</code></td>
|
||||||
|
<td>
|
||||||
|
{% if license[2] == 'full' %}
|
||||||
|
<span class="badge bg-success">Vollversion</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning">Testversion</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ license[3].strftime('%d.%m.%Y') }}</td>
|
||||||
|
<td>{{ license[4].strftime('%d.%m.%Y') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if license[5] %}
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-danger">✗</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/license/edit/{{ license[0] }}" class="btn btn-outline-primary btn-sm">Bearbeiten</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Dieser Kunde hat noch keine Lizenzen.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Lizenz bearbeiten{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Lizenz bearbeiten</h2>
|
||||||
|
<div>
|
||||||
|
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">📋 Zurück zur Übersicht</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/license/edit/{{ license[0] }}" accept-charset="UTF-8">
|
||||||
|
{% if request.args.get('show_test') == 'true' %}
|
||||||
|
<input type="hidden" name="show_test" value="true">
|
||||||
|
{% endif %}
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Kunde</label>
|
||||||
|
<input type="text" class="form-control" value="{{ license[2] }}" disabled>
|
||||||
|
<small class="text-muted">Kunde kann nicht geändert werden</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">E-Mail</label>
|
||||||
|
<input type="email" class="form-control" value="{{ license[3] or '-' }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
|
||||||
|
<input type="text" class="form-control" id="licenseKey" name="license_key" value="{{ license[1] }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||||
|
<select class="form-select" id="licenseType" name="license_type" required>
|
||||||
|
<option value="full" {% if license[4] == 'full' %}selected{% endif %}>Vollversion</option>
|
||||||
|
<option value="test" {% if license[4] == 'test' %}selected{% endif %}>Testversion</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="validFrom" class="form-label">Gültig von</label>
|
||||||
|
<input type="date" class="form-control" id="validFrom" name="valid_from" value="{{ license[5].strftime('%Y-%m-%d') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="validUntil" class="form-label">Gültig bis</label>
|
||||||
|
<input type="date" class="form-control" id="validUntil" name="valid_until" value="{{ license[6].strftime('%Y-%m-%d') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="isActive" name="is_active" {% if license[7] %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="isActive">
|
||||||
|
Lizenz ist aktiv
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="deviceLimit" class="form-label">Gerätelimit</label>
|
||||||
|
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||||
|
{% for i in range(1, 11) %}
|
||||||
|
<option value="{{ i }}" {% if license[10] == i %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Maximale Anzahl gleichzeitig aktiver Geräte</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="isTest" name="is_test" {% if license[9] %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="isTest">
|
||||||
|
<i class="fas fa-flask"></i> Als Testdaten markieren
|
||||||
|
<small class="text-muted">(wird von der Software ignoriert)</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
|
||||||
|
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,533 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Admin Panel{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Neue Lizenz erstellen</h2>
|
||||||
|
<a href="/customers-licenses" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/create" accept-charset="UTF-8">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="customerSelect" class="form-label">Kunde auswählen</label>
|
||||||
|
<select class="form-select" id="customerSelect" name="customer_id" required>
|
||||||
|
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
|
||||||
|
<option value="new">➕ Neuer Kunde</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6" id="customerNameDiv" style="display: none;">
|
||||||
|
<label for="customerName" class="form-label">Kundenname</label>
|
||||||
|
<input type="text" class="form-control" id="customerName" name="customer_name" accept-charset="UTF-8">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6" id="emailDiv" style="display: none;">
|
||||||
|
<label for="email" class="form-label">E-Mail</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" accept-charset="UTF-8">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="licenseKey" name="license_key"
|
||||||
|
placeholder="AF-F-YYYYMM-XXXX-YYYY-ZZZZ" required
|
||||||
|
pattern="AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}"
|
||||||
|
title="Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ">
|
||||||
|
<button type="button" class="btn btn-outline-primary" onclick="generateLicenseKey()">
|
||||||
|
🔑 Generieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||||
|
<select class="form-select" id="licenseType" name="license_type" required>
|
||||||
|
<option value="full">Vollversion</option>
|
||||||
|
<option value="test">Testversion</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label for="validFrom" class="form-label">Kaufdatum</label>
|
||||||
|
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label for="duration" class="form-label">Laufzeit</label>
|
||||||
|
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label for="durationType" class="form-label">Einheit</label>
|
||||||
|
<select class="form-select" id="durationType" name="duration_type" required>
|
||||||
|
<option value="days">Tage</option>
|
||||||
|
<option value="months">Monate</option>
|
||||||
|
<option value="years" selected>Jahre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label for="validUntil" class="form-label">Ablaufdatum</label>
|
||||||
|
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resource Pool Allocation -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-server"></i> Ressourcen-Zuweisung
|
||||||
|
<small class="text-muted float-end" id="resourceStatus"></small>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="domainCount" class="form-label">
|
||||||
|
<i class="fas fa-globe"></i> Domains
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="domainCount" name="domain_count" required>
|
||||||
|
{% for i in range(11) %}
|
||||||
|
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="ipv4Count" class="form-label">
|
||||||
|
<i class="fas fa-network-wired"></i> IPv4-Adressen
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
|
||||||
|
{% for i in range(11) %}
|
||||||
|
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="phoneCount" class="form-label">
|
||||||
|
<i class="fas fa-phone"></i> Telefonnummern
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="phoneCount" name="phone_count" required>
|
||||||
|
{% for i in range(11) %}
|
||||||
|
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info mt-3 mb-0" role="alert">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
Die Ressourcen werden bei der Lizenzerstellung automatisch aus dem Pool zugewiesen.
|
||||||
|
Wählen Sie 0, wenn für diesen Typ keine Ressourcen benötigt werden.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Limit -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-laptop"></i> Gerätelimit
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="deviceLimit" class="form-label">
|
||||||
|
Maximale Anzahl Geräte
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||||
|
{% for i in range(1, 11) %}
|
||||||
|
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Data Checkbox -->
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="isTest" name="is_test">
|
||||||
|
<label class="form-check-label" for="isTest">
|
||||||
|
<i class="fas fa-flask"></i> Als Testdaten markieren
|
||||||
|
<small class="text-muted">(wird von der Software ignoriert)</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">➕ Lizenz erstellen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// License Key Generator
|
||||||
|
function generateLicenseKey() {
|
||||||
|
const licenseType = document.getElementById('licenseType').value;
|
||||||
|
|
||||||
|
// Zeige Ladeindikator
|
||||||
|
const button = event.target;
|
||||||
|
const originalText = button.innerHTML;
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = '⏳ Generiere...';
|
||||||
|
|
||||||
|
// API-Call
|
||||||
|
fetch('/api/generate-license-key', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({type: licenseType})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('licenseKey').value = data.key;
|
||||||
|
// Visuelles Feedback
|
||||||
|
document.getElementById('licenseKey').classList.add('border-success');
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('licenseKey').classList.remove('border-success');
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
alert('Fehler bei der Key-Generierung: ' + (data.error || 'Unbekannter Fehler'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Fehler:', error);
|
||||||
|
alert('Netzwerkfehler bei der Key-Generierung');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// Button zurücksetzen
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listener für Lizenztyp-Änderung
|
||||||
|
document.getElementById('licenseType').addEventListener('change', function() {
|
||||||
|
const keyField = document.getElementById('licenseKey');
|
||||||
|
if (keyField.value && keyField.value.startsWith('AF-')) {
|
||||||
|
// Prüfe ob der Key zum neuen Typ passt
|
||||||
|
const currentType = this.value;
|
||||||
|
const keyType = keyField.value.charAt(3); // Position des F/T im Key (AF-F-...)
|
||||||
|
|
||||||
|
if ((currentType === 'full' && keyType === 'T') ||
|
||||||
|
(currentType === 'test' && keyType === 'F')) {
|
||||||
|
if (confirm('Der aktuelle Key passt nicht zum gewählten Lizenztyp. Neuen Key generieren?')) {
|
||||||
|
generateLicenseKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-Uppercase für License Key Input
|
||||||
|
document.getElementById('licenseKey').addEventListener('input', function(e) {
|
||||||
|
e.target.value = e.target.value.toUpperCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Funktion zur Berechnung des Ablaufdatums
|
||||||
|
function calculateValidUntil() {
|
||||||
|
const validFrom = document.getElementById('validFrom').value;
|
||||||
|
const duration = parseInt(document.getElementById('duration').value) || 1;
|
||||||
|
const durationType = document.getElementById('durationType').value;
|
||||||
|
|
||||||
|
if (!validFrom) return;
|
||||||
|
|
||||||
|
const startDate = new Date(validFrom);
|
||||||
|
let endDate = new Date(startDate);
|
||||||
|
|
||||||
|
switch(durationType) {
|
||||||
|
case 'days':
|
||||||
|
endDate.setDate(endDate.getDate() + duration);
|
||||||
|
break;
|
||||||
|
case 'months':
|
||||||
|
endDate.setMonth(endDate.getMonth() + duration);
|
||||||
|
break;
|
||||||
|
case 'years':
|
||||||
|
endDate.setFullYear(endDate.getFullYear() + duration);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Tag abziehen, da der Starttag mitgezählt wird
|
||||||
|
endDate.setDate(endDate.getDate() - 1);
|
||||||
|
|
||||||
|
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listener für Änderungen
|
||||||
|
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
|
||||||
|
document.getElementById('duration').addEventListener('input', calculateValidUntil);
|
||||||
|
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
|
||||||
|
|
||||||
|
// Setze heutiges Datum als Standard für valid_from
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
document.getElementById('validFrom').value = today;
|
||||||
|
|
||||||
|
// Berechne initiales Ablaufdatum
|
||||||
|
calculateValidUntil();
|
||||||
|
|
||||||
|
// Initialisiere Select2 für Kundenauswahl
|
||||||
|
$('#customerSelect').select2({
|
||||||
|
theme: 'bootstrap-5',
|
||||||
|
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
|
||||||
|
allowClear: true,
|
||||||
|
ajax: {
|
||||||
|
url: '/api/customers',
|
||||||
|
dataType: 'json',
|
||||||
|
delay: 250,
|
||||||
|
data: function (params) {
|
||||||
|
return {
|
||||||
|
q: params.term,
|
||||||
|
page: params.page || 1
|
||||||
|
};
|
||||||
|
},
|
||||||
|
processResults: function (data, params) {
|
||||||
|
params.page = params.page || 1;
|
||||||
|
|
||||||
|
// "Neuer Kunde" Option immer oben anzeigen
|
||||||
|
const results = data.results || [];
|
||||||
|
if (params.page === 1) {
|
||||||
|
results.unshift({
|
||||||
|
id: 'new',
|
||||||
|
text: '➕ Neuer Kunde',
|
||||||
|
isNew: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: results,
|
||||||
|
pagination: data.pagination
|
||||||
|
};
|
||||||
|
},
|
||||||
|
cache: true
|
||||||
|
},
|
||||||
|
minimumInputLength: 0,
|
||||||
|
language: {
|
||||||
|
inputTooShort: function() { return ''; },
|
||||||
|
noResults: function() { return 'Keine Kunden gefunden'; },
|
||||||
|
searching: function() { return 'Suche...'; },
|
||||||
|
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vorausgewählten Kunden setzen (falls von kombinierter Ansicht kommend)
|
||||||
|
{% if preselected_customer_id %}
|
||||||
|
// Lade Kundendetails und setze Auswahl
|
||||||
|
fetch('/api/customers?id={{ preselected_customer_id }}')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.results && data.results.length > 0) {
|
||||||
|
const customer = data.results[0];
|
||||||
|
// Erstelle Option und setze sie als ausgewählt
|
||||||
|
const option = new Option(customer.text, customer.id, true, true);
|
||||||
|
$('#customerSelect').append(option).trigger('change');
|
||||||
|
// Verstecke die Eingabefelder
|
||||||
|
document.getElementById('customerNameDiv').style.display = 'none';
|
||||||
|
document.getElementById('emailDiv').style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// Event Handler für Kundenauswahl
|
||||||
|
$('#customerSelect').on('select2:select', function (e) {
|
||||||
|
const selectedValue = e.params.data.id;
|
||||||
|
const nameDiv = document.getElementById('customerNameDiv');
|
||||||
|
const emailDiv = document.getElementById('emailDiv');
|
||||||
|
const nameInput = document.getElementById('customerName');
|
||||||
|
const emailInput = document.getElementById('email');
|
||||||
|
|
||||||
|
if (selectedValue === 'new') {
|
||||||
|
// Zeige Eingabefelder für neuen Kunden
|
||||||
|
nameDiv.style.display = 'block';
|
||||||
|
emailDiv.style.display = 'block';
|
||||||
|
nameInput.required = true;
|
||||||
|
emailInput.required = true;
|
||||||
|
} else {
|
||||||
|
// Verstecke Eingabefelder bei bestehendem Kunden
|
||||||
|
nameDiv.style.display = 'none';
|
||||||
|
emailDiv.style.display = 'none';
|
||||||
|
nameInput.required = false;
|
||||||
|
emailInput.required = false;
|
||||||
|
nameInput.value = '';
|
||||||
|
emailInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear handler
|
||||||
|
$('#customerSelect').on('select2:clear', function (e) {
|
||||||
|
document.getElementById('customerNameDiv').style.display = 'none';
|
||||||
|
document.getElementById('emailDiv').style.display = 'none';
|
||||||
|
document.getElementById('customerName').required = false;
|
||||||
|
document.getElementById('email').required = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resource Availability Check
|
||||||
|
checkResourceAvailability();
|
||||||
|
|
||||||
|
// Event Listener für Resource Count Änderungen
|
||||||
|
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
|
||||||
|
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
|
||||||
|
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit
|
||||||
|
function checkResourceAvailability() {
|
||||||
|
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
|
||||||
|
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
|
||||||
|
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
|
||||||
|
|
||||||
|
// API-Call zur Verfügbarkeitsprüfung
|
||||||
|
fetch(`/api/resources/check-availability?domain=${domainCount}&ipv4=${ipv4Count}&phone=${phoneCount}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Update der Verfügbarkeitsanzeigen
|
||||||
|
updateAvailabilityDisplay('domainsAvailable', data.domain_available, domainCount);
|
||||||
|
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, ipv4Count);
|
||||||
|
updateAvailabilityDisplay('phoneAvailable', data.phone_available, phoneCount);
|
||||||
|
|
||||||
|
// Gesamtstatus aktualisieren
|
||||||
|
updateResourceStatus(data, domainCount, ipv4Count, phoneCount);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hilfsfunktion zur Anzeige der Verfügbarkeit
|
||||||
|
function updateAvailabilityDisplay(elementId, available, requested) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
const container = element.parentElement;
|
||||||
|
|
||||||
|
// Verfügbarkeit mit Prozentanzeige
|
||||||
|
const percent = Math.round((available / (available + requested + 50)) * 100);
|
||||||
|
let statusHtml = `<strong>${available}</strong>`;
|
||||||
|
|
||||||
|
if (requested > 0 && available < requested) {
|
||||||
|
element.classList.remove('text-success', 'text-warning');
|
||||||
|
element.classList.add('text-danger');
|
||||||
|
statusHtml += ` <i class="bi bi-exclamation-triangle"></i>`;
|
||||||
|
|
||||||
|
// Füge Warnung hinzu
|
||||||
|
if (!container.querySelector('.availability-warning')) {
|
||||||
|
const warning = document.createElement('div');
|
||||||
|
warning.className = 'availability-warning text-danger small mt-1';
|
||||||
|
warning.innerHTML = `<i class="bi bi-x-circle"></i> Nicht genügend Ressourcen verfügbar!`;
|
||||||
|
container.appendChild(warning);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Entferne Warnung wenn vorhanden
|
||||||
|
const warning = container.querySelector('.availability-warning');
|
||||||
|
if (warning) warning.remove();
|
||||||
|
|
||||||
|
if (available < 20) {
|
||||||
|
element.classList.remove('text-success');
|
||||||
|
element.classList.add('text-danger');
|
||||||
|
statusHtml += ` <span class="badge bg-danger">Kritisch</span>`;
|
||||||
|
} else if (available < 50) {
|
||||||
|
element.classList.remove('text-success', 'text-danger');
|
||||||
|
element.classList.add('text-warning');
|
||||||
|
statusHtml += ` <span class="badge bg-warning text-dark">Niedrig</span>`;
|
||||||
|
} else {
|
||||||
|
element.classList.remove('text-danger', 'text-warning');
|
||||||
|
element.classList.add('text-success');
|
||||||
|
statusHtml += ` <span class="badge bg-success">OK</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
element.innerHTML = statusHtml;
|
||||||
|
|
||||||
|
// Zeige Fortschrittsbalken
|
||||||
|
updateResourceProgressBar(elementId.replace('Available', ''), available, requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fortschrittsbalken für Ressourcen
|
||||||
|
function updateResourceProgressBar(resourceType, available, requested) {
|
||||||
|
const progressId = `${resourceType}Progress`;
|
||||||
|
let progressBar = document.getElementById(progressId);
|
||||||
|
|
||||||
|
// Erstelle Fortschrittsbalken wenn nicht vorhanden
|
||||||
|
if (!progressBar) {
|
||||||
|
const container = document.querySelector(`#${resourceType}Available`).parentElement.parentElement;
|
||||||
|
const progressDiv = document.createElement('div');
|
||||||
|
progressDiv.className = 'mt-2';
|
||||||
|
progressDiv.innerHTML = `
|
||||||
|
<div class="progress" style="height: 20px;" id="${progressId}">
|
||||||
|
<div class="progress-bar bg-success" role="progressbar" style="width: 0%">
|
||||||
|
<span class="progress-text"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(progressDiv);
|
||||||
|
progressBar = document.getElementById(progressId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = available + requested;
|
||||||
|
const availablePercent = total > 0 ? (available / total) * 100 : 100;
|
||||||
|
const bar = progressBar.querySelector('.progress-bar');
|
||||||
|
const text = progressBar.querySelector('.progress-text');
|
||||||
|
|
||||||
|
// Setze Farbe basierend auf Verfügbarkeit
|
||||||
|
bar.classList.remove('bg-success', 'bg-warning', 'bg-danger');
|
||||||
|
if (requested > 0 && available < requested) {
|
||||||
|
bar.classList.add('bg-danger');
|
||||||
|
} else if (availablePercent < 30) {
|
||||||
|
bar.classList.add('bg-warning');
|
||||||
|
} else {
|
||||||
|
bar.classList.add('bg-success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animiere Fortschrittsbalken
|
||||||
|
bar.style.width = `${availablePercent}%`;
|
||||||
|
text.textContent = requested > 0 ? `${available} von ${total}` : `${available} verfügbar`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gesamtstatus der Ressourcen-Verfügbarkeit
|
||||||
|
function updateResourceStatus(data, domainCount, ipv4Count, phoneCount) {
|
||||||
|
const statusElement = document.getElementById('resourceStatus');
|
||||||
|
let hasIssue = false;
|
||||||
|
let message = '';
|
||||||
|
|
||||||
|
if (domainCount > 0 && data.domain_available < domainCount) {
|
||||||
|
hasIssue = true;
|
||||||
|
message = '⚠️ Nicht genügend Domains';
|
||||||
|
} else if (ipv4Count > 0 && data.ipv4_available < ipv4Count) {
|
||||||
|
hasIssue = true;
|
||||||
|
message = '⚠️ Nicht genügend IPv4-Adressen';
|
||||||
|
} else if (phoneCount > 0 && data.phone_available < phoneCount) {
|
||||||
|
hasIssue = true;
|
||||||
|
message = '⚠️ Nicht genügend Telefonnummern';
|
||||||
|
} else {
|
||||||
|
message = '✅ Alle Ressourcen verfügbar';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusElement.textContent = message;
|
||||||
|
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,375 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Lizenzübersicht{% endblock %}
|
||||||
|
|
||||||
|
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||||
|
<th>
|
||||||
|
{% if current_sort == field %}
|
||||||
|
<a href="{{ url_for('licenses', sort=field, order='desc' if current_order == 'asc' else 'asc', search=search, type=filter_type, status=filter_status, page=1) }}"
|
||||||
|
class="server-sortable">
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('licenses', sort=field, order='asc', search=search, type=filter_type, status=filter_status, page=1) }}"
|
||||||
|
class="server-sortable">
|
||||||
|
{% endif %}
|
||||||
|
{{ label }}
|
||||||
|
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||||
|
{% if current_sort == field %}
|
||||||
|
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||||
|
{% else %}
|
||||||
|
↕
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2>Lizenzübersicht</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Such- und Filterformular -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" action="/licenses" id="filterForm">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="search" class="form-label">🔍 Suchen</label>
|
||||||
|
<input type="text" class="form-control" id="search" name="search"
|
||||||
|
placeholder="Lizenzschlüssel, Kunde, E-Mail..."
|
||||||
|
value="{{ search }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label for="type" class="form-label">Typ</label>
|
||||||
|
<select class="form-select" id="type" name="type">
|
||||||
|
<option value="">Alle Typen</option>
|
||||||
|
<option value="full" {% if filter_type == 'full' %}selected{% endif %}>Vollversion</option>
|
||||||
|
<option value="test" {% if filter_type == 'test' %}selected{% endif %}>Testversion</option>
|
||||||
|
<option value="test_data" {% if filter_type == 'test_data' %}selected{% endif %}>🧪 Testdaten</option>
|
||||||
|
<option value="live_data" {% if filter_type == 'live_data' %}selected{% endif %}>🚀 Live-Daten</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="status" class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="status" name="status">
|
||||||
|
<option value="">Alle Status</option>
|
||||||
|
<option value="active" {% if filter_status == 'active' %}selected{% endif %}>✅ Aktiv</option>
|
||||||
|
<option value="expiring" {% if filter_status == 'expiring' %}selected{% endif %}>⏰ Läuft bald ab</option>
|
||||||
|
<option value="expired" {% if filter_status == 'expired' %}selected{% endif %}>⚠️ Abgelaufen</option>
|
||||||
|
<option value="inactive" {% if filter_status == 'inactive' %}selected{% endif %}>❌ Deaktiviert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<a href="/licenses" class="btn btn-outline-secondary">Zurücksetzen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if search or filter_type or filter_status %}
|
||||||
|
<div class="mt-2">
|
||||||
|
<small class="text-muted">
|
||||||
|
Gefiltert: {{ total }} Ergebnisse
|
||||||
|
{% if search %} | Suche: <strong>{{ search }}</strong>{% endif %}
|
||||||
|
{% if filter_type %} | Typ: <strong>{{ 'Vollversion' if filter_type == 'full' else 'Testversion' }}</strong>{% endif %}
|
||||||
|
{% if filter_status %} | Status: <strong>{{ filter_status }}</strong>{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table table-hover table-sticky mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="checkbox-cell">
|
||||||
|
<input type="checkbox" class="form-check-input form-check-input-custom" id="selectAll">
|
||||||
|
</th>
|
||||||
|
{{ sortable_header('ID', 'id', sort, order) }}
|
||||||
|
{{ sortable_header('Lizenzschlüssel', 'license_key', sort, order) }}
|
||||||
|
{{ sortable_header('Kunde', 'customer', sort, order) }}
|
||||||
|
{{ sortable_header('E-Mail', 'email', sort, order) }}
|
||||||
|
{{ sortable_header('Typ', 'type', sort, order) }}
|
||||||
|
{{ sortable_header('Gültig von', 'valid_from', sort, order) }}
|
||||||
|
{{ sortable_header('Gültig bis', 'valid_until', sort, order) }}
|
||||||
|
{{ sortable_header('Status', 'status', sort, order) }}
|
||||||
|
{{ sortable_header('Aktiv', 'active', sort, order) }}
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for license in licenses %}
|
||||||
|
<tr>
|
||||||
|
<td class="checkbox-cell">
|
||||||
|
<input type="checkbox" class="form-check-input form-check-input-custom license-checkbox" value="{{ license[0] }}">
|
||||||
|
</td>
|
||||||
|
<td>{{ license[0] }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<code class="me-2">{{ license[1] }}</code>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary btn-copy" onclick="copyToClipboard('{{ license[1] }}', this)" title="Kopieren">
|
||||||
|
📋
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ license[2] }}
|
||||||
|
{% if license[8] %}
|
||||||
|
<span class="badge bg-secondary ms-1" title="Testdaten">🧪</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ license[3] or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if license[4] == 'full' %}
|
||||||
|
<span class="badge bg-success">Vollversion</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning">Testversion</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ license[5].strftime('%d.%m.%Y') }}</td>
|
||||||
|
<td>{{ license[6].strftime('%d.%m.%Y') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if license[9] == 'abgelaufen' %}
|
||||||
|
<span class="status-abgelaufen">⚠️ Abgelaufen</span>
|
||||||
|
{% elif license[9] == 'läuft bald ab' %}
|
||||||
|
<span class="status-ablaufend">⏰ Läuft bald ab</span>
|
||||||
|
{% elif license[9] == 'deaktiviert' %}
|
||||||
|
<span class="status-deaktiviert">❌ Deaktiviert</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-aktiv">✅ Aktiv</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="form-check form-switch form-switch-custom">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
id="active_{{ license[0] }}"
|
||||||
|
{{ 'checked' if license[7] else '' }}
|
||||||
|
onchange="toggleLicenseStatus({{ license[0] }}, this.checked)">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<a href="/license/edit/{{ license[0] }}" class="btn btn-outline-primary">✏️ Bearbeiten</a>
|
||||||
|
<form method="post" action="/license/delete/{{ license[0] }}" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');">
|
||||||
|
<button type="submit" class="btn btn-outline-danger">🗑️ Löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if not licenses %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
{% if search %}
|
||||||
|
<p class="text-muted">Keine Lizenzen gefunden für: <strong>{{ search }}</strong></p>
|
||||||
|
<a href="/licenses" class="btn btn-secondary">Alle Lizenzen anzeigen</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">Noch keine Lizenzen vorhanden.</p>
|
||||||
|
<a href="/create" class="btn btn-primary">Erste Lizenz erstellen</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<nav aria-label="Seitennavigation" class="mt-3">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
<!-- Erste Seite -->
|
||||||
|
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('licenses', page=1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">Erste</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Vorherige Seite -->
|
||||||
|
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('licenses', page=page-1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">←</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Seitenzahlen -->
|
||||||
|
{% for p in range(1, total_pages + 1) %}
|
||||||
|
{% if p >= page - 2 and p <= page + 2 %}
|
||||||
|
<li class="page-item {% if p == page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('licenses', page=p, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Nächste Seite -->
|
||||||
|
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('licenses', page=page+1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">→</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Letzte Seite -->
|
||||||
|
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('licenses', page=total_pages, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">Letzte</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-center text-muted">
|
||||||
|
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Lizenzen
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Actions Bar -->
|
||||||
|
<div class="bulk-actions" id="bulkActionsBar">
|
||||||
|
<div>
|
||||||
|
<span id="selectedCount">0</span> Lizenzen ausgewählt
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-success btn-sm me-2" onclick="bulkActivate()">✅ Aktivieren</button>
|
||||||
|
<button class="btn btn-warning btn-sm me-2" onclick="bulkDeactivate()">⏸️ Deaktivieren</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="bulkDelete()">🗑️ Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Live Filtering
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const filterForm = document.getElementById('filterForm');
|
||||||
|
const searchInput = document.getElementById('search');
|
||||||
|
const typeSelect = document.getElementById('type');
|
||||||
|
const statusSelect = document.getElementById('status');
|
||||||
|
|
||||||
|
// Debounce timer für Suchfeld
|
||||||
|
let searchTimeout;
|
||||||
|
|
||||||
|
// Live-Filter für Suchfeld (mit 300ms Verzögerung)
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
filterForm.submit();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live-Filter für Dropdowns (sofort)
|
||||||
|
typeSelect.addEventListener('change', function() {
|
||||||
|
filterForm.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
statusSelect.addEventListener('change', function() {
|
||||||
|
filterForm.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy to Clipboard
|
||||||
|
function copyToClipboard(text, button) {
|
||||||
|
navigator.clipboard.writeText(text).then(function() {
|
||||||
|
button.classList.add('copied');
|
||||||
|
button.innerHTML = '✅';
|
||||||
|
setTimeout(function() {
|
||||||
|
button.classList.remove('copied');
|
||||||
|
button.innerHTML = '📋';
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle License Status
|
||||||
|
function toggleLicenseStatus(licenseId, isActive) {
|
||||||
|
fetch(`/api/license/${licenseId}/toggle`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ is_active: isActive })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Optional: Show success message
|
||||||
|
} else {
|
||||||
|
// Revert toggle on error
|
||||||
|
document.getElementById(`active_${licenseId}`).checked = !isActive;
|
||||||
|
alert('Fehler beim Ändern des Status');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk Selection
|
||||||
|
const selectAll = document.getElementById('selectAll');
|
||||||
|
const checkboxes = document.querySelectorAll('.license-checkbox');
|
||||||
|
const bulkActionsBar = document.getElementById('bulkActionsBar');
|
||||||
|
const selectedCount = document.getElementById('selectedCount');
|
||||||
|
|
||||||
|
selectAll.addEventListener('change', function() {
|
||||||
|
checkboxes.forEach(cb => cb.checked = this.checked);
|
||||||
|
updateBulkActions();
|
||||||
|
});
|
||||||
|
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.addEventListener('change', updateBulkActions);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateBulkActions() {
|
||||||
|
const checkedBoxes = document.querySelectorAll('.license-checkbox:checked');
|
||||||
|
const count = checkedBoxes.length;
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
bulkActionsBar.classList.add('show');
|
||||||
|
selectedCount.textContent = count;
|
||||||
|
} else {
|
||||||
|
bulkActionsBar.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update select all checkbox
|
||||||
|
selectAll.checked = count === checkboxes.length && count > 0;
|
||||||
|
selectAll.indeterminate = count > 0 && count < checkboxes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk Actions
|
||||||
|
function getSelectedIds() {
|
||||||
|
return Array.from(document.querySelectorAll('.license-checkbox:checked'))
|
||||||
|
.map(cb => cb.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkActivate() {
|
||||||
|
const ids = getSelectedIds();
|
||||||
|
if (confirm(`${ids.length} Lizenzen aktivieren?`)) {
|
||||||
|
performBulkAction('/api/licenses/bulk-activate', ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkDeactivate() {
|
||||||
|
const ids = getSelectedIds();
|
||||||
|
if (confirm(`${ids.length} Lizenzen deaktivieren?`)) {
|
||||||
|
performBulkAction('/api/licenses/bulk-deactivate', ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkDelete() {
|
||||||
|
const ids = getSelectedIds();
|
||||||
|
if (confirm(`${ids.length} Lizenzen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden!`)) {
|
||||||
|
performBulkAction('/api/licenses/bulk-delete', ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function performBulkAction(url, ids) {
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids: ids })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Fehler bei der Bulk-Aktion: ' + data.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Login - Lizenzverwaltung</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.error-failed {
|
||||||
|
background-color: #dc3545 !important;
|
||||||
|
color: white !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
text-align: center !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
text-transform: uppercase !important;
|
||||||
|
animation: shake 0.5s;
|
||||||
|
box-shadow: 0 0 20px rgba(220, 53, 69, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-blocked {
|
||||||
|
background-color: #6f42c1 !important;
|
||||||
|
color: white !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
font-size: 1.2rem !important;
|
||||||
|
text-align: center !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-captcha {
|
||||||
|
background-color: #fd7e14 !important;
|
||||||
|
color: white !important;
|
||||||
|
font-weight: bold !important;
|
||||||
|
font-size: 1.2rem !important;
|
||||||
|
text-align: center !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempts-warning {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row min-vh-100 align-items-center justify-content-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<h2 class="text-center mb-4">🔐 Admin Login</h2>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error-{{ error_type|default('failed') }}">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if attempts_left is defined and attempts_left > 0 and attempts_left < 5 %}
|
||||||
|
<div class="attempts-warning">
|
||||||
|
⚠️ Noch {{ attempts_left }} Versuch(e) bis zur IP-Sperre!
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Benutzername</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Passwort</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if show_captcha and recaptcha_site_key %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="g-recaptcha" data-sitekey="{{ recaptcha_site_key }}"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="security-info">
|
||||||
|
🛡️ Geschützt durch Rate-Limiting und IP-Sperre
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if show_captcha and recaptcha_site_key %}
|
||||||
|
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Benutzerprofil{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.profile-card {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.profile-card:hover {
|
||||||
|
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.profile-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.security-badge {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: #6c757d;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.25);
|
||||||
|
}
|
||||||
|
.password-strength {
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.strength-very-weak { background-color: #dc3545; width: 20%; }
|
||||||
|
.strength-weak { background-color: #fd7e14; width: 40%; }
|
||||||
|
.strength-medium { background-color: #ffc107; width: 60%; }
|
||||||
|
.strength-strong { background-color: #28a745; width: 80%; }
|
||||||
|
.strength-very-strong { background-color: #0d6efd; width: 100%; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>👤 Benutzerprofil</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<!-- User Info Stats -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card profile-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="profile-icon text-primary">👤</div>
|
||||||
|
<h5 class="card-title">{{ user.username }}</h5>
|
||||||
|
<p class="text-muted mb-0">{{ user.email or 'Keine E-Mail angegeben' }}</p>
|
||||||
|
<small class="text-muted">Mitglied seit: {{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'Unbekannt' }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card profile-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="profile-icon text-info">🔐</div>
|
||||||
|
<h5 class="card-title">Sicherheitsstatus</h5>
|
||||||
|
{% if user.totp_enabled %}
|
||||||
|
<span class="badge bg-success">2FA Aktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark">2FA Inaktiv</span>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-muted mb-0 mt-2">
|
||||||
|
<small>Letztes Passwort-Update:<br>{{ user.last_password_change.strftime('%d.%m.%Y') if user.last_password_change else 'Noch nie' }}</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Change Card -->
|
||||||
|
<div class="card profile-card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title d-flex align-items-center">
|
||||||
|
<span class="security-badge">🔑</span>
|
||||||
|
Passwort ändern
|
||||||
|
</h5>
|
||||||
|
<hr>
|
||||||
|
<form method="POST" action="{{ url_for('change_password') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="current_password" class="form-label">Aktuelles Passwort</label>
|
||||||
|
<input type="password" class="form-control" id="current_password" name="current_password" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_password" class="form-label">Neues Passwort</label>
|
||||||
|
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
|
||||||
|
<div class="password-strength" id="password-strength"></div>
|
||||||
|
<div class="form-text" id="password-help">Mindestens 8 Zeichen</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm_password" class="form-label">Neues Passwort bestätigen</label>
|
||||||
|
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||||||
|
<div class="invalid-feedback">Passwörter stimmen nicht überein</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">🔄 Passwort ändern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2FA Card -->
|
||||||
|
<div class="card profile-card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title d-flex align-items-center">
|
||||||
|
<span class="security-badge">🔐</span>
|
||||||
|
Zwei-Faktor-Authentifizierung (2FA)
|
||||||
|
</h5>
|
||||||
|
<hr>
|
||||||
|
{% if user.totp_enabled %}
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-1">Status: <span class="badge bg-success">Aktiv</span></h6>
|
||||||
|
<p class="text-muted mb-0">Ihr Account ist durch 2FA geschützt</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-success" style="font-size: 3rem;">✅</div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('disable_2fa') }}" onsubmit="return confirm('Sind Sie sicher, dass Sie 2FA deaktivieren möchten? Dies verringert die Sicherheit Ihres Accounts.');">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Passwort zur Bestätigung</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required placeholder="Ihr aktuelles Passwort">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-danger">🚫 2FA deaktivieren</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-1">Status: <span class="badge bg-warning text-dark">Inaktiv</span></h6>
|
||||||
|
<p class="text-muted mb-0">Aktivieren Sie 2FA für zusätzliche Sicherheit</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-warning" style="font-size: 3rem;">⚠️</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">
|
||||||
|
Mit 2FA wird bei jeder Anmeldung zusätzlich ein Code aus Ihrer Authenticator-App benötigt.
|
||||||
|
Dies schützt Ihren Account auch bei kompromittiertem Passwort.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">✨ 2FA einrichten</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Password strength indicator
|
||||||
|
document.getElementById('new_password').addEventListener('input', function(e) {
|
||||||
|
const password = e.target.value;
|
||||||
|
let strength = 0;
|
||||||
|
|
||||||
|
if (password.length >= 8) strength++;
|
||||||
|
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength++;
|
||||||
|
if (password.match(/[0-9]/)) strength++;
|
||||||
|
if (password.match(/[^a-zA-Z0-9]/)) strength++;
|
||||||
|
|
||||||
|
const strengthText = ['Sehr schwach', 'Schwach', 'Mittel', 'Stark', 'Sehr stark'];
|
||||||
|
const strengthClass = ['strength-very-weak', 'strength-weak', 'strength-medium', 'strength-strong', 'strength-very-strong'];
|
||||||
|
const textClass = ['text-danger', 'text-warning', 'text-warning', 'text-success', 'text-primary'];
|
||||||
|
|
||||||
|
const strengthBar = document.getElementById('password-strength');
|
||||||
|
const helpText = document.getElementById('password-help');
|
||||||
|
|
||||||
|
if (password.length > 0) {
|
||||||
|
strengthBar.className = `password-strength ${strengthClass[strength]}`;
|
||||||
|
strengthBar.style.display = 'block';
|
||||||
|
helpText.textContent = `Stärke: ${strengthText[strength]}`;
|
||||||
|
helpText.className = `form-text ${textClass[strength]}`;
|
||||||
|
} else {
|
||||||
|
strengthBar.style.display = 'none';
|
||||||
|
helpText.textContent = 'Mindestens 8 Zeichen';
|
||||||
|
helpText.className = 'form-text';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm password validation
|
||||||
|
document.getElementById('confirm_password').addEventListener('input', function(e) {
|
||||||
|
const password = document.getElementById('new_password').value;
|
||||||
|
const confirm = e.target.value;
|
||||||
|
|
||||||
|
if (confirm.length > 0) {
|
||||||
|
if (password !== confirm) {
|
||||||
|
e.target.classList.add('is-invalid');
|
||||||
|
e.target.setCustomValidity('Passwörter stimmen nicht überein');
|
||||||
|
} else {
|
||||||
|
e.target.classList.remove('is-invalid');
|
||||||
|
e.target.classList.add('is-valid');
|
||||||
|
e.target.setCustomValidity('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e.target.classList.remove('is-invalid', 'is-valid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also check when password field changes
|
||||||
|
document.getElementById('new_password').addEventListener('input', function(e) {
|
||||||
|
const confirm = document.getElementById('confirm_password');
|
||||||
|
if (confirm.value.length > 0) {
|
||||||
|
confirm.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,365 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Resource Historie{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
/* Resource Info Card */
|
||||||
|
.resource-info-card {
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resource Value Display */
|
||||||
|
.resource-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #212529;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: inline-block;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badge Large */
|
||||||
|
.status-badge-large {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline Styling */
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 30px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: linear-gradient(to bottom, #e9ecef 0%, #dee2e6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 80px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
top: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 3px 6px rgba(0,0,0,0.1);
|
||||||
|
position: relative;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 10px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -10px;
|
||||||
|
top: 15px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 10px 10px 10px 0;
|
||||||
|
border-color: transparent #fff transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Icons */
|
||||||
|
.action-icon {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-created { background-color: #d4edda; color: #155724; }
|
||||||
|
.action-allocated { background-color: #cce5ff; color: #004085; }
|
||||||
|
.action-deallocated { background-color: #d1ecf1; color: #0c5460; }
|
||||||
|
.action-quarantined { background-color: #fff3cd; color: #856404; }
|
||||||
|
.action-released { background-color: #d4edda; color: #155724; }
|
||||||
|
.action-deleted { background-color: #f8d7da; color: #721c24; }
|
||||||
|
|
||||||
|
/* Details Box */
|
||||||
|
.details-box {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Grid */
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-0">Resource Historie</h1>
|
||||||
|
<p class="text-muted mb-0">Detaillierte Aktivitätshistorie</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('resources') }}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-arrow-left"></i> Zurück zur Übersicht
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resource Info Card -->
|
||||||
|
<div class="card resource-info-card mb-4">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">📋 Resource Details</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Main Resource Info -->
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
{% if resource.resource_type == 'domain' %}
|
||||||
|
<span class="display-1">🌐</span>
|
||||||
|
{% elif resource.resource_type == 'ipv4' %}
|
||||||
|
<span class="display-1">🖥️</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="display-1">📱</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="resource-value mb-3">{{ resource.resource_value }}</div>
|
||||||
|
<div>
|
||||||
|
{% if resource.status == 'available' %}
|
||||||
|
<span class="status-badge-large badge bg-success">
|
||||||
|
✅ Verfügbar
|
||||||
|
</span>
|
||||||
|
{% elif resource.status == 'allocated' %}
|
||||||
|
<span class="status-badge-large badge bg-primary">
|
||||||
|
🔗 Zugeteilt
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge-large badge bg-warning text-dark">
|
||||||
|
⚠️ Quarantäne
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Info Grid -->
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Ressourcentyp</div>
|
||||||
|
<div class="info-value">{{ resource.resource_type|upper }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Erstellt am</div>
|
||||||
|
<div class="info-value">
|
||||||
|
{{ resource.created_at.strftime('%d.%m.%Y %H:%M') if resource.created_at else '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Status geändert</div>
|
||||||
|
<div class="info-value">
|
||||||
|
{{ resource.status_changed_at.strftime('%d.%m.%Y %H:%M') if resource.status_changed_at else '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if resource.allocated_to_license %}
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Zugewiesen an Lizenz</div>
|
||||||
|
<div class="info-value">
|
||||||
|
<a href="{{ url_for('edit_license', license_id=resource.allocated_to_license) }}"
|
||||||
|
class="text-decoration-none">
|
||||||
|
{{ license_info.license_key if license_info else 'ID: ' + resource.allocated_to_license|string }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if resource.quarantine_reason %}
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Quarantäne-Grund</div>
|
||||||
|
<div class="info-value">
|
||||||
|
<span class="badge bg-warning text-dark">{{ resource.quarantine_reason }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if resource.quarantine_until %}
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Quarantäne bis</div>
|
||||||
|
<div class="info-value">
|
||||||
|
{{ resource.quarantine_until.strftime('%d.%m.%Y') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if resource.notes %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
<h6 class="alert-heading">📝 Notizen</h6>
|
||||||
|
<p class="mb-0">{{ resource.notes }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History Timeline -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="mb-0">⏱️ Aktivitäts-Historie</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if history %}
|
||||||
|
<div class="timeline">
|
||||||
|
{% for event in history %}
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-marker
|
||||||
|
{% if event.action == 'created' %}bg-success
|
||||||
|
{% elif event.action == 'allocated' %}bg-primary
|
||||||
|
{% elif event.action == 'deallocated' %}bg-info
|
||||||
|
{% elif event.action == 'quarantined' %}bg-warning
|
||||||
|
{% elif event.action == 'released' %}bg-success
|
||||||
|
{% elif event.action == 'deleted' %}bg-danger
|
||||||
|
{% else %}bg-secondary{% endif %}">
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
{% if event.action == 'created' %}
|
||||||
|
<span class="action-icon action-created">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
</span>
|
||||||
|
<h6 class="mb-0">Ressource erstellt</h6>
|
||||||
|
{% elif event.action == 'allocated' %}
|
||||||
|
<span class="action-icon action-allocated">
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
</span>
|
||||||
|
<h6 class="mb-0">An Lizenz zugeteilt</h6>
|
||||||
|
{% elif event.action == 'deallocated' %}
|
||||||
|
<span class="action-icon action-deallocated">
|
||||||
|
<i class="fas fa-unlink"></i>
|
||||||
|
</span>
|
||||||
|
<h6 class="mb-0">Von Lizenz freigegeben</h6>
|
||||||
|
{% elif event.action == 'quarantined' %}
|
||||||
|
<span class="action-icon action-quarantined">
|
||||||
|
<i class="fas fa-ban"></i>
|
||||||
|
</span>
|
||||||
|
<h6 class="mb-0">In Quarantäne gesetzt</h6>
|
||||||
|
{% elif event.action == 'released' %}
|
||||||
|
<span class="action-icon action-released">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</span>
|
||||||
|
<h6 class="mb-0">Aus Quarantäne entlassen</h6>
|
||||||
|
{% elif event.action == 'deleted' %}
|
||||||
|
<span class="action-icon action-deleted">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</span>
|
||||||
|
<h6 class="mb-0">Ressource gelöscht</h6>
|
||||||
|
{% else %}
|
||||||
|
<h6 class="mb-0">{{ event.action }}</h6>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="fas fa-user"></i> {{ event.action_by }}
|
||||||
|
{% if event.ip_address %}
|
||||||
|
• <i class="fas fa-globe"></i> {{ event.ip_address }}
|
||||||
|
{% endif %}
|
||||||
|
{% if event.license_id %}
|
||||||
|
•
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
<a href="{{ url_for('edit_license', license_id=event.license_id) }}">
|
||||||
|
Lizenz #{{ event.license_id }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if event.details %}
|
||||||
|
<div class="details-box">
|
||||||
|
<strong>Details:</strong>
|
||||||
|
<pre class="mb-0" style="white-space: pre-wrap;">{{ event.details|tojson(indent=2) }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-end ms-3">
|
||||||
|
<div class="badge bg-light text-dark">
|
||||||
|
<i class="far fa-calendar"></i> {{ event.action_at.strftime('%d.%m.%Y') }}
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
<i class="far fa-clock"></i> {{ event.action_at.strftime('%H:%M:%S') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="fas fa-history text-muted" style="font-size: 3rem; opacity: 0.5;"></i>
|
||||||
|
<p class="text-muted mt-3">Keine Historie-Einträge vorhanden.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden Mehr anzeigen
In neuem Issue referenzieren
Einen Benutzer sperren