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