29252 Zeilen
1.1 MiB
29252 Zeilen
1.1 MiB
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
|
||
index 0238830..a95b7a2 100644
|
||
--- a/.claude/settings.local.json
|
||
+++ b/.claude/settings.local.json
|
||
@@ -63,7 +63,8 @@
|
||
"Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"BACKUP|LOGIN_2FA_SUCCESS\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/app.py)",
|
||
"Bash(sed:*)",
|
||
"Bash(python:*)",
|
||
- "Bash(awk:*)"
|
||
+ "Bash(awk:*)",
|
||
+ "Bash(./backup_before_cleanup.sh:*)"
|
||
],
|
||
"deny": []
|
||
}
|
||
diff --git a/JOURNAL.md b/JOURNAL.md
|
||
index fc95379..be0c5f5 100644
|
||
--- a/JOURNAL.md
|
||
+++ b/JOURNAL.md
|
||
@@ -1,2732 +1,2732 @@
|
||
-# v2-Docker Projekt Journal
|
||
-
|
||
-## Letzte Änderungen (06.01.2025)
|
||
-
|
||
-### Gerätelimit-Feature implementiert
|
||
-- **Datenbank-Schema erweitert**:
|
||
- - Neue Spalte `device_limit` in `licenses` Tabelle (Standard: 3, Range: 1-10)
|
||
- - Neue Tabelle `device_registrations` für Hardware-ID Tracking
|
||
- - Indizes für Performance-Optimierung hinzugefügt
|
||
-
|
||
-- **UI-Anpassungen**:
|
||
- - Einzellizenz-Formular: Dropdown für Gerätelimit (1-10 Geräte)
|
||
- - Batch-Formular: Gerätelimit pro Lizenz auswählbar
|
||
- - Lizenz-Bearbeitung: Gerätelimit änderbar
|
||
- - Lizenz-Anzeige: Zeigt aktive Geräte (z.B. "💻 2/3")
|
||
-
|
||
-- **Backend-Änderungen**:
|
||
- - Lizenz-Erstellung speichert device_limit
|
||
- - Batch-Erstellung berücksichtigt device_limit
|
||
- - Lizenz-Update kann device_limit ändern
|
||
- - API-Endpoints liefern Geräteinformationen
|
||
-
|
||
-- **Migration**:
|
||
- - Skript `migrate_device_limit.sql` erstellt
|
||
- - Setzt device_limit = 3 für alle bestehenden Lizenzen
|
||
-
|
||
-### Vollständig implementiert:
|
||
-✅ Device Management UI (Geräte pro Lizenz anzeigen/verwalten)
|
||
-✅ Device Validation Logic (Prüfung bei Geräte-Registrierung)
|
||
-✅ API-Endpoints für Geräte-Registrierung/Deregistrierung
|
||
-
|
||
-### API-Endpoints:
|
||
-- `GET /api/license/<id>/devices` - Listet alle Geräte einer Lizenz
|
||
-- `POST /api/license/<id>/register-device` - Registriert ein neues Gerät
|
||
-- `POST /api/license/<id>/deactivate-device/<device_id>` - Deaktiviert ein Gerät
|
||
-
|
||
-### Features:
|
||
-- Geräte-Registrierung mit Hardware-ID Validierung
|
||
-- Automatische Prüfung des Gerätelimits
|
||
-- Reaktivierung deaktivierter Geräte möglich
|
||
-- Geräte-Verwaltung UI mit Modal-Dialog
|
||
-- Anzeige von Gerätename, OS, IP, Registrierungsdatum
|
||
-- Admin kann Geräte manuell deaktivieren
|
||
-
|
||
----
|
||
-
|
||
-## Projektübersicht
|
||
-Lizenzmanagement-System für Social Media Account-Erstellungssoftware mit Docker-basierter Architektur.
|
||
-
|
||
-### Technische Anforderungen
|
||
-- **Lokaler Betrieb**: Docker mit 4GB RAM und 40GB Speicher
|
||
-- **Internet-Zugriff**:
|
||
- - Admin Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
- - API Server: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
-- **Datenbank**: PostgreSQL mit 2 Admin-Usern
|
||
-- **Ziel**: PoC für spätere VPS-Migration
|
||
-
|
||
----
|
||
-
|
||
-## Best Practices für Produktiv-Migration
|
||
-
|
||
-### Passwort-Management
|
||
-Für die Migration auf Hetzner/VPS müssen die Credentials sicher verwaltet werden:
|
||
-
|
||
-1. **Environment Variables erstellen:**
|
||
- ```bash
|
||
- # .env.example (ins Git Repository)
|
||
- POSTGRES_USER=changeme
|
||
- POSTGRES_PASSWORD=changeme
|
||
- POSTGRES_DB=changeme
|
||
- SECRET_KEY=generate-a-secure-key
|
||
- ADMIN_USER_1=changeme
|
||
- ADMIN_PASS_1=changeme
|
||
- ADMIN_USER_2=changeme
|
||
- ADMIN_PASS_2=changeme
|
||
-
|
||
- # .env (NICHT ins Git, auf Server erstellen)
|
||
- POSTGRES_USER=produktiv_user
|
||
- POSTGRES_PASSWORD=sicheres_passwort_min_20_zeichen
|
||
- POSTGRES_DB=v2docker_prod
|
||
- SECRET_KEY=generierter_64_zeichen_key
|
||
- # etc.
|
||
- ```
|
||
-
|
||
-2. **Sichere Passwörter generieren:**
|
||
- - Mindestens 20 Zeichen
|
||
- - Mix aus Groß-/Kleinbuchstaben, Zahlen, Sonderzeichen
|
||
- - Verschiedene Passwörter für Dev/Staging/Prod
|
||
- - Password-Generator verwenden (z.B. `openssl rand -base64 32`)
|
||
-
|
||
-3. **Erweiterte Sicherheit (Optional):**
|
||
- - HashiCorp Vault für zentrale Secret-Verwaltung
|
||
- - Docker Secrets (für Docker Swarm)
|
||
- - Cloud-Lösungen: AWS Secrets Manager, Azure Key Vault
|
||
-
|
||
-4. **Wichtige Checkliste:**
|
||
- - [ ] `.env` in `.gitignore` aufnehmen
|
||
- - [ ] Neue Credentials für Produktion generieren
|
||
- - [ ] Backup der Credentials an sicherem Ort
|
||
- - [ ] Regelmäßige Passwort-Rotation planen
|
||
- - [ ] Keine Default-Passwörter verwenden
|
||
-
|
||
----
|
||
-
|
||
-## Änderungsprotokoll
|
||
-
|
||
-### 2025-06-06 - Journal erstellt
|
||
-- Initialer Projektstand dokumentiert
|
||
-- Aufgabenliste priorisiert
|
||
-- Technische Anforderungen festgehalten
|
||
-
|
||
-### 2025-06-06 - UTF-8 Support implementiert
|
||
-- Flask App Konfiguration für UTF-8 hinzugefügt (JSON_AS_ASCII=False)
|
||
-- PostgreSQL Verbindung mit UTF-8 client_encoding
|
||
-- HTML Forms mit accept-charset="UTF-8"
|
||
-- Dockerfile mit deutschen Locale-Einstellungen (de_DE.UTF-8)
|
||
-- PostgreSQL Container mit UTF-8 Initialisierung
|
||
-- init.sql mit SET client_encoding = 'UTF8'
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- v2_adminpanel/app.py
|
||
-- v2_adminpanel/templates/index.html
|
||
-- v2_adminpanel/init.sql
|
||
-- v2_adminpanel/Dockerfile
|
||
-- v2/docker-compose.yaml
|
||
-
|
||
-**Nächster Test:**
|
||
-- Container neu bauen und starten
|
||
-- Kundennamen mit Umlauten testen (z.B. "Müller GmbH", "Björn Schäfer")
|
||
-- Email mit Umlauten testen
|
||
-
|
||
-### 2025-06-06 - Lizenzübersicht implementiert
|
||
-- Neue Route `/licenses` für Lizenzübersicht
|
||
-- SQL-Query mit JOIN zwischen licenses und customers
|
||
-- Status-Berechnung (aktiv, läuft bald ab, abgelaufen)
|
||
-- Farbcodierung für verschiedene Status
|
||
-- Navigation zwischen Lizenz erstellen und Übersicht
|
||
-
|
||
-**Neue Features:**
|
||
-- Anzeige aller Lizenzen mit Kundeninformationen
|
||
-- Status-Anzeige basierend auf Ablaufdatum
|
||
-- Unterscheidung zwischen Voll- und Testversion
|
||
-- Responsive Tabelle mit Bootstrap
|
||
-- Link von Dashboard zur Übersicht und zurück
|
||
-
|
||
-**Geänderte/Neue Dateien:**
|
||
-- v2_adminpanel/app.py (neue Route hinzugefügt)
|
||
-- v2_adminpanel/templates/licenses.html (neu erstellt)
|
||
-- v2_adminpanel/templates/index.html (Navigation ergänzt)
|
||
-
|
||
-**Nächster Test:**
|
||
-- Container neu starten
|
||
-- Mehrere Lizenzen mit verschiedenen Ablaufdaten erstellen
|
||
-- Lizenzübersicht unter /licenses aufrufen
|
||
-
|
||
-### 2025-06-06 - Lizenz bearbeiten/löschen implementiert
|
||
-- Neue Routen für Bearbeiten und Löschen von Lizenzen
|
||
-- Bearbeitungsformular mit vorausgefüllten Werten
|
||
-- Aktiv/Inaktiv-Status kann geändert werden
|
||
-- Lösch-Bestätigung per JavaScript confirm()
|
||
-- Kunde kann nicht geändert werden (nur Lizenzdetails)
|
||
-
|
||
-**Neue Features:**
|
||
-- `/license/edit/<id>` - Bearbeitungsformular
|
||
-- `/license/delete/<id>` - Lizenz löschen (POST)
|
||
-- Aktionen-Spalte in der Lizenzübersicht
|
||
-- Buttons für Bearbeiten und Löschen
|
||
-- Checkbox für Aktiv-Status
|
||
-
|
||
-**Geänderte/Neue Dateien:**
|
||
-- v2_adminpanel/app.py (edit_license und delete_license Routen)
|
||
-- v2_adminpanel/templates/licenses.html (Aktionen-Spalte hinzugefügt)
|
||
-- v2_adminpanel/templates/edit_license.html (neu erstellt)
|
||
-
|
||
-**Sicherheit:**
|
||
-- Login-Required für alle Aktionen
|
||
-- POST-only für Löschvorgänge
|
||
-- Bestätigungsdialog vor dem Löschen
|
||
-
|
||
-### 2025-06-06 - Kundenverwaltung implementiert
|
||
-- Komplette CRUD-Funktionalität für Kunden
|
||
-- Übersicht zeigt Anzahl aktiver/gesamter Lizenzen pro Kunde
|
||
-- Kunden können nur gelöscht werden, wenn sie keine Lizenzen haben
|
||
-- Bearbeitungsseite zeigt alle Lizenzen des Kunden
|
||
-
|
||
-**Neue Features:**
|
||
-- `/customers` - Kundenübersicht mit Statistiken
|
||
-- `/customer/edit/<id>` - Kunde bearbeiten (Name, E-Mail)
|
||
-- `/customer/delete/<id>` - Kunde löschen (nur ohne Lizenzen)
|
||
-- Navigation zwischen allen drei Hauptbereichen
|
||
-- Anzeige der Kundenlizenzen beim Bearbeiten
|
||
-
|
||
-**Geänderte/Neue Dateien:**
|
||
-- v2_adminpanel/app.py (customers, edit_customer, delete_customer Routen)
|
||
-- v2_adminpanel/templates/customers.html (neu erstellt)
|
||
-- v2_adminpanel/templates/edit_customer.html (neu erstellt)
|
||
-- v2_adminpanel/templates/index.html (Navigation erweitert)
|
||
-- v2_adminpanel/templates/licenses.html (Navigation erweitert)
|
||
-
|
||
-**Besonderheiten:**
|
||
-- Lösch-Button ist deaktiviert, wenn Kunde Lizenzen hat
|
||
-- Aktive Lizenzen werden separat gezählt (nicht abgelaufen + aktiv)
|
||
-- UTF-8 Support für Kundennamen mit Umlauten
|
||
-
|
||
-### 2025-06-06 - Dashboard mit Statistiken implementiert
|
||
-- Übersichtliches Dashboard als neue Startseite
|
||
-- Statistik-Karten mit wichtigen Kennzahlen
|
||
-- Listen für bald ablaufende und zuletzt erstellte Lizenzen
|
||
-- Routing angepasst: Dashboard (/) und Lizenz erstellen (/create)
|
||
-
|
||
-**Neue Features:**
|
||
-- Statistik-Karten: Kunden, Lizenzen gesamt, Aktive, Ablaufende
|
||
-- Aufteilung nach Lizenztypen (Vollversion/Testversion)
|
||
-- Aufteilung nach Status (Aktiv/Abgelaufen)
|
||
-- Top 10 bald ablaufende Lizenzen mit Restlaufzeit
|
||
-- Letzte 5 erstellte Lizenzen mit Status
|
||
-- Hover-Effekt auf Statistik-Karten
|
||
-- Einheitliche Navigation mit Dashboard-Link
|
||
-
|
||
-**Geänderte/Neue Dateien:**
|
||
-- v2_adminpanel/app.py (dashboard() komplett überarbeitet, create_license() Route)
|
||
-- v2_adminpanel/templates/dashboard.html (neu erstellt)
|
||
-- v2_adminpanel/templates/index.html (Navigation erweitert)
|
||
-- v2_adminpanel/templates/licenses.html (Navigation angepasst)
|
||
-- v2_adminpanel/templates/customers.html (Navigation angepasst)
|
||
-
|
||
-**Dashboard-Inhalte:**
|
||
-- 4 Hauptstatistiken als Karten
|
||
-- Lizenztyp-Verteilung
|
||
-- Status-Verteilung
|
||
-- Warnung für bald ablaufende Lizenzen
|
||
-- Übersicht der neuesten Aktivitäten
|
||
-
|
||
-### 2025-06-06 - Suchfunktion implementiert
|
||
-- Volltextsuche für Lizenzen und Kunden
|
||
-- Case-insensitive Suche mit LIKE-Operator
|
||
-- Suchergebnisse mit Hervorhebung des Suchbegriffs
|
||
-- Suche zurücksetzen Button
|
||
-
|
||
-**Neue Features:**
|
||
-- **Lizenzsuche**: Sucht in Lizenzschlüssel, Kundenname und E-Mail
|
||
-- **Kundensuche**: Sucht in Kundenname und E-Mail
|
||
-- Suchformular mit autofocus für schnelle Eingabe
|
||
-- Anzeige des aktiven Suchbegriffs
|
||
-- Unterschiedliche Meldungen für leere Ergebnisse
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- v2_adminpanel/app.py (licenses() und customers() mit Suchlogik erweitert)
|
||
-- v2_adminpanel/templates/licenses.html (Suchformular hinzugefügt)
|
||
-- v2_adminpanel/templates/customers.html (Suchformular hinzugefügt)
|
||
-
|
||
-**Technische Details:**
|
||
-- GET-Parameter für Suche
|
||
-- SQL LIKE mit LOWER() für Case-Insensitive Suche
|
||
-- Wildcards (%) für Teilstring-Suche
|
||
-- UTF-8 kompatibel für deutsche Umlaute
|
||
-
|
||
-### 2025-06-06 - Filter und Pagination implementiert
|
||
-- Erweiterte Filteroptionen für Lizenzübersicht
|
||
-- Pagination für große Datenmengen (20 Einträge pro Seite)
|
||
-- Filter bleiben bei Seitenwechsel erhalten
|
||
-
|
||
-**Neue Features für Lizenzen:**
|
||
-- **Filter nach Typ**: Alle, Vollversion, Testversion
|
||
-- **Filter nach Status**: Alle, Aktiv, Läuft bald ab, Abgelaufen, Deaktiviert
|
||
-- **Kombinierbar mit Suche**: Filter und Suche funktionieren zusammen
|
||
-- **Pagination**: Navigation durch mehrere Seiten
|
||
-- **Ergebnisanzeige**: Zeigt Anzahl gefilterter Ergebnisse
|
||
-
|
||
-**Neue Features für Kunden:**
|
||
-- **Pagination**: 20 Kunden pro Seite
|
||
-- **Seitennavigation**: Erste, Letzte, Vor, Zurück
|
||
-- **Kombiniert mit Suche**: Suchparameter bleiben erhalten
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- v2_adminpanel/app.py (licenses() und customers() mit Filter/Pagination erweitert)
|
||
-- v2_adminpanel/templates/licenses.html (Filter-Formular und Pagination hinzugefügt)
|
||
-- v2_adminpanel/templates/customers.html (Pagination hinzugefügt)
|
||
-
|
||
-**Technische Details:**
|
||
-- SQL WHERE-Klauseln für Filter
|
||
-- LIMIT/OFFSET für Pagination
|
||
-- URL-Parameter bleiben bei Navigation erhalten
|
||
-- Responsive Bootstrap-Komponenten
|
||
-
|
||
-### 2025-06-06 - Session-Tracking implementiert
|
||
-- Neue Tabelle für Session-Verwaltung
|
||
-- Anzeige aktiver und beendeter Sessions
|
||
-- Manuelles Beenden von Sessions möglich
|
||
-- Dashboard zeigt Anzahl aktiver Sessions
|
||
-
|
||
-**Neue Features:**
|
||
-- **Sessions-Tabelle**: Speichert Session-ID, IP, User-Agent, Zeitstempel
|
||
-- **Aktive Sessions**: Zeigt alle laufenden Sessions mit Inaktivitätszeit
|
||
-- **Session-Historie**: Letzte 24 Stunden beendeter Sessions
|
||
-- **Session beenden**: Admins können Sessions manuell beenden
|
||
-- **Farbcodierung**: Grün (aktiv), Gelb (>5 Min inaktiv), Rot (lange inaktiv)
|
||
-
|
||
-**Geänderte/Neue Dateien:**
|
||
-- v2_adminpanel/init.sql (sessions Tabelle hinzugefügt)
|
||
-- v2_adminpanel/app.py (sessions() und end_session() Routen)
|
||
-- v2_adminpanel/templates/sessions.html (neu erstellt)
|
||
-- v2_adminpanel/templates/dashboard.html (Session-Statistik)
|
||
-- Alle Templates (Session-Navigation hinzugefügt)
|
||
-
|
||
-**Technische Details:**
|
||
-- Heartbeat-basiertes Tracking (last_heartbeat)
|
||
-- Automatische Inaktivitätsberechnung
|
||
-- Session-Dauer Berechnung
|
||
-- Responsive Tabellen mit Bootstrap
|
||
-
|
||
-**Hinweis:**
|
||
-Die Session-Daten werden erst gefüllt, wenn der License Server API implementiert ist und Clients sich verbinden.
|
||
-
|
||
-### 2025-06-06 - Export-Funktion implementiert
|
||
-- CSV und Excel Export für Lizenzen und Kunden
|
||
-- Formatierte Ausgabe mit deutschen Datumsformaten
|
||
-- UTF-8 Unterstützung für Sonderzeichen
|
||
-
|
||
-**Neue Features:**
|
||
-- **Lizenz-Export**: Alle Lizenzen mit Kundeninformationen
|
||
-- **Kunden-Export**: Alle Kunden mit Lizenzstatistiken
|
||
-- **Format-Optionen**: Excel (.xlsx) und CSV (.csv)
|
||
-- **Deutsche Formatierung**: Datum als dd.mm.yyyy, Status auf Deutsch
|
||
-- **UTF-8 Export**: Korrekte Kodierung für Umlaute
|
||
-- **Export-Buttons**: Dropdown-Menüs in Lizenz- und Kundenübersicht
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- v2_adminpanel/app.py (export_licenses() und export_customers() Routen)
|
||
-- v2_adminpanel/requirements.txt (pandas und openpyxl hinzugefügt)
|
||
-- v2_adminpanel/templates/licenses.html (Export-Dropdown hinzugefügt)
|
||
-- v2_adminpanel/templates/customers.html (Export-Dropdown hinzugefügt)
|
||
-
|
||
-**Technische Details:**
|
||
-- Pandas für Datenverarbeitung
|
||
-- OpenPyXL für Excel-Export
|
||
-- CSV mit Semikolon-Trennung für deutsche Excel-Kompatibilität
|
||
-- Automatische Spaltenbreite in Excel
|
||
-- BOM für UTF-8 CSV (Excel-Kompatibilität)
|
||
-
|
||
-### 2025-06-06 - Audit-Log implementiert
|
||
-- Vollständiges Änderungsprotokoll für alle Aktionen
|
||
-- Filterbare Übersicht mit Pagination
|
||
-- Detaillierte Anzeige von Änderungen
|
||
-
|
||
-**Neue Features:**
|
||
-- **Audit-Log-Tabelle**: Speichert alle Änderungen mit Zeitstempel, Benutzer, IP
|
||
-- **Protokollierte Aktionen**: CREATE, UPDATE, DELETE, LOGIN, LOGOUT, EXPORT
|
||
-- **JSON-Speicherung**: Alte und neue Werte als JSONB für flexible Abfragen
|
||
-- **Filter-Optionen**: Nach Benutzer, Aktion und Entität
|
||
-- **Detail-Anzeige**: Aufklappbare Änderungsdetails
|
||
-- **Navigation**: Audit-Link in allen Templates
|
||
-
|
||
-**Geänderte/Neue Dateien:**
|
||
-- v2_adminpanel/init.sql (audit_log Tabelle mit Indizes)
|
||
-- v2_adminpanel/app.py (log_audit() Funktion und audit_log() Route)
|
||
-- v2_adminpanel/templates/audit_log.html (neu erstellt)
|
||
-- Alle Templates (Audit-Navigation hinzugefügt)
|
||
-
|
||
-**Technische Details:**
|
||
-- JSONB für strukturierte Datenspeicherung
|
||
-- Performance-Indizes auf timestamp, username und entity
|
||
-- Farbcodierung für verschiedene Aktionen
|
||
-- 50 Einträge pro Seite mit Pagination
|
||
-- IP-Adresse und User-Agent Tracking
|
||
-
|
||
-### 2025-06-06 - PostgreSQL UTF-8 Locale konfiguriert
|
||
-- Eigenes PostgreSQL Dockerfile für deutsche Locale
|
||
-- Sicherstellung der UTF-8 Unterstützung auf Datenbankebene
|
||
-
|
||
-**Neue Features:**
|
||
-- **PostgreSQL Dockerfile**: Installiert deutsche Locale (de_DE.UTF-8)
|
||
-- **Locale-Umgebungsvariablen**: LANG, LANGUAGE, LC_ALL gesetzt
|
||
-- **Docker Compose Update**: Verwendet jetzt eigenes PostgreSQL-Image
|
||
-
|
||
-**Neue Dateien:**
|
||
-- v2_postgres/Dockerfile (neu erstellt)
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- v2/docker-compose.yaml (postgres Service nutzt jetzt build statt image)
|
||
-
|
||
-**Technische Details:**
|
||
-- Basis-Image: postgres:14
|
||
-- Locale-Installation über apt-get
|
||
-- locale-gen für de_DE.UTF-8
|
||
-- Vollständige UTF-8 Unterstützung für deutsche Sonderzeichen
|
||
-
|
||
-### 2025-06-07 - Backup-Funktionalität implementiert
|
||
-- Verschlüsselte Backups mit manueller und automatischer Ausführung
|
||
-- Backup-Historie mit Download und Wiederherstellung
|
||
-- Dashboard-Integration für Backup-Status
|
||
-
|
||
-**Neue Features:**
|
||
-- **Backup-Erstellung**: Manuell und automatisch (täglich 3:00 Uhr)
|
||
-- **Verschlüsselung**: AES-256 mit Fernet, Key aus ENV oder automatisch generiert
|
||
-- **Komprimierung**: GZIP-Komprimierung vor Verschlüsselung
|
||
-- **Backup-Historie**: Vollständige Übersicht aller Backups
|
||
-- **Wiederherstellung**: Mit optionalem Verschlüsselungs-Passwort
|
||
-- **Download-Funktion**: Backups können heruntergeladen werden
|
||
-- **Dashboard-Widget**: Zeigt letztes Backup-Status
|
||
-- **E-Mail-Vorbereitung**: Struktur für Benachrichtigungen (deaktiviert)
|
||
-
|
||
-**Neue/Geänderte Dateien:**
|
||
-- v2_adminpanel/init.sql (backup_history Tabelle hinzugefügt)
|
||
-- v2_adminpanel/requirements.txt (cryptography, apscheduler hinzugefügt)
|
||
-- v2_adminpanel/app.py (Backup-Funktionen und Routen)
|
||
-- v2_adminpanel/templates/backups.html (neu erstellt)
|
||
-- v2_adminpanel/templates/dashboard.html (Backup-Status-Widget)
|
||
-- v2_adminpanel/Dockerfile (PostgreSQL-Client installiert)
|
||
-- v2/.env (EMAIL_ENABLED und BACKUP_ENCRYPTION_KEY)
|
||
-- Alle Templates (Backup-Navigation hinzugefügt)
|
||
-
|
||
-**Technische Details:**
|
||
-- Speicherort: C:\Users\Administrator\Documents\GitHub\v2-Docker\backups\
|
||
-- Dateiformat: backup_v2docker_YYYYMMDD_HHMMSS_encrypted.sql.gz.enc
|
||
-- APScheduler für automatische Backups
|
||
-- pg_dump/psql für Datenbank-Operationen
|
||
-- Audit-Log für alle Backup-Aktionen
|
||
-- Sicherheitsabfrage bei Wiederherstellung
|
||
-
|
||
-### 2025-06-07 - HTTPS/SSL und Internet-Zugriff implementiert
|
||
-- Nginx Reverse Proxy für externe Erreichbarkeit eingerichtet
|
||
-- SSL-Zertifikate von IONOS mit vollständiger Certificate Chain integriert
|
||
-- Netzwerkkonfiguration für feste IP-Adresse
|
||
-- DynDNS und Port-Forwarding konfiguriert
|
||
-
|
||
-**Neue Features:**
|
||
-- **Nginx Reverse Proxy**: Leitet HTTPS-Anfragen an Container weiter
|
||
-- **SSL-Zertifikate**: Wildcard-Zertifikat von IONOS für *.z5m7q9dk3ah2v1plx6ju.com
|
||
-- **Certificate Chain**: Server-, Intermediate- und Root-Zertifikate kombiniert
|
||
-- **Subdomain-Routing**: admin-panel-undso und api-software-undso
|
||
-- **Port-Forwarding**: FRITZ!Box 443 → 192.168.178.88
|
||
-- **Feste IP**: Windows-PC auf 192.168.178.88 konfiguriert
|
||
-
|
||
-**Neue/Geänderte Dateien:**
|
||
-- v2_nginx/nginx.conf (Reverse Proxy Konfiguration)
|
||
-- v2_nginx/Dockerfile (Nginx Container mit SSL)
|
||
-- v2_nginx/ssl/fullchain.pem (Certificate Chain)
|
||
-- v2_nginx/ssl/privkey.pem (Private Key)
|
||
-- v2/docker-compose.yaml (nginx Service hinzugefügt)
|
||
-- set-static-ip.ps1 (PowerShell Script für feste IP)
|
||
-- reset-to-dhcp.ps1 (PowerShell Script für DHCP)
|
||
-
|
||
-**Technische Details:**
|
||
-- SSL-Termination am Nginx Reverse Proxy
|
||
-- Backend-Kommunikation über Docker-internes Netzwerk
|
||
-- Admin-Panel nur noch über Nginx erreichbar (Port 443 nicht mehr exposed)
|
||
-- License-Server behält externen Port 8443 für direkte API-Zugriffe
|
||
-- Intermediate Certificates aus ZIP extrahiert und korrekt verkettet
|
||
-
|
||
-**Zugangsdaten:**
|
||
-- Admin-Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
-- Benutzer 1: rac00n
|
||
-- Benutzer 2: w@rh@mm3r
|
||
-
|
||
-**Status:**
|
||
-- ✅ Admin-Panel extern erreichbar ohne SSL-Warnungen
|
||
-- ✅ Reverse Proxy funktioniert
|
||
-- ✅ SSL-Zertifikate korrekt konfiguriert
|
||
-- ✅ Netzwerk-Setup abgeschlossen
|
||
-
|
||
-### 2025-06-07 - Projekt-Cleanup durchgeführt
|
||
-- Redundante und überflüssige Dateien entfernt
|
||
-- Projektstruktur verbessert und organisiert
|
||
-
|
||
-**Durchgeführte Änderungen:**
|
||
-1. **Entfernte Dateien:**
|
||
- - v2_adminpanel/templates/.env (Duplikat der Haupt-.env)
|
||
- - v2_postgreSQL/ (leeres Verzeichnis)
|
||
- - SSL-Zertifikate aus Root-Verzeichnis (7 Dateien)
|
||
- - Ungenutzer `json` Import aus app.py
|
||
-
|
||
-2. **Organisatorische Verbesserungen:**
|
||
- - PowerShell-Scripts in neuen `scripts/` Ordner verschoben
|
||
- - SSL-Zertifikate nur noch in v2_nginx/ssl/
|
||
- - Keine Konfigurationsdateien mehr in Template-Verzeichnissen
|
||
-
|
||
-**Technische Details:**
|
||
-- Docker-Container wurden gestoppt und nach Cleanup neu gestartet
|
||
-- Alle Services laufen wieder normal
|
||
-- Keine funktionalen Änderungen, nur Struktur-Verbesserungen
|
||
-
|
||
-**Ergebnis:**
|
||
-- Verbesserte Projektstruktur
|
||
-- Erhöhte Sicherheit (keine SSL-Zertifikate im Root)
|
||
-- Klarere Dateiorganisation
|
||
-
|
||
-### 2025-06-07 - SSL "Nicht sicher" Problem behoben
|
||
-- Chrome-Warnung trotz gültigem Zertifikat analysiert und behoben
|
||
-- Ursache: Selbstsigniertes Zertifikat in der Admin Panel Flask-App
|
||
-
|
||
-**Durchgeführte Änderungen:**
|
||
-1. **Admin Panel Konfiguration (app.py):**
|
||
- - Von HTTPS mit selbstsigniertem Zertifikat auf HTTP Port 5000 umgestellt
|
||
- - `ssl_context='adhoc'` entfernt
|
||
- - Flask läuft jetzt auf `0.0.0.0:5000` statt HTTPS
|
||
-
|
||
-2. **Dockerfile Anpassung (v2_adminpanel/Dockerfile):**
|
||
- - EXPOSE Port von 443 auf 5000 geändert
|
||
- - Container exponiert jetzt HTTP statt HTTPS
|
||
-
|
||
-3. **Nginx Konfiguration (nginx.conf):**
|
||
- - proxy_pass von `https://admin-panel:443` auf `http://admin-panel:5000` geändert
|
||
- - `proxy_ssl_verify off` entfernt (nicht mehr benötigt)
|
||
- - Sicherheits-Header für beide Domains hinzugefügt:
|
||
- - Strict-Transport-Security (HSTS) - erzwingt HTTPS für 1 Jahr
|
||
- - X-Content-Type-Options - verhindert MIME-Type Sniffing
|
||
- - X-Frame-Options - Schutz vor Clickjacking
|
||
- - X-XSS-Protection - aktiviert XSS-Filter
|
||
- - Referrer-Policy - kontrolliert Referrer-Informationen
|
||
-
|
||
-**Technische Details:**
|
||
-- Externer Traffic nutzt weiterhin HTTPS mit gültigen IONOS-Zertifikaten
|
||
-- Interne Kommunikation zwischen Nginx und Admin Panel läuft über HTTP (sicher im Docker-Netzwerk)
|
||
-- Kein selbstsigniertes Zertifikat mehr in der Zertifikatskette
|
||
-- SSL-Termination erfolgt ausschließlich am Nginx Reverse Proxy
|
||
-
|
||
-**Docker Neustart:**
|
||
-- Container gestoppt (`docker-compose down`)
|
||
-- Images neu gebaut (`docker-compose build`)
|
||
-- Container neu gestartet (`docker-compose up -d`)
|
||
-- Alle Services laufen normal
|
||
-
|
||
-**Ergebnis:**
|
||
-- ✅ "Nicht sicher" Warnung in Chrome behoben
|
||
-- ✅ Saubere SSL-Konfiguration ohne Mixed Content
|
||
-- ✅ Verbesserte Sicherheits-Header implementiert
|
||
-- ✅ Admin Panel zeigt jetzt grünes Schloss-Symbol
|
||
-
|
||
-### 2025-06-07 - Sicherheitslücke geschlossen: License Server Port
|
||
-- Direkter Zugriff auf License Server Port 8443 entfernt
|
||
-- Sicherheitsanalyse der exponierten Ports durchgeführt
|
||
-
|
||
-**Identifiziertes Problem:**
|
||
-- License Server war direkt auf Port 8443 von außen erreichbar
|
||
-- Umging damit die Nginx-Sicherheitsschicht und Security Headers
|
||
-- Besonders kritisch, da nur Platzhalter ohne echte Sicherheit
|
||
-
|
||
-**Durchgeführte Änderung:**
|
||
-- Port-Mapping für License Server in docker-compose.yaml entfernt
|
||
-- Service ist jetzt nur noch über Nginx Reverse Proxy erreichbar
|
||
-- Gleiche Sicherheitskonfiguration wie Admin Panel
|
||
-
|
||
-**Aktuelle Port-Exposition:**
|
||
-- ✅ Nginx: Port 80/443 (benötigt für externen Zugriff)
|
||
-- ✅ PostgreSQL: Keine Ports exponiert (gut)
|
||
-- ✅ Admin Panel: Nur über Nginx erreichbar
|
||
-- ✅ License Server: Nur über Nginx erreichbar (vorher direkt auf 8443)
|
||
-
|
||
-**Weitere identifizierte Sicherheitsthemen:**
|
||
-1. Credentials im Klartext in .env Datei
|
||
-2. SSL-Zertifikate im Repository gespeichert
|
||
-3. License Server noch nicht implementiert
|
||
-
|
||
-**Empfehlung:** Docker-Container neu starten für Änderungsübernahme
|
||
-
|
||
-### 2025-06-07 - License Server Port 8443 wieder aktiviert
|
||
-- Port 8443 für direkten Zugriff auf License Server wieder geöffnet
|
||
-- Notwendig für Client-Software Lizenzprüfung
|
||
-
|
||
-**Begründung:**
|
||
-- Client-Software benötigt direkten Zugriff für Lizenzprüfung
|
||
-- Umgehung von möglichen Firewall-Blockaden auf Port 443
|
||
-- Weniger Latenz ohne Nginx-Proxy
|
||
-- Flexibilität für verschiedene Client-Implementierungen
|
||
-
|
||
-**Konfiguration:**
|
||
-- License Server erreichbar über:
|
||
- - Direkt: Port 8443 (für Client-Software)
|
||
- - Via Nginx: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com (für Browser/Tests)
|
||
-
|
||
-**Sicherheitshinweis:**
|
||
-- Port 8443 ist wieder direkt exponiert
|
||
-- License Server muss vor Produktivbetrieb implementiert werden mit:
|
||
- - Eigener SSL-Konfiguration
|
||
- - API-Key Authentifizierung
|
||
- - Rate Limiting
|
||
- - Input-Validierung
|
||
-
|
||
-**Status:**
|
||
-- Port-Mapping in docker-compose.yaml wiederhergestellt
|
||
-- Änderung erfordert Docker-Neustart
|
||
-
|
||
-### 2025-06-07 - Rate-Limiting und Brute-Force-Schutz implementiert
|
||
-- Umfassender Schutz vor Login-Angriffen mit IP-Sperre
|
||
-- Dashboard-Integration für Sicherheitsüberwachung
|
||
-
|
||
-**Implementierte Features:**
|
||
-1. **Rate-Limiting System:**
|
||
- - 5 Login-Versuche erlaubt, danach 24h IP-Sperre
|
||
- - Progressive Fehlermeldungen (zufällig aus 5 lustigen Varianten)
|
||
- - CAPTCHA nach 2 Fehlversuchen (Google reCAPTCHA v2 vorbereitet)
|
||
- - E-Mail-Benachrichtigung bei Sperrung (vorbereitet, deaktiviert für PoC)
|
||
-
|
||
-2. **Timing-Attack Schutz:**
|
||
- - Mindestens 1 Sekunde Antwortzeit bei allen Login-Versuchen
|
||
- - Gleiche Antwortzeit bei richtigem/falschem Username
|
||
- - Verhindert Username-Enumeration
|
||
-
|
||
-3. **Lustige Fehlermeldungen (zufällig):**
|
||
- - "NOPE!"
|
||
- - "ACCESS DENIED, TRY HARDER"
|
||
- - "WRONG! 🚫"
|
||
- - "COMPUTER SAYS NO"
|
||
- - "YOU FAILED"
|
||
-
|
||
-4. **Dashboard-Sicherheitswidget:**
|
||
- - Sicherheitslevel-Anzeige (NORMAL/ERHÖHT/KRITISCH)
|
||
- - Anzahl gesperrter IPs
|
||
- - Fehlversuche heute
|
||
- - Letzte 5 Sicherheitsereignisse mit Details
|
||
-
|
||
-5. **IP-Verwaltung:**
|
||
- - Übersicht aller gesperrten IPs
|
||
- - Manuelles Entsperren möglich
|
||
- - Login-Versuche zurücksetzen
|
||
- - Detaillierte Informationen pro IP
|
||
-
|
||
-6. **Audit-Log Erweiterungen:**
|
||
- - LOGIN_SUCCESS - Erfolgreiche Anmeldung
|
||
- - LOGIN_FAILED - Fehlgeschlagener Versuch
|
||
- - LOGIN_BLOCKED - IP wurde gesperrt
|
||
- - UNBLOCK_IP - IP manuell entsperrt
|
||
- - CLEAR_ATTEMPTS - Versuche zurückgesetzt
|
||
-
|
||
-**Neue/Geänderte Dateien:**
|
||
-- v2_adminpanel/init.sql (login_attempts Tabelle)
|
||
-- v2_adminpanel/app.py (Rate-Limiting Logik, neue Routen)
|
||
-- v2_adminpanel/templates/login.html (Fehlermeldungs-Styling, CAPTCHA)
|
||
-- v2_adminpanel/templates/dashboard.html (Sicherheitswidget)
|
||
-- v2_adminpanel/templates/blocked_ips.html (neu - IP-Verwaltung)
|
||
-
|
||
-**Technische Details:**
|
||
-- IP-Ermittlung berücksichtigt Proxy-Header (X-Forwarded-For)
|
||
-- Fehlermeldungen mit Animation (shake-effect)
|
||
-- Farbcodierung: Rot für Fehler, Lila für Sperre, Orange für CAPTCHA
|
||
-- Automatische Bereinigung alter Einträge möglich
|
||
-
|
||
-**Sicherheitsverbesserungen:**
|
||
-- Schutz vor Brute-Force-Angriffen
|
||
-- Timing-Attack-Schutz implementiert
|
||
-- IP-basierte Sperrung für 24 Stunden
|
||
-- Audit-Trail für alle Sicherheitsereignisse
|
||
-
|
||
-**Hinweis für Produktion:**
|
||
-- CAPTCHA-Keys müssen in .env konfiguriert werden
|
||
-- E-Mail-Server für Benachrichtigungen einrichten
|
||
-- Rate-Limits können über Konstanten angepasst werden
|
||
-
|
||
-### 2025-06-07 - Session-Timeout mit Live-Timer implementiert
|
||
-- 5 Minuten Inaktivitäts-Timeout mit visueller Countdown-Anzeige
|
||
-- Automatische Session-Verlängerung bei Benutzeraktivität
|
||
-
|
||
-**Implementierte Features:**
|
||
-1. **Session-Timeout Backend:**
|
||
- - Flask Session-Timeout auf 5 Minuten konfiguriert
|
||
- - Heartbeat-Endpoint für Keep-Alive
|
||
- - Automatisches Session-Update bei jeder Aktion
|
||
-
|
||
-2. **Live-Timer in der Navbar:**
|
||
- - Countdown von 5:00 bis 0:00
|
||
- - Position: Zwischen Logo und Username
|
||
- - Farbwechsel nach verbleibender Zeit:
|
||
- - Grün: > 2 Minuten
|
||
- - Gelb: 1-2 Minuten
|
||
- - Rot: < 1 Minute
|
||
- - Blinkend: < 30 Sekunden
|
||
-
|
||
-3. **Benutzerinteraktion:**
|
||
- - Timer wird bei jeder Aktivität zurückgesetzt
|
||
- - Tracking von: Klicks, Tastatureingaben, Mausbewegungen
|
||
- - Automatischer Heartbeat bei Aktivität
|
||
- - Warnung bei < 1 Minute mit "Session verlängern" Button
|
||
-
|
||
-4. **Base-Template System:**
|
||
- - Neue base.html als Basis für alle Admin-Seiten
|
||
- - Alle Templates (außer login.html) nutzen jetzt base.html
|
||
- - Einheitliches Layout und Timer auf allen Seiten
|
||
-
|
||
-**Neue/Geänderte Dateien:**
|
||
-- v2_adminpanel/app.py (Session-Konfiguration, Heartbeat-Endpoint)
|
||
-- v2_adminpanel/templates/base.html (neu - Base-Template mit Timer)
|
||
-- Alle anderen Templates aktualisiert für Template-Vererbung
|
||
-
|
||
-**Technische Details:**
|
||
-- JavaScript-basierter Countdown-Timer
|
||
-- AJAX-Heartbeat alle 5 Sekunden bei Aktivität
|
||
-- LocalStorage für Tab-Synchronisation möglich
|
||
-- Automatischer Logout bei 0:00
|
||
-- Fetch-Interceptor für automatische Session-Verlängerung
|
||
-
|
||
-**Sicherheitsverbesserung:**
|
||
-- Automatischer Logout nach 5 Minuten Inaktivität
|
||
-- Verhindert vergessene Sessions
|
||
-- Visuelles Feedback für Session-Status
|
||
-
|
||
-### 2025-06-07 - Session-Timeout Bug behoben
|
||
-- Problem: Session-Timeout funktionierte nicht korrekt - Session blieb länger als 5 Minuten aktiv
|
||
-- Ursache: login_required Decorator aktualisierte last_activity bei JEDEM Request
|
||
-
|
||
-**Durchgeführte Änderungen:**
|
||
-1. **login_required Decorator (app.py):**
|
||
- - Prüft jetzt ob Session abgelaufen ist (5 Minuten seit last_activity)
|
||
- - Aktualisiert last_activity NICHT mehr automatisch
|
||
- - Führt AUTO_LOGOUT mit Audit-Log bei Timeout durch
|
||
- - Speichert Username vor session.clear() für korrektes Logging
|
||
-
|
||
-2. **Heartbeat-Endpoint (app.py):**
|
||
- - Geändert zu POST-only Endpoint
|
||
- - Aktualisiert explizit last_activity wenn aufgerufen
|
||
- - Wird nur bei aktiver Benutzerinteraktion aufgerufen
|
||
-
|
||
-3. **Frontend Timer (base.html):**
|
||
- - Heartbeat wird als POST Request gesendet
|
||
- - trackActivity() ruft extendSession() ohne vorheriges resetTimer() auf
|
||
- - Timer wird erst nach erfolgreichem Heartbeat zurückgesetzt
|
||
- - AJAX Interceptor ignoriert Heartbeat-Requests
|
||
-
|
||
-4. **Audit-Log Erweiterung:**
|
||
- - Neue Aktion AUTO_LOGOUT hinzugefügt
|
||
- - Orange Farbcodierung (#fd7e14)
|
||
- - Zeigt Grund des Timeouts im Audit-Log
|
||
-
|
||
-**Ergebnis:**
|
||
-- ✅ Session läuft nach exakt 5 Minuten Inaktivität ab
|
||
-- ✅ Benutzeraktivität verlängert Session korrekt
|
||
-- ✅ AUTO_LOGOUT wird im Audit-Log protokolliert
|
||
-- ✅ Visueller Timer zeigt verbleibende Zeit
|
||
-
|
||
-### 2025-06-07 - Session-Timeout weitere Verbesserungen
|
||
-- Zusätzliche Fixes nach Test-Feedback implementiert
|
||
-
|
||
-**Weitere durchgeführte Änderungen:**
|
||
-1. **Fehlender Import behoben:**
|
||
- - `flash` zu Flask-Imports hinzugefügt für Timeout-Warnmeldungen
|
||
-
|
||
-2. **Session-Cookie-Konfiguration erweitert (app.py):**
|
||
- - SESSION_COOKIE_HTTPONLY = True (Sicherheit gegen XSS)
|
||
- - SESSION_COOKIE_SECURE = False (intern HTTP, extern HTTPS via Nginx)
|
||
- - SESSION_COOKIE_SAMESITE = 'Lax' (CSRF-Schutz)
|
||
- - SESSION_COOKIE_NAME = 'admin_session' (eindeutiger Name)
|
||
- - SESSION_REFRESH_EACH_REQUEST = False (verhindert automatische Verlängerung)
|
||
-
|
||
-3. **Session-Handling verbessert:**
|
||
- - Entfernt: session.permanent = True aus login_required decorator
|
||
- - Hinzugefügt: session.modified = True im Heartbeat für explizites Speichern
|
||
- - Debug-Logging für Session-Timeout-Prüfung hinzugefügt
|
||
-
|
||
-4. **Nginx-Konfiguration:**
|
||
- - Bereits korrekt konfiguriert für Heartbeat-Weiterleitung
|
||
- - Proxy-Headers für korrekte IP-Weitergabe
|
||
-
|
||
-**Technische Details:**
|
||
-- Flask-Session mit Filesystem-Backend nutzt jetzt korrekte Cookie-Einstellungen
|
||
-- Session-Cookie wird nicht mehr automatisch bei jedem Request verlängert
|
||
-- Explizite Session-Modifikation nur bei Heartbeat-Requests
|
||
-- Debug-Logs zeigen Zeit seit letzter Aktivität für Troubleshooting
|
||
-
|
||
-**Status:**
|
||
-- ✅ Session-Timeout-Mechanismus vollständig implementiert
|
||
-- ✅ Debug-Logging für Session-Überwachung aktiv
|
||
-- ✅ Cookie-Sicherheitseinstellungen optimiert
|
||
-
|
||
-### 2025-06-07 - CAPTCHA Backend-Validierung implementiert
|
||
-- Google reCAPTCHA v2 Backend-Verifizierung hinzugefügt
|
||
-
|
||
-**Implementierte Features:**
|
||
-1. **verify_recaptcha() Funktion (app.py):**
|
||
- - Validiert CAPTCHA-Response mit Google API
|
||
- - Fallback: Wenn RECAPTCHA_SECRET_KEY nicht konfiguriert, wird CAPTCHA übersprungen (für PoC)
|
||
- - Timeout von 5 Sekunden für API-Request
|
||
- - Error-Handling für Netzwerkfehler
|
||
- - Logging für Debugging und Fehleranalyse
|
||
-
|
||
-2. **Login-Route Erweiterungen:**
|
||
- - CAPTCHA wird nach 2 Fehlversuchen angezeigt
|
||
- - Prüfung ob CAPTCHA-Response vorhanden
|
||
- - Validierung der CAPTCHA-Response gegen Google API
|
||
- - Unterschiedliche Fehlermeldungen für fehlende/ungültige CAPTCHA
|
||
- - Site Key wird aus Environment-Variable an Template übergeben
|
||
-
|
||
-3. **Environment-Konfiguration (.env):**
|
||
- - RECAPTCHA_SITE_KEY (für Frontend)
|
||
- - RECAPTCHA_SECRET_KEY (für Backend-Validierung)
|
||
- - Beide auskommentiert für PoC-Phase
|
||
-
|
||
-4. **Dependencies:**
|
||
- - requests Library zu requirements.txt hinzugefügt
|
||
-
|
||
-**Sicherheitsaspekte:**
|
||
-- CAPTCHA verhindert automatisierte Brute-Force-Angriffe
|
||
-- Timing-Attack-Schutz bleibt auch bei CAPTCHA-Prüfung aktiv
|
||
-- Bei Netzwerkfehlern wird CAPTCHA als bestanden gewertet (Verfügbarkeit vor Sicherheit)
|
||
-- Secret Key wird niemals im Frontend exponiert
|
||
-
|
||
-**Verwendung:**
|
||
-1. Google reCAPTCHA v2 Keys erstellen: https://www.google.com/recaptcha/admin
|
||
-2. Keys in .env eintragen:
|
||
- ```
|
||
- RECAPTCHA_SITE_KEY=your-site-key
|
||
- RECAPTCHA_SECRET_KEY=your-secret-key
|
||
- ```
|
||
-3. Container neu starten
|
||
-
|
||
-**Status:**
|
||
-- ✅ CAPTCHA-Frontend bereits vorhanden (login.html)
|
||
-- ✅ Backend-Validierung vollständig implementiert
|
||
-- ✅ Fallback für PoC-Betrieb ohne Google-Keys
|
||
-- ✅ Integration in Rate-Limiting-System
|
||
-- ⚠️ CAPTCHA-Keys noch nicht konfiguriert (für PoC deaktiviert)
|
||
-
|
||
-**Anleitung für Google reCAPTCHA Keys:**
|
||
-
|
||
-1. **Registrierung bei Google reCAPTCHA:**
|
||
- - Gehe zu: https://www.google.com/recaptcha/admin/create
|
||
- - Melde dich mit Google-Konto an
|
||
- - Label eingeben: "v2-Docker Admin Panel"
|
||
- - Typ wählen: "reCAPTCHA v2" → "Ich bin kein Roboter"-Kästchen
|
||
- - Domains hinzufügen:
|
||
- ```
|
||
- admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
- localhost
|
||
- ```
|
||
- - Nutzungsbedingungen akzeptieren
|
||
- - Senden klicken
|
||
-
|
||
-2. **Keys erhalten:**
|
||
- - Site Key (öffentlich für Frontend)
|
||
- - Secret Key (geheim für Backend-Validierung)
|
||
-
|
||
-3. **Keys in .env eintragen:**
|
||
- ```bash
|
||
- RECAPTCHA_SITE_KEY=6Ld...
|
||
- RECAPTCHA_SECRET_KEY=6Ld...
|
||
- ```
|
||
-
|
||
-4. **Container neu starten:**
|
||
- ```bash
|
||
- docker-compose down
|
||
- docker-compose up -d
|
||
- ```
|
||
-
|
||
-**Kosten:**
|
||
-- Kostenlos bis 1 Million Anfragen pro Monat
|
||
-- Danach: $1.00 pro 1000 zusätzliche Anfragen
|
||
-- Für dieses Projekt reicht die kostenlose Version vollkommen aus
|
||
-
|
||
-**Test-Keys für Entwicklung:**
|
||
-- Site Key: `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI`
|
||
-- Secret Key: `6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe`
|
||
-- ⚠️ Diese Keys nur für lokale Tests verwenden, niemals produktiv!
|
||
-
|
||
-**Aktueller Status:**
|
||
-- Code ist vollständig implementiert und getestet
|
||
-- CAPTCHA wird nach 2 Fehlversuchen angezeigt
|
||
-- Ohne konfigurierte Keys wird CAPTCHA-Prüfung übersprungen
|
||
-- Für Produktion müssen nur die Keys in .env eingetragen werden
|
||
-
|
||
-### 2025-06-07 - License Key Generator implementiert
|
||
-- Automatische Generierung von Lizenzschlüsseln mit definiertem Format
|
||
-
|
||
-**Implementiertes Format:**
|
||
-`AF-YYYYMMFT-XXXX-YYYY-ZZZZ`
|
||
-- **AF** = Account Factory (feste Produktkennung)
|
||
-- **YYYY** = Jahr (z.B. 2025)
|
||
-- **MM** = Monat (z.B. 06)
|
||
-- **FT** = Lizenztyp (F=Fullversion, T=Testversion)
|
||
-- **XXXX-YYYY-ZZZZ** = Zufällige alphanumerische Zeichen (ohne verwirrende wie 0/O, 1/I/l)
|
||
-
|
||
-**Beispiele:**
|
||
-- Vollversion: `AF-202506F-A7K9-M3P2-X8R4`
|
||
-- Testversion: `AF-202512T-B2N5-K8L3-Q9W7`
|
||
-
|
||
-**Implementierte Features:**
|
||
-
|
||
-1. **Backend-Funktionen (app.py):**
|
||
- - `generate_license_key()` - Generiert Keys mit kryptografisch sicherem Zufallsgenerator
|
||
- - `validate_license_key()` - Validiert das Key-Format mit Regex
|
||
- - Verwendet `secrets` statt `random` für Sicherheit
|
||
- - Erlaubte Zeichen: ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (ohne verwirrende)
|
||
-
|
||
-2. **API-Endpoint:**
|
||
- - POST `/api/generate-license-key` - JSON API für Key-Generierung
|
||
- - Prüft auf Duplikate in der Datenbank (max. 10 Versuche)
|
||
- - Audit-Log-Eintrag bei jeder Generierung
|
||
- - Login-Required geschützt
|
||
-
|
||
-3. **Frontend-Verbesserungen (index.html):**
|
||
- - Generate-Button neben License Key Input
|
||
- - Placeholder und Pattern-Attribut für Format-Hinweis
|
||
- - Auto-Uppercase bei manueller Eingabe
|
||
- - Visuelles Feedback bei erfolgreicher Generierung
|
||
- - Format-Hinweis unter dem Eingabefeld
|
||
-
|
||
-4. **JavaScript-Features:**
|
||
- - AJAX-basierte Key-Generierung ohne Seiten-Reload
|
||
- - Automatische Prüfung bei Lizenztyp-Änderung
|
||
- - Ladeindikator während der Generierung
|
||
- - Fehlerbehandlung mit Benutzer-Feedback
|
||
- - Standard-Datum-Einstellungen (heute + 1 Jahr)
|
||
-
|
||
-5. **Validierung:**
|
||
- - Server-seitige Format-Validierung beim Speichern
|
||
- - Flash-Message bei ungültigem Format
|
||
- - Automatische Großschreibung des Keys
|
||
- - Pattern-Validierung im HTML-Formular
|
||
-
|
||
-6. **Weitere Fixes:**
|
||
- - Form Action von "/" auf "/create" korrigiert
|
||
- - Flash-Messages mit Bootstrap Toasts implementiert
|
||
- - GENERATE_KEY Aktion zum Audit-Log hinzugefügt (Farbe: #20c997)
|
||
-
|
||
-**Technische Details:**
|
||
-- Keine vorhersagbaren Muster durch `secrets.choice()`
|
||
-- Datum im Key zeigt Erstellungszeitpunkt
|
||
-- Lizenztyp direkt im Key erkennbar
|
||
-- Kollisionsprüfung gegen Datenbank
|
||
-
|
||
-**Status:**
|
||
-- ✅ Backend-Generierung vollständig implementiert
|
||
-- ✅ Frontend mit Generate-Button und JavaScript
|
||
-- ✅ Validierung und Fehlerbehandlung
|
||
-- ✅ Audit-Log-Integration
|
||
-- ✅ Form-Action-Bug behoben
|
||
-
|
||
-### 2025-06-07 - Batch-Lizenzgenerierung implementiert
|
||
-- Mehrere Lizenzen auf einmal für einen Kunden erstellen
|
||
-
|
||
-**Implementierte Features:**
|
||
-
|
||
-1. **Batch-Formular (/batch):**
|
||
- - Kunde und E-Mail eingeben
|
||
- - Anzahl der Lizenzen (1-100)
|
||
- - Lizenztyp (Vollversion/Testversion)
|
||
- - Gültigkeitszeitraum für alle Lizenzen
|
||
- - Vorschau-Modal zeigt Key-Format
|
||
- - Standard-Datum-Einstellungen (heute + 1 Jahr)
|
||
-
|
||
-2. **Backend-Verarbeitung:**
|
||
- - Route `/batch` für GET (Formular) und POST (Generierung)
|
||
- - Generiert die angegebene Anzahl eindeutiger Keys
|
||
- - Speichert alle in einer Transaktion
|
||
- - Kunde wird automatisch angelegt (falls nicht vorhanden)
|
||
- - ON CONFLICT für existierende Kunden
|
||
- - Audit-Log-Eintrag mit CREATE_BATCH Aktion
|
||
-
|
||
-3. **Ergebnis-Seite:**
|
||
- - Zeigt alle generierten Lizenzen in Tabellenform
|
||
- - Kundeninformationen und Gültigkeitszeitraum
|
||
- - Einzelne Keys können kopiert werden (📋 Button)
|
||
- - Alle Keys auf einmal kopieren
|
||
- - Druckfunktion für physische Ausgabe
|
||
- - Link zur Lizenzübersicht mit Kundenfilter
|
||
-
|
||
-4. **Export-Funktionalität:**
|
||
- - Route `/batch/export` für CSV-Download
|
||
- - Speichert Batch-Daten in Session für Export
|
||
- - CSV mit UTF-8 BOM für Excel-Kompatibilität
|
||
- - Enthält Kundeninfo, Generierungsdatum und alle Keys
|
||
- - Format: Nr;Lizenzschlüssel;Typ
|
||
- - Dateiname: batch_licenses_KUNDE_TIMESTAMP.csv
|
||
-
|
||
-5. **Integration:**
|
||
- - Batch-Button in Navigation (Dashboard, Einzellizenz-Seite)
|
||
- - CREATE_BATCH Aktion im Audit-Log (Farbe: #6610f2)
|
||
- - Session-basierte Export-Daten
|
||
- - Flash-Messages für Feedback
|
||
-
|
||
-**Sicherheit:**
|
||
-- Limit von 100 Lizenzen pro Batch
|
||
-- Login-Required für alle Routen
|
||
-- Transaktionale Datenbank-Operationen
|
||
-- Validierung der Eingaben
|
||
-
|
||
-**Beispiel-Workflow:**
|
||
-1. Admin geht zu `/batch`
|
||
-2. Gibt Kunde "Firma GmbH", Anzahl "25", Typ "Vollversion" ein
|
||
-3. System generiert 25 eindeutige Keys
|
||
-4. Ergebnis-Seite zeigt alle Keys
|
||
-5. Admin kann CSV exportieren oder Keys kopieren
|
||
-6. Kunde erhält die Lizenzen
|
||
-
|
||
-**Status:**
|
||
-- ✅ Batch-Formular vollständig implementiert
|
||
-- ✅ Backend-Generierung mit Transaktionen
|
||
-- ✅ Export als CSV
|
||
-- ✅ Copy-to-Clipboard Funktionalität
|
||
-- ✅ Audit-Log-Integration
|
||
-- ✅ Navigation aktualisiert
|
||
-
|
||
-## 2025-06-06: Implementierung Searchable Dropdown für Kundenauswahl
|
||
-
|
||
-**Problem:**
|
||
-- Bei der Lizenzerstellung wurde immer ein neuer Kunde angelegt
|
||
-- Keine Möglichkeit, Lizenzen für bestehende Kunden zu erstellen
|
||
-- Bei vielen Kunden wäre ein normales Dropdown unübersichtlich
|
||
-
|
||
-**Lösung:**
|
||
-1. **Select2 Library** für searchable Dropdown integriert
|
||
-2. **API-Endpoint `/api/customers`** für die Kundensuche erstellt
|
||
-3. **Frontend angepasst:**
|
||
- - Searchable Dropdown mit Live-Suche
|
||
- - Option "Neuer Kunde" im Dropdown
|
||
- - Eingabefelder erscheinen nur bei "Neuer Kunde"
|
||
-4. **Backend-Logik verbessert:**
|
||
- - Prüfung ob neuer oder bestehender Kunde
|
||
- - E-Mail-Duplikatsprüfung vor Kundenerstellung
|
||
- - Separate Audit-Logs für Kunde und Lizenz
|
||
-5. **Datenbank:**
|
||
- - UNIQUE Constraint auf E-Mail-Spalte hinzugefügt
|
||
-
|
||
-**Änderungen:**
|
||
-- `app.py`: Neuer API-Endpoint `/api/customers`, angepasste Routes `/create` und `/batch`
|
||
-- `base.html`: Select2 CSS und JS eingebunden
|
||
-- `index.html`: Kundenauswahl mit Select2 implementiert
|
||
-- `batch_form.html`: Kundenauswahl mit Select2 implementiert
|
||
-- `init.sql`: UNIQUE Constraint für E-Mail
|
||
-
|
||
-**Status:**
|
||
-- ✅ API-Endpoint funktioniert mit Pagination
|
||
-- ✅ Select2 Dropdown mit Suchfunktion
|
||
-- ✅ Neue/bestehende Kunden können ausgewählt werden
|
||
-- ✅ E-Mail-Duplikate werden verhindert
|
||
-- ✅ Sowohl Einzellizenz als auch Batch unterstützt
|
||
-
|
||
-## 2025-06-06: Automatische Ablaufdatum-Berechnung
|
||
-
|
||
-**Problem:**
|
||
-- Manuelles Eingeben von Start- und Enddatum war umständlich
|
||
-- Fehleranfällig bei der Datumseingabe
|
||
-- Nicht intuitiv für Standard-Laufzeiten
|
||
-
|
||
-**Lösung:**
|
||
-1. **Frontend-Änderungen:**
|
||
- - Startdatum + Laufzeit (Zahl) + Einheit (Tage/Monate/Jahre)
|
||
- - Ablaufdatum wird automatisch berechnet und angezeigt (read-only)
|
||
- - Standard: 1 Jahr Laufzeit voreingestellt
|
||
-2. **Backend-Validierung:**
|
||
- - Server-seitige Berechnung zur Sicherheit
|
||
- - Verwendung von `python-dateutil` für korrekte Monats-/Jahresberechnungen
|
||
-3. **Benutzerfreundlichkeit:**
|
||
- - Sofortige Neuberechnung bei Änderungen
|
||
- - Visuelle Hervorhebung des berechneten Datums
|
||
-
|
||
-**Änderungen:**
|
||
-- `index.html`: Laufzeit-Eingabe statt Ablaufdatum
|
||
-- `batch_form.html`: Laufzeit-Eingabe statt Ablaufdatum
|
||
-- `app.py`: Datum-Berechnung in `/create` und `/batch` Routes
|
||
-- `requirements.txt`: `python-dateutil` hinzugefügt
|
||
-
|
||
-**Status:**
|
||
-- ✅ Automatische Berechnung funktioniert
|
||
-- ✅ Frontend zeigt berechnetes Datum sofort an
|
||
-- ✅ Backend validiert die Berechnung
|
||
-- ✅ Standardwert (1 Jahr) voreingestellt
|
||
-
|
||
-## 2025-06-06: Bugfix - created_at für licenses Tabelle
|
||
-
|
||
-**Problem:**
|
||
-- Batch-Generierung schlug fehl mit "Fehler bei der Batch-Generierung!"
|
||
-- INSERT Statement versuchte `created_at` zu setzen, aber Spalte existierte nicht
|
||
-- Inkonsistenz: Einzellizenzen hatten kein created_at, Batch-Lizenzen versuchten es zu setzen
|
||
-
|
||
-**Lösung:**
|
||
-1. **Datenbank-Schema erweitert:**
|
||
- - `created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` zur licenses Tabelle hinzugefügt
|
||
- - Migration für bestehende Datenbanken implementiert
|
||
- - Konsistent mit customers Tabelle
|
||
-2. **Code bereinigt:**
|
||
- - Explizites `created_at` aus Batch-INSERT entfernt
|
||
- - Datenbank setzt nun automatisch den Zeitstempel bei ALLEN Lizenzen
|
||
-
|
||
-**Änderungen:**
|
||
-- `init.sql`: created_at Spalte zur licenses Tabelle mit DEFAULT-Wert
|
||
-- `init.sql`: Migration für bestehende Datenbanken
|
||
-- `app.py`: Entfernt explizites created_at aus batch_licenses()
|
||
-
|
||
-**Status:**
|
||
-- ✅ Alle Lizenzen haben nun automatisch einen Erstellungszeitstempel
|
||
-- ✅ Batch-Generierung funktioniert wieder
|
||
-- ✅ Konsistente Zeitstempel für Audit-Zwecke
|
||
-
|
||
-## 2025-06-06: Status "Deaktiviert" für manuell abgeschaltete Lizenzen
|
||
-
|
||
-**Problem:**
|
||
-- Dashboard zeigte nur "aktiv" und "abgelaufen" als Status
|
||
-- Manuell deaktivierte Lizenzen (is_active = FALSE) wurden nicht korrekt angezeigt
|
||
-- Filter für "inactive" existierte, aber Status wurde nicht richtig berechnet
|
||
-
|
||
-**Lösung:**
|
||
-1. **Status-Berechnung erweitert:**
|
||
- - CASE-Statement prüft zuerst `is_active = FALSE`
|
||
- - Status "deaktiviert" wird vor anderen Status geprüft
|
||
- - Reihenfolge: deaktiviert → abgelaufen → läuft bald ab → aktiv
|
||
-2. **Dashboard-Statistik erweitert:**
|
||
- - Neue Zählung für deaktivierte Lizenzen
|
||
- - Variable `inactive_licenses` im stats Dictionary
|
||
-
|
||
-**Änderungen:**
|
||
-- `app.py`: Dashboard - Status-Berechnung für letzte 5 Lizenzen
|
||
-- `app.py`: Lizenzübersicht - Status-Berechnung in der Hauptabfrage
|
||
-- `app.py`: Export - Status-Berechnung für CSV/Excel Export
|
||
-- `app.py`: Dashboard - Neue Statistik für deaktivierte Lizenzen
|
||
-
|
||
-**Status:**
|
||
-- ✅ "Deaktiviert" wird korrekt als Status angezeigt
|
||
-- ✅ Dashboard zeigt Anzahl deaktivierter Lizenzen
|
||
-- ✅ Export enthält korrekten Status
|
||
-- ✅ Konsistente Status-Anzeige überall
|
||
-
|
||
-## 2025-06-08: SSL-Sicherheit verbessert - Chrome Warnung behoben
|
||
-
|
||
-**Problem:**
|
||
-- Chrome zeigte Warnung "Die Verbindung zu dieser Website ist nicht sicher"
|
||
-- Nginx erlaubte schwache Cipher Suites (WEAK) ohne Perfect Forward Secrecy
|
||
-- Veraltete SSL-Konfiguration mit `ssl_ciphers HIGH:!aNULL:!MD5;`
|
||
-
|
||
-**Lösung:**
|
||
-1. **Moderne Cipher Suite Konfiguration:**
|
||
- - Nur sichere ECDHE und DHE Cipher Suites
|
||
- - Entfernung aller RSA-only Cipher Suites
|
||
- - Perfect Forward Secrecy für alle Verbindungen
|
||
-2. **SSL-Optimierungen:**
|
||
- - Session Cache aktiviert (1 Tag Timeout)
|
||
- - OCSP Stapling für bessere Performance
|
||
- - DH Parameters (2048 bit) für zusätzliche Sicherheit
|
||
-3. **Resolver-Konfiguration:**
|
||
- - Google DNS Server für OCSP Stapling
|
||
-
|
||
-**Änderungen:**
|
||
-- `v2_nginx/nginx.conf`: Komplett überarbeitete SSL-Konfiguration
|
||
-- `v2_nginx/ssl/dhparam.pem`: Neue 2048-bit DH Parameters generiert
|
||
-- `v2_nginx/Dockerfile`: COPY Befehl für dhparam.pem hinzugefügt
|
||
-
|
||
-**Status:**
|
||
-- ✅ Nur noch sichere Cipher Suites aktiv
|
||
-- ✅ Perfect Forward Secrecy gewährleistet
|
||
-- ✅ OCSP Stapling aktiviert
|
||
-- ✅ Chrome Sicherheitswarnung behoben
|
||
-
|
||
-**Hinweis:** Nach dem Rebuild des nginx Containers wird die Verbindung als sicher angezeigt.
|
||
-
|
||
-## 2025-06-08: CAPTCHA-Login-Bug behoben
|
||
-
|
||
-**Problem:**
|
||
-- Nach 2 fehlgeschlagenen Login-Versuchen wurde CAPTCHA angezeigt
|
||
-- Da keine CAPTCHA-Keys konfiguriert waren (für PoC), konnte man sich nicht mehr einloggen
|
||
-- Selbst mit korrektem Passwort war Login blockiert
|
||
-- Fehlermeldung "CAPTCHA ERFORDERLICH!" erschien immer
|
||
-
|
||
-**Lösung:**
|
||
-1. **CAPTCHA-Prüfung nur wenn Keys vorhanden:**
|
||
- - `recaptcha_site_key` wird vor CAPTCHA-Prüfung geprüft
|
||
- - Wenn keine Keys konfiguriert → kein CAPTCHA-Check
|
||
- - CAPTCHA wird nur angezeigt wenn Keys existieren
|
||
-2. **Template-Anpassungen:**
|
||
- - login.html zeigt CAPTCHA nur wenn `recaptcha_site_key` vorhanden
|
||
- - Kein Test-Key mehr als Fallback
|
||
-3. **Konsistente Logik:**
|
||
- - show_captcha prüft jetzt auch ob Keys vorhanden sind
|
||
- - Bei GET und POST Requests gleiche Logik
|
||
-
|
||
-**Änderungen:**
|
||
-- `v2_adminpanel/app.py`: CAPTCHA-Check nur wenn `RECAPTCHA_SITE_KEY` existiert
|
||
-- `v2_adminpanel/templates/login.html`: CAPTCHA nur anzeigen wenn Keys vorhanden
|
||
-
|
||
-**Status:**
|
||
-- ✅ Login funktioniert wieder nach 2+ Fehlversuchen
|
||
-- ✅ CAPTCHA wird nur angezeigt wenn Keys konfiguriert sind
|
||
-- ✅ Für PoC-Phase ohne CAPTCHA nutzbar
|
||
-- ✅ Produktiv-ready wenn CAPTCHA-Keys eingetragen werden
|
||
-
|
||
-### 2025-06-08: Zeitzone auf Europe/Berlin umgestellt
|
||
-
|
||
-**Problem:**
|
||
-- Alle Zeitstempel wurden in UTC gespeichert und angezeigt
|
||
-- Backup-Dateinamen zeigten UTC-Zeit statt deutsche Zeit
|
||
-- Verwirrung bei Zeitangaben im Admin Panel und Logs
|
||
-
|
||
-**Lösung:**
|
||
-1. **Docker Container Zeitzone konfiguriert:**
|
||
- - Alle Dockerfiles mit `TZ=Europe/Berlin` und tzdata Installation
|
||
- - PostgreSQL mit `PGTZ=Europe/Berlin` für Datenbank-Zeitzone
|
||
- - Explizite Zeitzone-Dateien in /etc/localtime und /etc/timezone
|
||
-
|
||
-2. **Python Code angepasst:**
|
||
- - Import von `zoneinfo.ZoneInfo` für Zeitzonenunterstützung
|
||
- - Alle `datetime.now()` Aufrufe mit `ZoneInfo("Europe/Berlin")`
|
||
- - `.replace(tzinfo=None)` für Kompatibilität mit timezone-unaware Timestamps
|
||
-
|
||
-3. **PostgreSQL Konfiguration:**
|
||
- - `SET timezone = 'Europe/Berlin';` in init.sql
|
||
- - Umgebungsvariablen TZ und PGTZ in docker-compose.yaml
|
||
-
|
||
-4. **docker-compose.yaml erweitert:**
|
||
- - `TZ: Europe/Berlin` für alle Services
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/Dockerfile`: Zeitzone und tzdata hinzugefügt
|
||
-- `v2_postgres/Dockerfile`: Zeitzone und tzdata hinzugefügt
|
||
-- `v2_nginx/Dockerfile`: Zeitzone und tzdata hinzugefügt
|
||
-- `v2_lizenzserver/Dockerfile`: Zeitzone und tzdata hinzugefügt
|
||
-- `v2_adminpanel/app.py`: 14 datetime.now() Aufrufe mit Zeitzone versehen
|
||
-- `v2_adminpanel/init.sql`: PostgreSQL Zeitzone gesetzt
|
||
-- `v2/docker-compose.yaml`: TZ Environment-Variable für alle Services
|
||
-
|
||
-**Ergebnis:**
|
||
-- ✅ Alle neuen Zeitstempel werden in deutscher Zeit (Europe/Berlin) gespeichert
|
||
-- ✅ Backup-Dateinamen zeigen korrekte deutsche Zeit
|
||
-- ✅ Admin Panel zeigt alle Zeiten in deutscher Zeitzone
|
||
-- ✅ Automatische Anpassung bei Sommer-/Winterzeit
|
||
-- ✅ Konsistente Zeitangaben über alle Komponenten
|
||
-
|
||
-**Hinweis:** Nach diesen Änderungen müssen die Docker Container neu gebaut werden:
|
||
-```bash
|
||
-docker-compose down
|
||
-docker-compose build
|
||
-docker-compose up -d
|
||
-```
|
||
-
|
||
-### 2025-06-08: Zeitzone-Fix - PostgreSQL Timestamps
|
||
-
|
||
-**Problem nach erster Implementierung:**
|
||
-- Trotz Zeitzoneneinstellung wurden Zeiten immer noch in UTC angezeigt
|
||
-- Grund: PostgreSQL Tabellen verwendeten `TIMESTAMP WITHOUT TIME ZONE`
|
||
-
|
||
-**Zusätzliche Lösung:**
|
||
-1. **Datenbankschema angepasst:**
|
||
- - Alle `TIMESTAMP` Spalten auf `TIMESTAMP WITH TIME ZONE` geändert
|
||
- - Betrifft: created_at, timestamp, started_at, ended_at, last_heartbeat, etc.
|
||
- - Migration für bestehende Datenbanken berücksichtigt
|
||
-
|
||
-2. **SQL-Abfragen vereinfacht:**
|
||
- - `AT TIME ZONE 'Europe/Berlin'` nicht mehr nötig
|
||
- - PostgreSQL handhabt Zeitzonenkonvertierung automatisch
|
||
-
|
||
-**Geänderte Datei:**
|
||
-- `v2_adminpanel/init.sql`: Alle TIMESTAMP Felder mit WITH TIME ZONE
|
||
-
|
||
-**Wichtig:** Bei bestehenden Installationen muss die Datenbank neu initialisiert oder manuell migriert werden:
|
||
-```sql
|
||
-ALTER TABLE customers ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE;
|
||
-ALTER TABLE licenses ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE;
|
||
-ALTER TABLE sessions ALTER COLUMN started_at TYPE TIMESTAMP WITH TIME ZONE;
|
||
-ALTER TABLE sessions ALTER COLUMN last_heartbeat TYPE TIMESTAMP WITH TIME ZONE;
|
||
-ALTER TABLE sessions ALTER COLUMN ended_at TYPE TIMESTAMP WITH TIME ZONE;
|
||
-ALTER TABLE audit_log ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE;
|
||
-ALTER TABLE backup_history ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE;
|
||
-ALTER TABLE login_attempts ALTER COLUMN first_attempt TYPE TIMESTAMP WITH TIME ZONE;
|
||
-ALTER TABLE login_attempts ALTER COLUMN last_attempt TYPE TIMESTAMP WITH TIME ZONE;
|
||
-ALTER TABLE login_attempts ALTER COLUMN blocked_until TYPE TIMESTAMP WITH TIME ZONE;
|
||
-```
|
||
-
|
||
-### 2025-06-08: UI/UX Überarbeitung - Phase 1 (Navigation)
|
||
-
|
||
-**Problem:**
|
||
-- Inkonsistente Navigation zwischen verschiedenen Seiten
|
||
-- Zu viele Navigationspunkte im Dashboard
|
||
-- Verwirrende Benutzerführung
|
||
-
|
||
-**Lösung:**
|
||
-1. **Dashboard vereinfacht:**
|
||
- - Nur noch 3 Buttons: Neue Lizenz, Batch-Lizenzen, Log
|
||
- - Statistik-Karten wurden klickbar gemacht (verlinken zu jeweiligen Seiten)
|
||
- - "Audit" wurde zu "Log" umbenannt
|
||
-
|
||
-2. **Navigation konsistent gemacht:**
|
||
- - Navbar-Brand "AccountForger - Admin Panel" ist jetzt klickbar und führt zum Dashboard
|
||
- - Keine Log-Links mehr in Unterseiten
|
||
- - Konsistente "Dashboard" Buttons in allen Unterseiten
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/templates/base.html`: Navbar-Brand klickbar gemacht
|
||
-- `v2_adminpanel/templates/dashboard.html`: Navigation reduziert, Karten klickbar
|
||
-- `v2_adminpanel/templates/*.html`: Konsistente Dashboard-Links
|
||
-
|
||
-### 2025-06-08: UI/UX Überarbeitung - Phase 2 (Visuelle Verbesserungen)
|
||
-
|
||
-**Implementierte Verbesserungen:**
|
||
-1. **Größere Icons in Statistik-Karten:**
|
||
- - Icon-Größe auf 3rem erhöht
|
||
- - Bessere visuelle Hierarchie
|
||
-
|
||
-2. **Donut-Chart für Lizenzen:**
|
||
- - Chart.js Integration für Lizenzstatistik
|
||
- - Zeigt Verhältnis Aktiv/Abgelaufen
|
||
- - UPDATE: Später wieder entfernt auf Benutzerwunsch
|
||
-
|
||
-3. **Pulse-Effekt für aktive Sessions:**
|
||
- - CSS-Animation für aktive Sessions
|
||
- - Visueller Indikator für Live-Aktivität
|
||
-
|
||
-4. **Progress-Bar für Backup-Status:**
|
||
- - Zeigt visuell den Erfolg des letzten Backups
|
||
- - Inkl. Dateigröße und Dauer
|
||
-
|
||
-5. **Konsistente Farbcodierung:**
|
||
- - CSS-Variablen für Statusfarben
|
||
- - Globale Klassen für konsistente Darstellung
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/templates/base.html`: Globale CSS-Variablen und Statusklassen
|
||
-- `v2_adminpanel/templates/dashboard.html`: Visuelle Verbesserungen implementiert
|
||
-
|
||
-### 2025-06-08: UI/UX Überarbeitung - Phase 3 (Tabellen-Optimierungen)
|
||
-
|
||
-**Problem:**
|
||
-- Tabellen waren schwer zu navigieren bei vielen Einträgen
|
||
-- Keine Möglichkeit für Bulk-Operationen
|
||
-- Umständliches Kopieren von Lizenzschlüsseln
|
||
-
|
||
-**Lösung:**
|
||
-1. **Sticky Headers:**
|
||
- - Tabellenköpfe bleiben beim Scrollen sichtbar
|
||
- - CSS-Klasse `.table-sticky` mit `position: sticky`
|
||
-
|
||
-2. **Inline-Actions:**
|
||
- - Copy-Button direkt neben Lizenzschlüsseln
|
||
- - Toggle-Switches für Aktiv/Inaktiv-Status
|
||
- - Visuelles Feedback bei Aktionen
|
||
-
|
||
-3. **Bulk-Actions:**
|
||
- - Checkboxen für Mehrfachauswahl
|
||
- - "Select All" Funktionalität
|
||
- - Bulk-Actions Bar mit Aktivieren/Deaktivieren/Löschen
|
||
- - JavaScript für dynamische Anzeige
|
||
-
|
||
-4. **API-Endpoints hinzugefügt:**
|
||
- - `/api/license/<id>/toggle` - Toggle einzelner Lizenzstatus
|
||
- - `/api/licenses/bulk-activate` - Mehrere Lizenzen aktivieren
|
||
- - `/api/licenses/bulk-deactivate` - Mehrere Lizenzen deaktivieren
|
||
- - `/api/licenses/bulk-delete` - Mehrere Lizenzen löschen
|
||
-
|
||
-5. **Beispieldaten eingefügt:**
|
||
- - 15 Testkunden
|
||
- - 18 Lizenzen (verschiedene Status)
|
||
- - Sessions, Audit-Logs, Login-Attempts
|
||
- - Backup-Historie
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/templates/base.html`: CSS für Sticky-Tables und Bulk-Actions
|
||
-- `v2_adminpanel/templates/licenses.html`: Komplette Tabellen-Überarbeitung
|
||
-- `v2_adminpanel/app.py`: 4 neue API-Endpoints für Toggle und Bulk-Operationen
|
||
-- `v2_adminpanel/sample_data.sql`: Umfangreiche Testdaten erstellt
|
||
-
|
||
-**Bugfix:**
|
||
-- API-Endpoints versuchten `updated_at` zu setzen, obwohl die Spalte nicht existiert
|
||
-- Entfernt aus allen 3 betroffenen Endpoints
|
||
-
|
||
-**Status:**
|
||
-- ✅ Sticky Headers funktionieren
|
||
-- ✅ Copy-Buttons mit Clipboard-API
|
||
-- ✅ Toggle-Switches ändern Lizenzstatus
|
||
-- ✅ Bulk-Operationen vollständig implementiert
|
||
-- ✅ Testdaten erfolgreich eingefügt
|
||
-
|
||
-### 2025-06-08: UI/UX Überarbeitung - Phase 4 (Sortierbare Tabellen)
|
||
-
|
||
-**Problem:**
|
||
-- Keine Möglichkeit, Tabellen nach verschiedenen Spalten zu sortieren
|
||
-- Besonders bei großen Datenmengen schwer zu navigieren
|
||
-
|
||
-**Lösung - Hybrid-Ansatz:**
|
||
-1. **Client-seitige Sortierung für kleine Tabellen:**
|
||
- - Dashboard (3 kleine Übersichtstabellen)
|
||
- - Blocked IPs (typischerweise wenige Einträge)
|
||
- - Backups (begrenzte Anzahl)
|
||
- - JavaScript-basierte Sortierung ohne Reload
|
||
-
|
||
-2. **Server-seitige Sortierung für große Tabellen:**
|
||
- - Licenses (potenziell tausende Einträge)
|
||
- - Customers (viele Kunden möglich)
|
||
- - Audit Log (wächst kontinuierlich)
|
||
- - Sessions (viele aktive/beendete Sessions)
|
||
- - URL-Parameter für Sortierung mit SQL ORDER BY
|
||
-
|
||
-**Implementierung:**
|
||
-1. **Client-seitige Sortierung:**
|
||
- - Generische JavaScript-Funktion in base.html
|
||
- - CSS-Klasse `.sortable-table` für betroffene Tabellen
|
||
- - Sortier-Indikatoren (↑↓↕) bei Hover/Active
|
||
- - Unterstützung für Text, Zahlen und deutsche Datumsformate
|
||
-
|
||
-2. **Server-seitige Sortierung:**
|
||
- - Query-Parameter `sort` und `order` in Routes
|
||
- - Whitelist für erlaubte Sortierfelder (SQL-Injection-Schutz)
|
||
- - Makro-Funktionen für sortierbare Header
|
||
- - Sortier-Parameter in Pagination-Links erhalten
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/templates/base.html`: CSS und JavaScript für Sortierung
|
||
-- `v2_adminpanel/templates/dashboard.html`: Client-seitige Sortierung
|
||
-- `v2_adminpanel/templates/blocked_ips.html`: Client-seitige Sortierung
|
||
-- `v2_adminpanel/templates/backups.html`: Client-seitige Sortierung
|
||
-- `v2_adminpanel/templates/licenses.html`: Server-seitige Sortierung
|
||
-- `v2_adminpanel/templates/customers.html`: Server-seitige Sortierung
|
||
-- `v2_adminpanel/templates/audit_log.html`: Server-seitige Sortierung
|
||
-- `v2_adminpanel/templates/sessions.html`: Server-seitige Sortierung (2 Tabellen)
|
||
-- `v2_adminpanel/app.py`: 4 Routes erweitert für Sortierung
|
||
-
|
||
-**Besonderheiten:**
|
||
-- Sessions-Seite hat zwei unabhängige Tabellen mit eigenen Sortierparametern
|
||
-- Intelligente Datentyp-Erkennung (numeric, date) für korrekte Sortierung
|
||
-- Visuelle Sortier-Indikatoren zeigen aktuelle Sortierung
|
||
-- Alle anderen Filter und Suchparameter bleiben bei Sortierung erhalten
|
||
-
|
||
-**Status:**
|
||
-- ✅ Client-seitige Sortierung für kleine Tabellen
|
||
-- ✅ Server-seitige Sortierung für große Tabellen
|
||
-- ✅ Sortier-Indikatoren und visuelle Rückmeldung
|
||
-- ✅ SQL-Injection-Schutz durch Whitelisting
|
||
-- ✅ Vollständige Integration mit bestehenden Features
|
||
-
|
||
-### 2025-06-08: Bugfix - Sortierlogik korrigiert
|
||
-
|
||
-**Problem:**
|
||
-- Sortierung funktionierte nicht korrekt
|
||
-- Beim Klick auf Spaltenköpfe wurde immer absteigend sortiert
|
||
-- Toggle zwischen ASC/DESC funktionierte nicht
|
||
-
|
||
-**Ursachen:**
|
||
-1. **Falsche Bedingungslogik**: Die ursprüngliche Implementierung verwendete eine fehlerhafte Ternär-Bedingung
|
||
-2. **Berechnete Felder**: Das 'status' Feld in der Lizenztabelle konnte nicht direkt sortiert werden
|
||
-
|
||
-**Lösung:**
|
||
-1. **Sortierlogik korrigiert:**
|
||
- - Bei neuer Spalte: Immer aufsteigend (ASC) beginnen
|
||
- - Bei gleicher Spalte: Toggle zwischen ASC und DESC
|
||
- - Implementiert durch bedingte Links in den Makros
|
||
-
|
||
-2. **Spezialbehandlung für berechnete Felder:**
|
||
- - Status-Feld verwendet CASE-Statement in ORDER BY
|
||
- - Wiederholt die gleiche Logik wie im SELECT
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/templates/licenses.html`: Sortierlogik korrigiert
|
||
-- `v2_adminpanel/templates/customers.html`: Sortierlogik korrigiert
|
||
-- `v2_adminpanel/templates/audit_log.html`: Sortierlogik korrigiert
|
||
-- `v2_adminpanel/templates/sessions.html`: Sortierlogik für beide Tabellen korrigiert
|
||
-- `v2_adminpanel/app.py`: Spezialbehandlung für Status-Feld in licenses Route
|
||
-
|
||
-**Verhalten nach Fix:**
|
||
-- ✅ Erster Klick auf Spalte: Aufsteigend sortieren
|
||
-- ✅ Zweiter Klick: Absteigend sortieren
|
||
-- ✅ Weitere Klicks: Toggle zwischen ASC/DESC
|
||
-- ✅ Sortierung funktioniert korrekt mit Pagination und Filtern
|
||
-
|
||
-### 2025-06-09: Port 8443 geschlossen - API nur noch über Nginx
|
||
-
|
||
-**Problem:**
|
||
-- Doppelte Verfügbarkeit des License Servers (Port 8443 + Nginx) machte keinen Sinn
|
||
-- Inkonsistente Sicherheitskonfiguration (Nginx hatte Security Headers, Port 8443 nicht)
|
||
-- Doppelte SSL-Konfiguration nötig
|
||
-- Verwirrung welcher Zugangsweg genutzt werden soll
|
||
-
|
||
-**Lösung:**
|
||
-- Port-Mapping für License Server in docker-compose.yaml entfernt
|
||
-- API nur noch über Nginx erreichbar: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
-- Interne Kommunikation zwischen Nginx und License Server bleibt bestehen
|
||
-
|
||
-**Vorteile:**
|
||
-- ✅ Einheitliche Sicherheitskonfiguration (Security Headers, HSTS)
|
||
-- ✅ Zentrale SSL-Verwaltung nur in Nginx
|
||
-- ✅ Möglichkeit für Rate Limiting und zentrales Logging
|
||
-- ✅ Keine zusätzlichen offenen Ports (nur 80/443)
|
||
-- ✅ Professionellere API-URL ohne Port-Angabe
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2/docker-compose.yaml`: Port-Mapping "8443:8443" entfernt
|
||
-
|
||
-**Hinweis für Client-Software:**
|
||
-- API-Endpunkte sind weiterhin unter https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com erreichbar
|
||
-- Keine Änderung der API-URLs nötig, nur Port 8443 ist nicht mehr direkt zugänglich
|
||
-
|
||
-**Status:**
|
||
-- ✅ Port 8443 geschlossen
|
||
-- ✅ API nur noch über Nginx Reverse Proxy erreichbar
|
||
-- ✅ Sicherheit erhöht durch zentrale Verwaltung
|
||
-
|
||
-### 2025-06-09: Live-Filtering implementiert
|
||
-
|
||
-**Problem:**
|
||
-- Benutzer mussten immer auf "Filter anwenden" klicken
|
||
-- Umständliche Bedienung, besonders bei mehreren Filterkriterien
|
||
-- Nicht zeitgemäße User Experience
|
||
-
|
||
-**Lösung:**
|
||
-- JavaScript Event-Listener für automatisches Filtern
|
||
-- Text-Eingaben: 300ms Debouncing (verzögerte Suche nach Tipp-Pause)
|
||
-- Dropdowns: Sofortiges Filtern bei Änderung
|
||
-- "Filter anwenden" Button entfernt, nur "Zurücksetzen" bleibt
|
||
-
|
||
-**Implementierte Live-Filter:**
|
||
-1. **Lizenzübersicht** (licenses.html):
|
||
- - Suchfeld mit Debouncing
|
||
- - Typ-Dropdown (Vollversion/Testversion)
|
||
- - Status-Dropdown (Aktiv/Ablaufend/Abgelaufen/Deaktiviert)
|
||
-
|
||
-2. **Kundenübersicht** (customers.html):
|
||
- - Suchfeld mit Debouncing
|
||
- - "Suchen" Button entfernt
|
||
-
|
||
-3. **Audit-Log** (audit_log.html):
|
||
- - Benutzer-Textfeld mit Debouncing
|
||
- - Aktion-Dropdown
|
||
- - Entität-Dropdown
|
||
-
|
||
-**Technische Details:**
|
||
-- `addEventListener('input')` für Textfelder
|
||
-- `addEventListener('change')` für Select-Elemente
|
||
-- `setTimeout()` mit 300ms für Debouncing
|
||
-- Automatisches `form.submit()` bei Änderungen
|
||
-
|
||
-**Vorteile:**
|
||
-- ✅ Schnellere und intuitivere Bedienung
|
||
-- ✅ Weniger Klicks erforderlich
|
||
-- ✅ Moderne User Experience
|
||
-- ✅ Besonders hilfreich bei komplexen Filterkriterien
|
||
-
|
||
-**Status:**
|
||
-- ✅ Live-Filtering auf allen Hauptseiten implementiert
|
||
-- ✅ Debouncing verhindert zu viele Server-Requests
|
||
-- ✅ Zurücksetzen-Button bleibt für schnelles Löschen aller Filter
|
||
-
|
||
-### 2025-06-09: Resource Pool System implementiert (Phase 1 & 2)
|
||
-
|
||
-**Ziel:**
|
||
-Ein Pool-System für Domains, IPv4-Adressen und Telefonnummern, wobei bei jeder Lizenzerstellung 1-10 Ressourcen pro Typ zugewiesen werden. Ressourcen haben 3 Status: available, allocated, quarantine.
|
||
-
|
||
-**Phase 1 - Datenbank-Schema (✅ Abgeschlossen):**
|
||
-1. **Neue Tabellen erstellt:**
|
||
- - `resource_pools` - Haupttabelle für alle Ressourcen
|
||
- - `resource_history` - Vollständige Historie aller Aktionen
|
||
- - `resource_metrics` - Performance-Tracking und ROI-Berechnung
|
||
- - `license_resources` - Zuordnung zwischen Lizenzen und Ressourcen
|
||
-
|
||
-2. **Erweiterte licenses Tabelle:**
|
||
- - `domain_count`, `ipv4_count`, `phone_count` Spalten hinzugefügt
|
||
- - Constraints: 0-10 pro Resource-Typ
|
||
-
|
||
-3. **Indizes für Performance:**
|
||
- - Status, Type, Allocated License, Quarantine Date
|
||
-
|
||
-**Phase 2 - Backend-Implementierung (✅ Abgeschlossen):**
|
||
-1. **Resource Management Routes:**
|
||
- - `/resources` - Hauptübersicht mit Statistiken
|
||
- - `/resources/add` - Bulk-Import von Ressourcen
|
||
- - `/resources/quarantine/<id>` - Ressourcen sperren
|
||
- - `/resources/release` - Quarantäne aufheben
|
||
- - `/resources/history/<id>` - Komplette Historie
|
||
- - `/resources/metrics` - Performance Dashboard
|
||
- - `/resources/report` - Report-Generator
|
||
-
|
||
-2. **API-Endpunkte:**
|
||
- - `/api/resources/allocate` - Ressourcen-Zuweisung
|
||
- - `/api/resources/check-availability` - Verfügbarkeit prüfen
|
||
-
|
||
-3. **Integration in Lizenzerstellung:**
|
||
- - `create_license()` erweitert um Resource-Allocation
|
||
- - `batch_licenses()` mit Ressourcen-Prüfung für gesamten Batch
|
||
- - Transaktionale Sicherheit bei Zuweisung
|
||
-
|
||
-4. **Dashboard-Integration:**
|
||
- - Resource-Statistiken in Dashboard eingebaut
|
||
- - Warning-Level basierend auf Verfügbarkeit
|
||
-
|
||
-5. **Navigation erweitert:**
|
||
- - Resources-Link in Navbar hinzugefügt
|
||
-
|
||
-**Was noch zu tun ist:**
|
||
-
|
||
-### Phase 3 - UI-Komponenten (🔄 Ausstehend):
|
||
-1. **Templates erstellen:**
|
||
- - `resources.html` - Hauptübersicht mit Drag&Drop
|
||
- - `add_resources.html` - Formular für Bulk-Import
|
||
- - `resource_history.html` - Historie-Anzeige
|
||
- - `resource_metrics.html` - Performance Dashboard
|
||
-
|
||
-2. **Formulare erweitern:**
|
||
- - `index.html` - Resource-Dropdowns hinzufügen
|
||
- - `batch_form.html` - Resource-Dropdowns hinzufügen
|
||
-
|
||
-3. **Dashboard-Widget:**
|
||
- - Resource Pool Statistik mit Ampelsystem
|
||
- - Warnung bei niedrigem Bestand
|
||
-
|
||
-### Phase 4 - Erweiterte Features (🔄 Ausstehend):
|
||
-1. **Quarantäne-Workflow:**
|
||
- - Gründe: abuse, defect, maintenance, blacklisted, expired
|
||
- - Automatische Tests vor Freigabe
|
||
- - Genehmigungsprozess
|
||
-
|
||
-2. **Performance-Metrics:**
|
||
- - Täglicher Cronjob für Metriken
|
||
- - ROI-Berechnung
|
||
- - Issue-Tracking
|
||
-
|
||
-3. **Report-Generator:**
|
||
- - Auslastungsreport
|
||
- - Performance-Report
|
||
- - Compliance-Report
|
||
-
|
||
-### Phase 5 - Backup erweitern (🔄 Ausstehend):
|
||
-- Neue Tabellen in Backup einbeziehen:
|
||
- - resource_pools
|
||
- - resource_history
|
||
- - resource_metrics
|
||
- - license_resources
|
||
-
|
||
-### Phase 6 - Testing & Migration (🔄 Ausstehend):
|
||
-1. **Test-Daten generieren:**
|
||
- - 500 Test-Domains
|
||
- - 200 Test-IPs
|
||
- - 100 Test-Telefonnummern
|
||
-
|
||
-2. **Migrations-Script:**
|
||
- - Bestehende Lizenzen auf default resource_count setzen
|
||
-
|
||
-### Phase 7 - Dokumentation (🔄 Ausstehend):
|
||
-- API-Dokumentation für License Server
|
||
-- Admin-Handbuch für Resource Management
|
||
-
|
||
-**Technische Details:**
|
||
-- 3-Status-System: available/allocated/quarantine
|
||
-- Transaktionale Ressourcen-Zuweisung mit FOR UPDATE Lock
|
||
-- Vollständige Historie mit IP-Tracking
|
||
-- Drag&Drop UI für Resource-Management geplant
|
||
-- Automatische Warnung bei < 50 verfügbaren Ressourcen
|
||
-
|
||
-**Status:**
|
||
-- ✅ Datenbank-Schema komplett
|
||
-- ✅ Backend-Routen implementiert
|
||
-- ✅ Integration in Lizenzerstellung
|
||
-- ❌ UI-Templates fehlen noch
|
||
-- ❌ Erweiterte Features ausstehend
|
||
-- ❌ Testing und Migration offen
|
||
-
|
||
-### 2025-06-09: Resource Pool System UI-Implementierung (Phase 3 & 4)
|
||
-
|
||
-**Phase 3 - UI-Komponenten (✅ Abgeschlossen):**
|
||
-
|
||
-1. **Neue Templates erstellt:**
|
||
- - `resources.html` - Hauptübersicht mit Statistiken, Filter, Live-Suche, Pagination
|
||
- - `add_resources.html` - Bulk-Import Formular mit Validierung
|
||
- - `resource_history.html` - Timeline-Ansicht der Historie mit Details
|
||
- - `resource_metrics.html` - Performance Dashboard mit Charts
|
||
- - `resource_report.html` - Report-Generator UI
|
||
-
|
||
-2. **Erweiterte Formulare:**
|
||
- - `index.html` - Resource-Count Dropdowns (0-10) mit Live-Verfügbarkeitsprüfung
|
||
- - `batch_form.html` - Resource-Count mit Batch-Berechnung (zeigt Gesamtbedarf)
|
||
-
|
||
-3. **Dashboard-Widget:**
|
||
- - Resource Pool Statistik mit Ampelsystem implementiert
|
||
- - Zeigt verfügbare/zugeteilte/quarantäne Ressourcen
|
||
- - Warnung bei niedrigem Bestand (<50)
|
||
- - Fortschrittsbalken für visuelle Darstellung
|
||
-
|
||
-4. **Backend-Anpassungen:**
|
||
- - `resource_history` Route korrigiert für Object-Style Template-Zugriff
|
||
- - `resources_metrics` Route vollständig implementiert mit Charts-Daten
|
||
- - `resources_report` Route erweitert für Template-Anzeige und Downloads
|
||
- - Dashboard erweitert um Resource-Statistiken
|
||
-
|
||
-**Phase 4 - Erweiterte Features (✅ Teilweise):**
|
||
-1. **Report-Generator:**
|
||
- - Template für Report-Auswahl erstellt
|
||
- - 4 Report-Typen: Usage, Performance, Compliance, Inventory
|
||
- - Export als Excel, CSV oder PDF-Vorschau
|
||
- - Zeitraum-Auswahl mit Validierung
|
||
-
|
||
-**Technische Details der Implementierung:**
|
||
-- Live-Filtering ohne Reload durch JavaScript
|
||
-- AJAX-basierte Verfügbarkeitsprüfung
|
||
-- Bootstrap 5 für konsistentes Design
|
||
-- Chart.js für Metriken-Visualisierung
|
||
-- Responsives Design für alle Templates
|
||
-- Copy-to-Clipboard für Resource-Werte
|
||
-- Modal-Dialoge für Quarantäne-Aktionen
|
||
-
|
||
-**Was noch fehlt:**
|
||
-
|
||
-### Phase 5 - Backup erweitern (🔄 Ausstehend):
|
||
-- Resource-Tabellen in pg_dump einbeziehen:
|
||
- - resource_pools
|
||
- - resource_history
|
||
- - resource_metrics
|
||
- - license_resources
|
||
-
|
||
-### Phase 6 - Testing & Migration (🔄 Ausstehend):
|
||
-1. **Test-Daten generieren:**
|
||
- - Script für 500 Test-Domains
|
||
- - 200 Test-IPv4-Adressen
|
||
- - 100 Test-Telefonnummern
|
||
- - Realistische Verteilung über Status
|
||
-
|
||
-2. **Migrations-Script:**
|
||
- - Bestehende Lizenzen auf Default resource_count setzen
|
||
- - UPDATE licenses SET domain_count=1, ipv4_count=1, phone_count=1 WHERE ...
|
||
-
|
||
-### Phase 7 - Dokumentation (🔄 Ausstehend):
|
||
-- API-Dokumentation für Resource-Endpunkte
|
||
-- Admin-Handbuch für Resource Management
|
||
-- Troubleshooting-Guide
|
||
-
|
||
-**Offene Punkte für Produktion:**
|
||
-1. Drag&Drop für Resource-Verwaltung (Nice-to-have)
|
||
-2. Automatische Quarantäne-Aufhebung nach Zeitablauf
|
||
-3. E-Mail-Benachrichtigungen bei niedrigem Bestand
|
||
-4. API für externe Resource-Prüfung
|
||
-5. Bulk-Delete für Ressourcen
|
||
-6. Resource-Import aus CSV/Excel
|
||
-
|
||
-### 2025-06-09: Resource Pool System finalisiert
|
||
-
|
||
-**Problem:**
|
||
-- Resource Pool System war nur teilweise implementiert
|
||
-- UI-Templates waren vorhanden, aber nicht dokumentiert
|
||
-- Test-Daten und Migration fehlten
|
||
-- Backup-Integration unklar
|
||
-
|
||
-**Analyse und Lösung:**
|
||
-1. **Status-Überprüfung durchgeführt:**
|
||
- - Alle 5 UI-Templates existierten bereits (resources.html, add_resources.html, etc.)
|
||
- - Resource-Dropdowns waren bereits in index.html und batch_form.html integriert
|
||
- - Dashboard-Widget war bereits implementiert
|
||
- - Backup-System inkludiert bereits alle Tabellen (pg_dump ohne -t Parameter)
|
||
-
|
||
-2. **Fehlende Komponenten erstellt:**
|
||
- - Test-Daten Script: `test_data_resources.sql`
|
||
- - 500 Test-Domains (400 verfügbar, 50 zugeteilt, 50 in Quarantäne)
|
||
- - 200 Test-IPv4-Adressen (150 verfügbar, 30 zugeteilt, 20 in Quarantäne)
|
||
- - 100 Test-Telefonnummern (70 verfügbar, 20 zugeteilt, 10 in Quarantäne)
|
||
- - Resource History und Metrics für realistische Daten
|
||
-
|
||
- - Migration Script: `migrate_existing_licenses.sql`
|
||
- - Setzt Default Resource Counts (Vollversion: 2, Testversion: 1, Inaktiv: 0)
|
||
- - Weist automatisch verfügbare Ressourcen zu
|
||
- - Erstellt Audit-Log Einträge
|
||
- - Gibt detaillierten Migrationsbericht aus
|
||
-
|
||
-**Neue Dateien:**
|
||
-- `v2_adminpanel/test_data_resources.sql` - Testdaten für Resource Pool
|
||
-- `v2_adminpanel/migrate_existing_licenses.sql` - Migration für bestehende Lizenzen
|
||
-
|
||
-**Status:**
|
||
-- ✅ Resource Pool System vollständig implementiert und dokumentiert
|
||
-- ✅ Alle UI-Komponenten vorhanden und funktionsfähig
|
||
-- ✅ Integration in Lizenz-Formulare abgeschlossen
|
||
-- ✅ Dashboard-Widget zeigt Resource-Statistiken
|
||
-- ✅ Backup-System inkludiert Resource-Tabellen
|
||
-- ✅ Test-Daten und Migration bereitgestellt
|
||
-
|
||
-**Nächste Schritte:**
|
||
-1. Test-Daten einspielen: `psql -U adminuser -d meinedatenbank -f test_data_resources.sql`
|
||
-2. Migration ausführen: `psql -U adminuser -d meinedatenbank -f migrate_existing_licenses.sql`
|
||
-3. License Server API implementieren (Hauptaufgabe)
|
||
-
|
||
-### 2025-06-09: Bugfix - Resource Pool Tabellen fehlten
|
||
-
|
||
-**Problem:**
|
||
-- Admin Panel zeigte "Internal Server Error"
|
||
-- Dashboard Route versuchte auf `resource_pools` Tabelle zuzugreifen
|
||
-- Tabelle existierte nicht in der Datenbank
|
||
-
|
||
-**Ursache:**
|
||
-- Bei bereits existierender Datenbank wird init.sql nicht erneut ausgeführt
|
||
-- Resource Pool Tabellen wurden erst später zum init.sql hinzugefügt
|
||
-- Docker Container verwendeten noch die alte Datenbankstruktur
|
||
-
|
||
-**Lösung:**
|
||
-1. Separates Script `create_resource_tables.sql` erstellt
|
||
-2. Script manuell in der Datenbank ausgeführt
|
||
-3. Alle 4 Resource-Tabellen erfolgreich erstellt:
|
||
- - resource_pools
|
||
- - resource_history
|
||
- - resource_metrics
|
||
- - license_resources
|
||
-
|
||
-**Status:**
|
||
-- ✅ Admin Panel funktioniert wieder
|
||
-- ✅ Dashboard zeigt Resource Pool Statistiken
|
||
-- ✅ Alle Resource-Funktionen verfügbar
|
||
-
|
||
-**Empfehlung für Neuinstallationen:**
|
||
-- Bei frischer Installation funktioniert alles automatisch
|
||
-- Bei bestehenden Installationen: `create_resource_tables.sql` ausführen
|
||
-
|
||
-### 2025-06-09: Navigation vereinfacht
|
||
-
|
||
-**Änderung:**
|
||
-- Navigationspunkte aus der schwarzen Navbar entfernt
|
||
-- Links zu Lizenzen, Kunden, Ressourcen, Sessions, Backups und Log entfernt
|
||
-
|
||
-**Grund:**
|
||
-- Cleaner Look mit nur Logo, Timer und Logout
|
||
-- Alle Funktionen sind weiterhin über das Dashboard erreichbar
|
||
-- Bessere Übersichtlichkeit und weniger Ablenkung
|
||
-
|
||
-**Geänderte Datei:**
|
||
-- `v2_adminpanel/templates/base.html` - Navbar-Links auskommentiert
|
||
-
|
||
-**Status:**
|
||
-- ✅ Navbar zeigt nur noch Logo, Session-Timer und Logout
|
||
-- ✅ Navigation erfolgt über Dashboard und Buttons auf den jeweiligen Seiten
|
||
-- ✅ Alle Funktionen bleiben erreichbar
|
||
-
|
||
-### 2025-06-09: Bugfix - Resource Report Einrückungsfehler
|
||
-
|
||
-**Problem:**
|
||
-- Resource Report Route zeigte "Internal Server Error"
|
||
-- UnboundLocalError: `report_type` wurde verwendet bevor es definiert war
|
||
-
|
||
-**Ursache:**
|
||
-- Fehlerhafte Einrückung in der `resources_report()` Funktion
|
||
-- `elif` und `else` Blöcke waren falsch eingerückt
|
||
-- Variablen wurden außerhalb ihres Gültigkeitsbereichs verwendet
|
||
-
|
||
-**Lösung:**
|
||
-- Korrekte Einrückung für alle Conditional-Blöcke wiederhergestellt
|
||
-- Alle Report-Typen (usage, performance, compliance, inventory) richtig strukturiert
|
||
-- Excel und CSV Export-Code korrekt eingerückt
|
||
-
|
||
-**Geänderte Datei:**
|
||
-- `v2_adminpanel/app.py` - resources_report() Funktion korrigiert
|
||
-
|
||
-**Status:**
|
||
-- ✅ Resource Report funktioniert wieder
|
||
-- ✅ Alle 4 Report-Typen verfügbar
|
||
-- ✅ Export als Excel und CSV möglich
|
||
-
|
||
----
|
||
-
|
||
-## Zusammenfassung der heutigen Arbeiten (2025-06-09)
|
||
-
|
||
-### 1. Resource Pool System Finalisierung
|
||
-- **Ausgangslage**: Resource Pool war nur teilweise dokumentiert
|
||
-- **Überraschung**: UI-Templates waren bereits vorhanden (nicht dokumentiert)
|
||
-- **Ergänzt**:
|
||
- - Test-Daten Script (`test_data_resources.sql`)
|
||
- - Migration Script (`migrate_existing_licenses.sql`)
|
||
-- **Status**: ✅ Vollständig implementiert
|
||
-
|
||
-### 2. Database Migration Bug
|
||
-- **Problem**: Admin Panel zeigte "Internal Server Error"
|
||
-- **Ursache**: Resource Pool Tabellen fehlten in bestehender DB
|
||
-- **Lösung**: Separates Script `create_resource_tables.sql` erstellt
|
||
-- **Status**: ✅ Behoben
|
||
-
|
||
-### 3. UI Cleanup
|
||
-- **Änderung**: Navigation aus Navbar entfernt
|
||
-- **Effekt**: Cleaner Look, Navigation nur über Dashboard
|
||
-- **Status**: ✅ Implementiert
|
||
-
|
||
-### 4. Resource Report Bug
|
||
-- **Problem**: Einrückungsfehler in `resources_report()` Funktion
|
||
-- **Lösung**: Korrekte Einrückung wiederhergestellt
|
||
-- **Status**: ✅ Behoben
|
||
-
|
||
-### Neue Dateien erstellt heute:
|
||
-1. `v2_adminpanel/test_data_resources.sql` - 800 Test-Ressourcen
|
||
-
|
||
-### 2025-06-09: Bugfix - Resource Quarantäne Modal
|
||
-
|
||
-**Problem:**
|
||
-- Quarantäne-Button funktionierte nicht
|
||
-- Modal öffnete sich nicht beim Klick
|
||
-
|
||
-**Ursache:**
|
||
-- Bootstrap 5 vs Bootstrap 4 API-Inkompatibilität
|
||
-- Modal wurde mit Bootstrap 4 Syntax (`modal.modal('show')`) aufgerufen
|
||
-- jQuery wurde nach Bootstrap geladen
|
||
-
|
||
-**Lösung:**
|
||
-1. **JavaScript angepasst:**
|
||
- - Von jQuery Modal-API zu nativer Bootstrap 5 Modal-API gewechselt
|
||
- - `new bootstrap.Modal(element).show()` statt `$(element).modal('show')`
|
||
-
|
||
-2. **HTML-Struktur aktualisiert:**
|
||
- - Modal-Close-Button: `data-bs-dismiss="modal"` statt `data-dismiss="modal"`
|
||
- - `btn-close` Klasse statt custom close button
|
||
- - Form-Klassen: `mb-3` statt `form-group`, `form-select` statt `form-control` für Select
|
||
-
|
||
-3. **Script-Reihenfolge korrigiert:**
|
||
- - jQuery vor Bootstrap laden für korrekte Initialisierung
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/templates/resources.html`
|
||
-- `v2_adminpanel/templates/base.html`
|
||
-
|
||
-**Status:** ✅ Behoben
|
||
-
|
||
-### 2025-06-09: Resource Pool UI Redesign
|
||
-
|
||
-**Ziel:**
|
||
-- Komplettes Redesign des Resource Pool Managements für bessere Benutzerfreundlichkeit
|
||
-- Konsistentes Design mit dem Rest der Anwendung
|
||
-
|
||
-**Durchgeführte Änderungen:**
|
||
-
|
||
-1. **resources.html - Hauptübersicht:**
|
||
- - Moderne Statistik-Karten mit Hover-Effekten
|
||
- - Farbcodierte Progress-Bars mit Tooltips
|
||
- - Verbesserte Tabelle mit Icons und Status-Badges
|
||
- - Live-Filter mit sofortiger Suche
|
||
- - Überarbeitete Quarantäne-Modal für Bootstrap 5
|
||
- - Responsive Design mit Grid-Layout
|
||
-
|
||
-2. **add_resources.html - Ressourcen hinzufügen:**
|
||
- - 3-Schritt Wizard-ähnliches Interface
|
||
- - Visueller Ressourcentyp-Selector mit Icons
|
||
- - Live-Validierung mit Echtzeit-Feedback
|
||
- - Statistik-Anzeige (Gültig/Duplikate/Ungültig)
|
||
- - Formatierte Beispiele mit Erklärungen
|
||
- - Verbesserte Fehlerbehandlung
|
||
-
|
||
-3. **resource_history.html - Historie:**
|
||
- - Zentrierte Resource-Anzeige mit großen Icons
|
||
- - Info-Grid Layout für Details
|
||
- - Modernisierte Timeline mit Hover-Effekten
|
||
- - Farbcodierte Action-Icons
|
||
- - Verbesserte Darstellung von Details
|
||
-
|
||
-4. **resource_metrics.html - Metriken:**
|
||
- - Dashboard-Style Metrik-Karten mit Icon-Badges
|
||
- - Modernisierte Charts mit besseren Farben
|
||
- - Performance-Tabellen mit Progress-Bars
|
||
- - Trend-Indikatoren für Performance
|
||
- - Responsives Grid-Layout
|
||
-
|
||
-**Design-Verbesserungen:**
|
||
-- Konsistente Emoji-Icons für bessere visuelle Kommunikation
|
||
-- Einheitliche Farbgebung (Blau/Lila/Grün für Ressourcentypen)
|
||
-- Card-basiertes Layout mit Schatten und Hover-Effekten
|
||
-- Bootstrap 5 kompatible Komponenten
|
||
-- Verbesserte Typografie und Spacing
|
||
-
|
||
-**Technische Details:**
|
||
-- Bootstrap 5 Modal-API statt jQuery
|
||
-- CSS Grid für responsive Layouts
|
||
-- Moderne Chart.js Konfiguration
|
||
-- Optimierte JavaScript-Validierung
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/templates/resources.html`
|
||
-- `v2_adminpanel/templates/add_resources.html`
|
||
-- `v2_adminpanel/templates/resource_history.html`
|
||
-- `v2_adminpanel/templates/resource_metrics.html`
|
||
-
|
||
-**Status:** ✅ Abgeschlossen
|
||
-
|
||
-### 2025-06-09: Zusammenfassung der heutigen Arbeiten
|
||
-
|
||
-**Durchgeführte Aufgaben:**
|
||
-
|
||
-1. **Quarantäne-Funktion repariert:**
|
||
- - Bootstrap 5 Modal-API implementiert
|
||
- - data-bs-dismiss statt data-dismiss
|
||
- - jQuery vor Bootstrap laden
|
||
-
|
||
-2. **Resource Pool UI komplett überarbeitet:**
|
||
- - Alle 4 Templates modernisiert (resources, add_resources, resource_history, resource_metrics)
|
||
- - Konsistentes Design mit Emoji-Icons
|
||
- - Einheitliche Farbgebung (Blau/Lila/Grün)
|
||
- - Bootstrap 5 kompatible Komponenten
|
||
- - Responsive Grid-Layouts
|
||
-
|
||
-**Aktuelle Projekt-Status:**
|
||
-- ✅ Admin Panel voll funktionsfähig
|
||
-- ✅ Resource Pool Management mit modernem UI
|
||
-- ✅ PostgreSQL mit allen Tabellen
|
||
-- ✅ Nginx Reverse Proxy mit SSL
|
||
-- ❌ Lizenzserver noch nicht implementiert (nur Platzhalter)
|
||
-
|
||
-**Nächste Schritte:**
|
||
-- Lizenzserver implementieren
|
||
-- API-Endpunkte für Lizenzvalidierung
|
||
-- Heartbeat-System für Sessions
|
||
-- Versionsprüfung implementieren
|
||
-1. `v2_adminpanel/templates/base.html` - Navigation entfernt
|
||
-2. `v2_adminpanel/app.py` - Resource Report Einrückung korrigiert
|
||
-3. `JOURNAL.md` - Alle Änderungen dokumentiert
|
||
-
|
||
-### Offene Hauptaufgabe:
|
||
-- **License Server API** - Noch komplett zu implementieren
|
||
- - `/api/version` - Versionscheck
|
||
- - `/api/validate` - Lizenzvalidierung
|
||
- - `/api/heartbeat` - Session-Management
|
||
-
|
||
-### 2025-06-09: Resource Pool Internal Error behoben
|
||
-
|
||
-**Problem:**
|
||
-- Internal Server Error beim Zugriff auf `/resources`
|
||
-- NameError: name 'datetime' is not defined in Template
|
||
-
|
||
-**Ursache:**
|
||
-- Fehlende `datetime` und `timedelta` Objekte im Template-Kontext
|
||
-- Falsche Array-Indizes in resources.html für activity-Daten
|
||
-
|
||
-**Lösung:**
|
||
-1. **app.py (Zeile 2797-2798):**
|
||
- - `datetime=datetime` und `timedelta=timedelta` zu render_template hinzugefügt
|
||
-
|
||
-2. **resources.html (Zeile 484-490):**
|
||
- - Array-Indizes korrigiert:
|
||
- - activity[0] = action
|
||
- - activity[1] = action_by
|
||
- - activity[2] = action_at
|
||
- - activity[3] = resource_type
|
||
- - activity[4] = resource_value
|
||
- - activity[5] = details
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/app.py`
|
||
-- `v2_adminpanel/templates/resources.html`
|
||
-
|
||
-**Status:** ✅ Behoben - Resource Pool funktioniert wieder einwandfrei
|
||
-
|
||
-### 2025-06-09: Passwort-Änderung und 2FA implementiert
|
||
-
|
||
-**Ziel:**
|
||
-- Benutzer können ihr Passwort ändern
|
||
-- Zwei-Faktor-Authentifizierung (2FA) mit TOTP
|
||
-- Komplett kostenlose Lösung ohne externe Services
|
||
-
|
||
-**Implementierte Features:**
|
||
-
|
||
-1. **Datenbank-Erweiterung:**
|
||
- - Neue `users` Tabelle mit Passwort-Hash und 2FA-Feldern
|
||
- - Unterstützung für TOTP-Secrets und Backup-Codes
|
||
- - Migration von Environment-Variablen zu Datenbank
|
||
-
|
||
-2. **Passwort-Management:**
|
||
- - Sichere Passwort-Hashes mit bcrypt
|
||
- - Passwort-Änderung mit Verifikation des alten Passworts
|
||
- - Passwort-Stärke-Indikator im Frontend
|
||
-
|
||
-3. **2FA-Implementation:**
|
||
- - TOTP-basierte 2FA (Google Authenticator, Authy kompatibel)
|
||
- - QR-Code-Generierung für einfaches Setup
|
||
- - 8 Backup-Codes für Notfallzugriff
|
||
- - Backup-Codes als Textdatei downloadbar
|
||
-
|
||
-4. **Neue Routen:**
|
||
- - `/profile` - Benutzerprofil mit Passwort und 2FA-Verwaltung
|
||
- - `/verify-2fa` - 2FA-Verifizierung beim Login
|
||
- - `/profile/setup-2fa` - 2FA-Einrichtung mit QR-Code
|
||
- - `/profile/enable-2fa` - 2FA-Aktivierung
|
||
- - `/profile/disable-2fa` - 2FA-Deaktivierung
|
||
- - `/profile/change-password` - Passwort ändern
|
||
-
|
||
-5. **Sicherheits-Features:**
|
||
- - Fallback zu Environment-Variablen für Rückwärtskompatibilität
|
||
- - Session-Management für 2FA-Verifizierung
|
||
- - Fehlgeschlagene 2FA-Versuche werden protokolliert
|
||
- - Verwendete Backup-Codes werden entfernt
|
||
-
|
||
-**Verwendete Libraries (alle kostenlos):**
|
||
-- `bcrypt` - Passwort-Hashing
|
||
-- `pyotp` - TOTP-Generierung und Verifizierung
|
||
-- `qrcode[pil]` - QR-Code-Generierung
|
||
-
|
||
-**Migration:**
|
||
-- Script `migrate_users.py` erstellt für Migration existierender Benutzer
|
||
-- Erhält bestehende Credentials aus Environment-Variablen
|
||
-- Erstellt Datenbank-Einträge mit gehashten Passwörtern
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/init.sql` - Users-Tabelle hinzugefügt
|
||
-- `v2_adminpanel/requirements.txt` - Neue Dependencies
|
||
-- `v2_adminpanel/app.py` - Auth-Funktionen und neue Routen
|
||
-- `v2_adminpanel/migrate_users.py` - Migrations-Script (neu)
|
||
-- `v2_adminpanel/templates/base.html` - Profil-Link hinzugefügt
|
||
-- `v2_adminpanel/templates/profile.html` - Profil-Seite (neu)
|
||
-- `v2_adminpanel/templates/verify_2fa.html` - 2FA-Verifizierung (neu)
|
||
-- `v2_adminpanel/templates/setup_2fa.html` - 2FA-Setup (neu)
|
||
-- `v2_adminpanel/templates/backup_codes.html` - Backup-Codes Anzeige (neu)
|
||
-
|
||
-**Status:** ✅ Vollständig implementiert
|
||
-
|
||
-### 2025-06-09: Internal Server Error behoben und UI-Design angepasst
|
||
-
|
||
-### 2025-06-09: Journal-Bereinigung und Projekt-Cleanup
|
||
-
|
||
-**Durchgeführte Aufgaben:**
|
||
-
|
||
-1. **Überflüssige SQL-Dateien gelöscht:**
|
||
- - `create_resource_tables.sql` - War nur für Migrations nötig
|
||
- - `migrate_existing_licenses.sql` - Keine alten Installationen vorhanden
|
||
- - `sample_data.sql` - Testdaten nicht mehr benötigt
|
||
- - `test_data_resources.sql` - Testdaten nicht mehr benötigt
|
||
-
|
||
-2. **Journal aktualisiert:**
|
||
- - Veraltete Todo-Liste korrigiert (viele Features bereits implementiert)
|
||
- - Passwörter aus Zugangsdaten entfernt (Sicherheit)
|
||
- - "Bekannte Probleme" auf aktuellen Stand gebracht
|
||
- - Neuer Abschnitt "Best Practices für Produktiv-Migration" hinzugefügt
|
||
-
|
||
-3. **Status-Klärungen:**
|
||
- - Alle Daten sind Testdaten (PoC-Phase)
|
||
- - 2FA ist implementiert und funktionsfähig
|
||
- - Resource Pool System ist vollständig implementiert
|
||
- - Port 8443 ist geschlossen (nur über Nginx erreichbar)
|
||
-
|
||
-**Noch zu erledigen:**
|
||
-- Nginx Config anpassen (proxy_pass von https:// auf http://)
|
||
-- License Server API implementieren (Hauptaufgabe)
|
||
-
|
||
-**Problem:**
|
||
-- Internal Server Error nach Login wegen fehlender `users` Tabelle
|
||
-- UI-Design der neuen 2FA-Seiten passte nicht zum Rest der Anwendung
|
||
-
|
||
-**Lösung:**
|
||
-
|
||
-1. **Datenbank-Fix:**
|
||
- - Users-Tabelle wurde nicht automatisch erstellt
|
||
- - Manuell mit SQL-Script nachgeholt
|
||
- - Migration erfolgreich durchgeführt
|
||
- - Beide Admin-User (rac00n, w@rh@mm3r) migriert
|
||
-
|
||
-2. **UI-Design Überarbeitung:**
|
||
- - Profile-Seite im Dashboard-Stil mit Cards und Hover-Effekten
|
||
- - 2FA-Setup mit nummerierten Schritten und modernem Card-Design
|
||
- - Backup-Codes Seite mit Animation und verbessertem Layout
|
||
- - Konsistente Farbgebung und Icons
|
||
- - Verbesserte Benutzerführung mit visuellen Hinweisen
|
||
-
|
||
-**Design-Features:**
|
||
-- Card-basiertes Layout mit Schatten-Effekten
|
||
-- Hover-Animationen für bessere Interaktivität
|
||
-- Farbcodierte Sicherheitsstatus-Anzeigen
|
||
-- Passwort-Stärke-Indikator mit visueller Rückmeldung
|
||
-- Responsive Design für alle Bildschirmgrößen
|
||
-- Print-optimiertes Layout für Backup-Codes
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/create_users_table.sql` - SQL für Users-Tabelle (temporär)
|
||
-
|
||
-### 2025-06-09: Journal-Umstrukturierung
|
||
-
|
||
-**Durchgeführte Änderungen:**
|
||
-
|
||
-1. **Dokumentation aufgeteilt:**
|
||
- - `JOURNAL.md` - Enthält nur noch chronologische Änderungen (wie ein Tagebuch)
|
||
- - `THE_ROAD_SO_FAR.md` - Neues Dokument mit aktuellem Status und Roadmap
|
||
-
|
||
-2. **THE_ROAD_SO_FAR.md erstellt mit:**
|
||
- - Aktueller Status (was läuft bereits)
|
||
- - Nächste Schritte (Priorität Hoch)
|
||
- - Offene Aufgaben (Priorität Mittel)
|
||
- - Nice-to-have Features
|
||
- - Bekannte Probleme
|
||
- - Deployment-Hinweise
|
||
-
|
||
-3. **JOURNAL.md bereinigt:**
|
||
- - Todo-Listen entfernt (jetzt in THE_ROAD_SO_FAR.md)
|
||
- - Nur noch chronologische Einträge
|
||
- - Fokus auf "Was wurde gemacht" statt "Was muss gemacht werden"
|
||
-
|
||
-**Vorteile der Aufteilung:**
|
||
-- Journal bleibt übersichtlich und wächst linear
|
||
-- Status und Todos sind immer aktuell an einem Ort
|
||
-- Klare Trennung zwischen Historie und Planung
|
||
-- Einfacher für neue Entwickler einzusteigen
|
||
-
|
||
-### 2025-06-09: Nginx Config angepasst
|
||
-
|
||
-**Änderung:**
|
||
-- proxy_pass für License Server von `https://license-server:8443` auf `http://license-server:8443` geändert
|
||
-- `proxy_ssl_verify off` entfernt (nicht mehr nötig bei HTTP)
|
||
-- WebSocket-Support hinzugefügt (für zukünftige Features)
|
||
-
|
||
-**Grund:**
|
||
-- License Server läuft intern auf HTTP (wie Admin Panel)
|
||
-- SSL-Termination erfolgt nur am Nginx
|
||
-- Vereinfachte Konfiguration ohne doppelte SSL-Verschlüsselung
|
||
-
|
||
-**Hinweis:**
|
||
-Docker-Container müssen neu gestartet werden, damit die Änderung wirksam wird:
|
||
-```bash
|
||
-docker-compose down
|
||
-docker-compose up -d
|
||
-```
|
||
-- `v2_adminpanel/templates/profile.html` - Komplett überarbeitet
|
||
-- `v2_adminpanel/templates/setup_2fa.html` - Neues Step-by-Step Design
|
||
-- `v2_adminpanel/templates/backup_codes.html` - Modernisiertes Layout
|
||
-
|
||
-**Status:** ✅ Abgeschlossen - Login funktioniert, UI im konsistenten Design
|
||
-
|
||
-### 2025-06-09: Lizenzschlüssel-Format geändert
|
||
-
|
||
-**Änderung:**
|
||
-- Altes Format: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` (z.B. AF-202506F-V55Y-9DWE-GL5G)
|
||
-- Neues Format: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` (z.B. AF-F-202506-V55Y-9DWE-GL5G)
|
||
-
|
||
-**Vorteile:**
|
||
-- Klarere Struktur mit separatem Typ-Indikator
|
||
-- Einfacher zu lesen und zu verstehen
|
||
-- Typ (F/T) sofort im zweiten Block erkennbar
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/app.py`:
|
||
- - `generate_license_key()` - Generiert Keys im neuen Format
|
||
- - `validate_license_key()` - Validiert Keys mit neuem Regex-Pattern
|
||
-- `v2_adminpanel/templates/index.html`:
|
||
- - Placeholder und Pattern für Input-Feld angepasst
|
||
- - JavaScript charAt() Position für Typ-Prüfung korrigiert
|
||
-- `v2_adminpanel/templates/batch_form.html`:
|
||
- - Vorschau-Format für Batch-Generierung angepasst
|
||
-
|
||
-**Hinweis:** Alte Keys im bisherigen Format bleiben ungültig. Bei Bedarf könnte eine Migration oder Dual-Support implementiert werden.
|
||
-
|
||
-**Status:** ✅ Implementiert
|
||
-
|
||
-### 2025-06-09: Datenbank-Migration der Lizenzschlüssel
|
||
-
|
||
-**Durchgeführt:**
|
||
-- Alle bestehenden Lizenzschlüssel in der Datenbank auf das neue Format migriert
|
||
-- 18 Lizenzschlüssel erfolgreich konvertiert (16 Full, 2 Test)
|
||
-
|
||
-**Migration:**
|
||
-- Von: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ`
|
||
-- Nach: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ`
|
||
-
|
||
-**Beispiele:**
|
||
-- Alt: `AF-202506F-V55Y-9DWE-GL5G`
|
||
-- Neu: `AF-F-202506-V55Y-9DWE-GL5G`
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/migrate_license_keys.sql` - Migrations-Script (temporär)
|
||
-- `v2_adminpanel/fix_license_keys.sql` - Korrektur-Script (temporär)
|
||
-
|
||
-**Status:** ✅ Alle Lizenzschlüssel erfolgreich migriert
|
||
-
|
||
-### 2025-06-09: Kombinierte Kunden-Lizenz-Ansicht implementiert
|
||
-
|
||
-**Problem:**
|
||
-- Umständliche Navigation zwischen Kunden- und Lizenzseiten
|
||
-- Viel Hin-und-Her-Springen bei der Verwaltung
|
||
-- Kontext-Verlust beim Wechseln zwischen Ansichten
|
||
-
|
||
-**Lösung:**
|
||
-Master-Detail View mit 2-Spalten Layout implementiert
|
||
-
|
||
-**Phase 1-3 abgeschlossen:**
|
||
-1. **Backend-Implementierung:**
|
||
- - Neue Route `/customers-licenses` für kombinierte Ansicht
|
||
- - API-Endpoints für AJAX: `/api/customer/<id>/licenses`, `/api/customer/<id>/quick-stats`
|
||
- - API-Endpoint `/api/license/<id>/quick-edit` für Inline-Bearbeitung
|
||
- - Optimierte SQL-Queries mit JOIN für Performance
|
||
-
|
||
-2. **Template-Erstellung:**
|
||
- - Neues Template `customers_licenses.html` mit Master-Detail Layout
|
||
- - Links: Kundenliste (30%) mit Suchfeld
|
||
- - Rechts: Lizenzen des ausgewählten Kunden (70%)
|
||
- - Responsive Design (Mobile: untereinander)
|
||
- - JavaScript für dynamisches Laden ohne Seitenreload
|
||
- - Keyboard-Navigation (↑↓ für Kundenwechsel)
|
||
-
|
||
-3. **Integration:**
|
||
- - Dashboard: Neuer Button "Kunden & Lizenzen"
|
||
- - Customers-Seite: Link zur kombinierten Ansicht
|
||
- - Licenses-Seite: Link zur kombinierten Ansicht
|
||
- - Lizenz-Erstellung: Unterstützung für vorausgewählten Kunden
|
||
- - API /api/customers erweitert für Einzelabruf per ID
|
||
-
|
||
-**Features:**
|
||
-- Live-Suche in Kundenliste
|
||
-- Quick-Actions: Copy License Key, Toggle Status
|
||
-- Modal für neue Lizenz direkt aus Kundenansicht
|
||
-- URL-Update ohne Reload für Bookmarking
|
||
-- Loading-States während AJAX-Calls
|
||
-- Visuelles Feedback (aktiver Kunde hervorgehoben)
|
||
-
|
||
-**Noch ausstehend:**
|
||
-- Phase 4: Inline-Edit für Lizenzdetails
|
||
-- Phase 5: Erweiterte Error-Handling und Polish
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/app.py` - Neue Routen und API-Endpoints
|
||
-- `v2_adminpanel/templates/customers_licenses.html` - Neues Template
|
||
-- `v2_adminpanel/templates/dashboard.html` - Neuer Button
|
||
-- `v2_adminpanel/templates/customers.html` - Link zur kombinierten Ansicht
|
||
-- `v2_adminpanel/templates/licenses.html` - Link zur kombinierten Ansicht
|
||
-- `v2_adminpanel/templates/index.html` - Unterstützung für preselected_customer_id
|
||
-
|
||
-**Status:** ✅ Grundfunktionalität implementiert und funktionsfähig
|
||
-
|
||
-### 2025-06-09: Kombinierte Ansicht - Fertigstellung und TODOs aktualisiert
|
||
-
|
||
-**Abgeschlossen:**
|
||
-- Phase 1-3 der kombinierten Kunden-Lizenz-Ansicht vollständig implementiert
|
||
-- Master-Detail Layout funktioniert einwandfrei
|
||
-- AJAX-basiertes Laden ohne Seitenreload
|
||
-- Keyboard-Navigation mit Pfeiltasten
|
||
-- Quick-Actions für Copy und Toggle Status
|
||
-- Integration in alle relevanten Seiten
|
||
-
|
||
-**THE_ROAD_SO_FAR.md aktualisiert:**
|
||
-- Kombinierte Ansicht als "Erledigt" markiert
|
||
-- Von "In Arbeit" zu "Abgeschlossen" verschoben
|
||
-- Status dokumentiert
|
||
-
|
||
-**Verbesserung gegenüber vorher:**
|
||
-- Kein Hin-und-Her-Springen mehr zwischen Seiten
|
||
-- Kontext bleibt erhalten beim Arbeiten mit Kunden
|
||
-- Schnellere Navigation und bessere Übersicht
|
||
-- Deutlich verbesserte User Experience
|
||
-
|
||
-**Optional für später (Phase 4-5):**
|
||
-- Inline-Edit für weitere Felder
|
||
-- Erweiterte Quick-Actions
|
||
-- Session-basierte Filter-Persistenz
|
||
-
|
||
-Die Hauptproblematik der umständlichen Navigation ist damit gelöst!
|
||
-
|
||
-### 2025-06-09: Test-Flag für Lizenzen implementiert
|
||
-
|
||
-**Ziel:**
|
||
-- Klare Trennung zwischen Testdaten und echten Produktivdaten
|
||
-- Testdaten sollen von der Software ignoriert werden können
|
||
-- Bessere Übersicht im Admin Panel
|
||
-
|
||
-**Durchgeführte Änderungen:**
|
||
-
|
||
-1. **Datenbank-Schema (init.sql):**
|
||
- - Neue Spalte `is_test BOOLEAN DEFAULT FALSE` zur `licenses` Tabelle hinzugefügt
|
||
- - Migration für bestehende Daten: Alle werden als `is_test = TRUE` markiert
|
||
- - Index `idx_licenses_is_test` für bessere Performance
|
||
-
|
||
-2. **Backend (app.py):**
|
||
- - Dashboard-Queries filtern Testdaten mit `WHERE is_test = FALSE` aus
|
||
- - Lizenz-Erstellung: Neues Checkbox-Feld für Test-Markierung
|
||
- - Lizenz-Bearbeitung: Test-Status kann geändert werden
|
||
- - Export: Optional mit/ohne Testdaten (`?include_test=true`)
|
||
- - Bulk-Operationen: Nur auf Live-Daten anwendbar
|
||
- - Neue Filter in Lizenzliste: "🧪 Testdaten" und "🚀 Live-Daten"
|
||
-
|
||
-3. **Frontend Templates:**
|
||
- - **index.html**: Checkbox "Als Testdaten markieren" bei Lizenzerstellung
|
||
- - **edit_license.html**: Checkbox zum Ändern des Test-Status
|
||
- - **licenses.html**: Badge 🧪 für Testdaten, neue Filteroptionen
|
||
- - **dashboard.html**: Info-Box zeigt Anzahl der Testdaten
|
||
- - **batch_form.html**: Option für Batch-Test-Lizenzen
|
||
-
|
||
-4. **Audit-Log Integration:**
|
||
- - `is_test` Feld wird bei CREATE/UPDATE geloggt
|
||
- - Nachvollziehbarkeit von Test/Live-Status-Änderungen
|
||
-
|
||
-**Technische Details:**
|
||
-- Testdaten werden in allen Statistiken ausgefiltert
|
||
-- License Server API wird Lizenzen mit `is_test = TRUE` ignorieren
|
||
-- Resource Pool bleibt unverändert (kann Test- und Live-Ressourcen verwalten)
|
||
-
|
||
-**Migration der bestehenden Daten:**
|
||
-```sql
|
||
-UPDATE licenses SET is_test = TRUE; -- Alle aktuellen Daten sind Testdaten
|
||
-```
|
||
-
|
||
-**Status:** ✅ Implementiert
|
||
-
|
||
-### 2025-06-09: Test-Flag für Kunden und Resource Pools erweitert
|
||
-
|
||
-**Ziel:**
|
||
-- Konsistentes Test-Daten-Management über alle Entitäten
|
||
-- Kunden und Resource Pools können ebenfalls als Testdaten markiert werden
|
||
-- Automatische Verknüpfung: Test-Kunde → Test-Lizenzen → Test-Ressourcen
|
||
-
|
||
-**Durchgeführte Änderungen:**
|
||
-
|
||
-1. **Datenbank-Schema erweitert:**
|
||
- - `customers.is_test BOOLEAN DEFAULT FALSE` hinzugefügt
|
||
- - `resource_pools.is_test BOOLEAN DEFAULT FALSE` hinzugefügt
|
||
- - Indizes für bessere Performance erstellt
|
||
- - Migrations in init.sql integriert
|
||
-
|
||
-2. **Backend (app.py) - Erweiterte Logik:**
|
||
- - Dashboard: Separate Zähler für Test-Kunden und Test-Ressourcen
|
||
- - Kunde-Erstellung: Erbt Test-Status von Lizenz
|
||
- - Test-Kunde erzwingt Test-Lizenzen
|
||
- - Resource-Zuweisung: Test-Lizenzen bekommen nur Test-Ressourcen
|
||
- - Customer-Management mit is_test Filter
|
||
-
|
||
-3. **Frontend Updates:**
|
||
- - **customers.html**: 🧪 Badge für Test-Kunden
|
||
- - **edit_customer.html**: Checkbox für Test-Status
|
||
- - **dashboard.html**: Erweiterte Test-Statistik (Lizenzen, Kunden, Ressourcen)
|
||
-
|
||
-4. **Geschäftslogik:**
|
||
- - Wenn neuer Kunde bei Test-Lizenz erstellt wird → automatisch Test-Kunde
|
||
- - Wenn Test-Kunde gewählt wird → Lizenz automatisch als Test markiert
|
||
- - Resource Pool Allocation prüft Test-Status für korrekte Zuweisung
|
||
-
|
||
-**Migration der bestehenden Daten:**
|
||
-```sql
|
||
-UPDATE customers SET is_test = TRUE; -- 5 Kunden
|
||
-UPDATE resource_pools SET is_test = TRUE; -- 20 Ressourcen
|
||
-```
|
||
-
|
||
-**Technische Details:**
|
||
-- Konsistente Test/Live-Trennung über alle Ebenen
|
||
-- Dashboard-Statistiken zeigen nur Live-Daten
|
||
-- Test-Ressourcen werden nur Test-Lizenzen zugewiesen
|
||
-- Alle bestehenden Daten sind jetzt als Test markiert
|
||
-
|
||
-**Status:** ✅ Vollständig implementiert
|
||
-
|
||
-### 2025-06-09 (17:20 - 18:13): Kunden-Lizenz-Verwaltung konsolidiert
|
||
-
|
||
-**Problem:**
|
||
-- Kombinierte Ansicht `/customers-licenses` hatte Formatierungs- und Funktionsprobleme
|
||
-- Kunden wurden nicht angezeigt
|
||
-- Bootstrap Icons fehlten
|
||
-- JavaScript-Fehler beim Modal
|
||
-- Inkonsistentes Design im Vergleich zu anderen Seiten
|
||
-- Testkunden-Filter wurde beim Navigieren nicht beibehalten
|
||
-
|
||
-**Durchgeführte Änderungen:**
|
||
-
|
||
-1. **Frontend-Fixes (base.html):**
|
||
- - Bootstrap Icons CSS hinzugefügt: `bootstrap-icons@1.11.3/font/bootstrap-icons.min.css`
|
||
- - Bootstrap JavaScript Bundle bereits vorhanden, Reihenfolge optimiert
|
||
-
|
||
-2. **customers_licenses.html komplett überarbeitet:**
|
||
- - Container-Klasse von `container-fluid` auf `container py-5` geändert
|
||
- - Emojis und Button-Styling vereinheitlicht (👥 Kunden & Lizenzen)
|
||
- - Export-Dropdown wie in anderen Ansichten implementiert
|
||
- - Card-Styling mit Schatten für einheitliches Design
|
||
- - Checkbox "Testkunden anzeigen" mit Status-Beibehaltung
|
||
- - JavaScript-Funktionen korrigiert:
|
||
- - copyToClipboard mit event.currentTarget
|
||
- - showNewLicenseModal mit Bootstrap Modal
|
||
- - Header-Update beim AJAX-Kundenwechsel
|
||
- - URL-Parameter `show_test` wird überall beibehalten
|
||
-
|
||
-3. **Backend-Anpassungen (app.py):**
|
||
- - customers_licenses Route: Optional Testkunden anzeigen mit `show_test` Parameter
|
||
- - Redirects von `/customers` und `/licenses` auf `/customers-licenses` implementiert
|
||
- - Alte Route-Funktionen entfernt (kein toter Code mehr)
|
||
- - edit_license und edit_customer: Redirects behalten show_test Parameter bei
|
||
- - Dashboard-Links zeigen jetzt auf kombinierte Ansicht
|
||
-
|
||
-4. **Navigation optimiert:**
|
||
- - Dashboard: Klick auf Kunden/Lizenzen-Statistik führt zur kombinierten Ansicht
|
||
- - Alle Edit-Links behalten den show_test Parameter bei
|
||
- - Konsistente User Experience beim Navigieren
|
||
-
|
||
-**Technische Details:**
|
||
-- AJAX-Loading für dynamisches Laden der Lizenzen
|
||
-- Keyboard-Navigation (↑↓) für Kundenliste
|
||
-- Responsive Design mit Bootstrap Grid
|
||
-- Modal-Dialoge für Bestätigungen
|
||
-- Live-Suche in der Kundenliste
|
||
-
|
||
-**Resultat:**
|
||
-- ✅ Einheitliches Design mit anderen Admin-Panel-Seiten
|
||
-- ✅ Alle Funktionen arbeiten korrekt
|
||
-- ✅ Testkunden-Filter bleibt erhalten
|
||
-- ✅ Keine redundanten Views mehr
|
||
-- ✅ Zentrale Verwaltung für Kunden und Lizenzen
|
||
-
|
||
-**Status:** ✅ Vollständig implementiert
|
||
-
|
||
-### 2025-06-09: Test-Daten Checkbox Persistenz implementiert
|
||
-
|
||
-**Problem:**
|
||
-- Die "Testkunden anzeigen" Checkbox in `/customers-licenses` verlor ihren Status beim Navigieren zwischen Seiten
|
||
-- Wenn Benutzer zu anderen Seiten (Resources, Audit Log, etc.) wechselten und zurückkehrten, war die Checkbox wieder deaktiviert
|
||
-- Benutzer mussten die Checkbox jedes Mal neu aktivieren, was umständlich war
|
||
-
|
||
-**Lösung:**
|
||
-- Globale JavaScript-Funktion `preserveShowTestParameter()` in base.html implementiert
|
||
-- Die Funktion prüft beim Laden jeder Seite, ob `show_test=true` in der URL ist
|
||
-- Wenn ja, wird dieser Parameter automatisch an alle internen Links angehängt
|
||
-- Backend-Route `/create` wurde angepasst, um den Parameter bei Redirects beizubehalten
|
||
-
|
||
-**Technische Details:**
|
||
-1. **base.html** - JavaScript-Funktion hinzugefügt:
|
||
- - Läuft beim `DOMContentLoaded` Event
|
||
- - Findet alle Links die mit "/" beginnen
|
||
- - Fügt `show_test=true` Parameter hinzu wenn nicht bereits vorhanden
|
||
- - Überspringt Fragment-Links (#) und Links die bereits den Parameter haben
|
||
-
|
||
-2. **app.py** - Route-Anpassung:
|
||
- - `/create` Route behält jetzt `show_test` Parameter bei Redirects bei
|
||
- - Andere Routen (edit_license, edit_customer) behalten Parameter bereits bei
|
||
-
|
||
-**Vorteile:**
|
||
-- ✅ Konsistente User Experience beim Navigieren
|
||
-- ✅ Keine manuelle Anpassung aller Links nötig
|
||
-- ✅ Funktioniert automatisch für alle zukünftigen Links
|
||
-- ✅ Minimaler Code-Overhead
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/templates/base.html`
|
||
-- `v2_adminpanel/app.py`
|
||
-
|
||
-**Status:** ✅ Vollständig implementiert
|
||
-
|
||
-### 2025-06-09: Bearbeiten-Button Fehler behoben
|
||
-
|
||
-**Problem:**
|
||
-- Der "Bearbeiten" Button neben dem Kundennamen in der `/customers-licenses` Ansicht verursachte einen Internal Server Error
|
||
-- Die URL-Konstruktion war fehlerhaft wenn kein `show_test` Parameter vorhanden war
|
||
-- Die edit_customer.html Template hatte falsche Array-Indizes und veraltete Links
|
||
-
|
||
-**Ursache:**
|
||
-1. Die href-Attribute wurden falsch konstruiert:
|
||
- - Alt: `/customer/edit/ID{% if show_test %}?ref=customers-licenses&show_test=true{% endif %}`
|
||
- - Problem: Ohne show_test fehlte das `?ref=customers-licenses` komplett
|
||
-
|
||
-2. Die SQL-Abfrage in edit_customer() holte nur 4 Felder, aber das Template erwartete 5:
|
||
- - Query: `SELECT id, name, email, is_test`
|
||
- - Template erwartete: `customer[3]` = created_at und `customer[4]` = is_test
|
||
-
|
||
-3. Veraltete Links zu `/customers` statt `/customers-licenses`
|
||
-
|
||
-**Lösung:**
|
||
-1. URL-Konstruktion korrigiert in beiden Fällen:
|
||
- - Neu: `/customer/edit/ID?ref=customers-licenses{% if show_test %}&show_test=true{% endif %}`
|
||
-
|
||
-2. SQL-Query erweitert um created_at:
|
||
- - Neu: `SELECT id, name, email, created_at, is_test`
|
||
-
|
||
-3. Template-Indizes korrigiert:
|
||
- - is_test Checkbox nutzt jetzt `customer[4]`
|
||
-
|
||
-4. Navigation-Links aktualisiert:
|
||
- - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/templates/customers_licenses.html` (Zeilen 103 und 295)
|
||
-- `v2_adminpanel/app.py` (edit_customer Route)
|
||
-- `v2_adminpanel/templates/edit_customer.html`
|
||
-
|
||
-**Status:** ✅ Behoben
|
||
-
|
||
-### 2025-06-09: Unnötigen Lizenz-Erstellungs-Popup entfernt
|
||
-
|
||
-**Änderung:**
|
||
-- Der Bestätigungs-Popup "Möchten Sie eine neue Lizenz für KUNDENNAME erstellen?" wurde entfernt
|
||
-- Klick auf "Neue Lizenz" Button führt jetzt direkt zur Lizenzerstellung
|
||
-
|
||
-**Technische Details:**
|
||
-- Modal-HTML komplett entfernt
|
||
-- `showNewLicenseModal()` Funktion vereinfacht - navigiert jetzt direkt zu `/create?customer_id=X`
|
||
-- URL-Parameter (wie `show_test`) werden dabei beibehalten
|
||
-
|
||
-**Vorteile:**
|
||
-- ✅ Ein Klick weniger für Benutzer
|
||
-- ✅ Schnellerer Workflow
|
||
-- ✅ Weniger Code zu warten
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/templates/customers_licenses.html`
|
||
-
|
||
-**Status:** ✅ Implementiert
|
||
-
|
||
-### 2025-06-09: Testkunden-Checkbox bleibt jetzt bei Lizenz/Kunden-Bearbeitung erhalten
|
||
-
|
||
-**Problem:**
|
||
-- Bei "Lizenz bearbeiten" ging der "Testkunden anzeigen" Haken verloren beim Zurückkehren
|
||
-- Die Navigation-Links in edit_license.html zeigten auf `/licenses` statt `/customers-licenses`
|
||
-- Der show_test Parameter wurde nur über den unsicheren Referrer übertragen
|
||
-
|
||
-**Lösung:**
|
||
-1. **Navigation-Links korrigiert**:
|
||
- - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter
|
||
- - Betrifft: "Zurück zur Übersicht" und "Abbrechen" Buttons
|
||
-
|
||
-2. **Hidden Form Field hinzugefügt**:
|
||
- - Sowohl in edit_license.html als auch edit_customer.html
|
||
- - Überträgt den show_test Parameter sicher beim POST
|
||
-
|
||
-3. **Route-Logik verbessert**:
|
||
- - Parameter wird aus Form-Daten ODER GET-Parametern gelesen
|
||
- - Nicht mehr auf unsicheren Referrer angewiesen
|
||
- - Funktioniert sowohl bei Speichern als auch Abbrechen
|
||
-
|
||
-**Technische Details:**
|
||
-- Templates prüfen `request.args.get('show_test')` für Navigation
|
||
-- Hidden Input: `<input type="hidden" name="show_test" value="true">`
|
||
-- Routes: `show_test = request.form.get('show_test') or request.args.get('show_test')`
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/templates/edit_license.html`
|
||
-- `v2_adminpanel/templates/edit_customer.html`
|
||
-- `v2_adminpanel/app.py` (edit_license und edit_customer Routen)
|
||
-
|
||
-**Status:** ✅ Vollständig implementiert
|
||
-
|
||
-### 2025-06-09 22:02: Konsistente Sortierung bei Status-Toggle
|
||
-
|
||
-**Problem:**
|
||
-- Beim Klicken auf den An/Aus-Knopf (Status-Toggle) in der Kunden & Lizenzen Ansicht änderte sich die Reihenfolge der Lizenzen
|
||
-- Dies war verwirrend für Benutzer, da die Position der gerade bearbeiteten Lizenz springen konnte
|
||
-
|
||
-**Ursache:**
|
||
-- Die Sortierung `ORDER BY l.created_at DESC` war nicht stabil genug
|
||
-- Bei gleichem Erstellungszeitpunkt konnte die Datenbank die Reihenfolge inkonsistent zurückgeben
|
||
-
|
||
-**Lösung:**
|
||
-- Sekundäres Sortierkriterium hinzugefügt: `ORDER BY l.created_at DESC, l.id DESC`
|
||
-- Dies stellt sicher, dass bei gleichem Erstellungsdatum nach ID sortiert wird
|
||
-- Die Reihenfolge bleibt jetzt konsistent, auch nach Status-Änderungen
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/app.py`:
|
||
- - Zeile 2278: `/customers-licenses` Route
|
||
- - Zeile 2319: `/api/customer/<int:customer_id>/licenses` API-Route
|
||
-
|
||
-### 2025-06-10 00:01: Verbesserte Integration zwischen Kunden & Lizenzen und Resource Pool
|
||
-
|
||
-**Problem:**
|
||
-- Umständliche Navigation zwischen Kunden & Lizenzen und Resource Pool Bereichen
|
||
-- Keine direkte Verbindung zwischen beiden Ansichten
|
||
-- Benutzer mussten ständig zwischen verschiedenen Seiten hin- und herspringen
|
||
-
|
||
-**Implementierte Lösung - 5 Phasen:**
|
||
-
|
||
-1. **Phase 1: Ressourcen-Details in Kunden & Lizenzen Ansicht**
|
||
- - API `/api/customer/{id}/licenses` erweitert um konkrete Ressourcen-Informationen
|
||
- - Neue API `/api/license/{id}/resources` für detaillierte Ressourcen einer Lizenz
|
||
- - Anzeige der zugewiesenen Ressourcen mit Info-Buttons und Modal-Dialogen
|
||
- - Klickbare Links zu Ressourcen-Details im Resource Pool
|
||
-
|
||
-2. **Phase 2: Quick-Actions für Ressourcenverwaltung**
|
||
- - "Ressourcen verwalten" Button (Zahnrad-Icon) bei jeder Lizenz
|
||
- - Modal mit Übersicht aller zugewiesenen Ressourcen
|
||
- - Vorbereitung für Quarantäne-Funktionen und Ressourcen-Austausch
|
||
-
|
||
-3. **Phase 3: Ressourcen-Preview bei Lizenzerstellung**
|
||
- - Live-Anzeige verfügbarer Ressourcen beim Ändern der Anzahl
|
||
- - Erweiterte Verfügbarkeitsanzeige mit Badges (OK/Niedrig/Kritisch)
|
||
- - Warnungen bei niedrigem Bestand mit visuellen Hinweisen
|
||
- - Fortschrittsbalken zur Visualisierung der Verfügbarkeit
|
||
-
|
||
-4. **Phase 4: Dashboard-Integration**
|
||
- - Resource Pool Widget mit erweiterten Links
|
||
- - Kritische Warnungen bei < 50 Ressourcen mit "Auffüllen" Button
|
||
- - Direkte Navigation zu gefilterten Ansichten (nach Typ/Status)
|
||
- - Verbesserte visuelle Darstellung mit Tooltips
|
||
-
|
||
-5. **Phase 5: Bidirektionale Navigation**
|
||
- - Von Resource Pool: Links zu Kunden/Lizenzen bei zugewiesenen Ressourcen
|
||
- - "Zurück zu Kunden" Button wenn von Kunden & Lizenzen kommend
|
||
- - Navigation-Links im Dashboard für schnellen Zugriff
|
||
- - SQL-Query erweitert um customer_id für direkte Verlinkung
|
||
-
|
||
-**Technische Details:**
|
||
-- JavaScript-Funktionen für Modal-Dialoge und Ressourcen-Details
|
||
-- Erweiterte SQL-Queries mit JOINs für Ressourcen-Informationen
|
||
-- Bootstrap 5 Tooltips und Modals für bessere UX
|
||
-- Globale Variable `currentLicenses` für Caching der Lizenzdaten
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/app.py` - Neue APIs und erweiterte Queries
|
||
-- `v2_adminpanel/templates/customers_licenses.html` - Ressourcen-Details und Modals
|
||
-- `v2_adminpanel/templates/index.html` - Erweiterte Verfügbarkeitsanzeige
|
||
-- `v2_adminpanel/templates/dashboard.html` - Verbesserte Resource Pool Integration
|
||
-- `v2_adminpanel/templates/resources.html` - Bidirektionale Navigation
|
||
-
|
||
-**Status:** ✅ Alle 5 Phasen erfolgreich implementiert
|
||
-
|
||
-### 2025-06-10 00:15: IP-Adressen-Erfassung hinter Reverse Proxy korrigiert
|
||
-
|
||
-**Problem:**
|
||
-- Flask-App erfasste nur die Docker-interne IP-Adresse von Nginx (172.19.0.5)
|
||
-- Echte Client-IPs wurden nicht in Audit-Logs und Login-Attempts gespeichert
|
||
-- Nginx setzte die Header korrekt, aber Flask las sie nicht aus
|
||
-
|
||
-**Ursache:**
|
||
-- Flask verwendet standardmäßig nur `request.remote_addr`
|
||
-- Dies gibt bei einem Reverse Proxy nur die Proxy-IP zurück
|
||
-- Die Header `X-Real-IP` und `X-Forwarded-For` wurden ignoriert
|
||
-
|
||
-**Lösung:**
|
||
-1. **ProxyFix Middleware** hinzugefügt für korrekte Header-Verarbeitung
|
||
-2. **get_client_ip() Funktion** angepasst:
|
||
- - Prüft zuerst `X-Real-IP` Header
|
||
- - Dann `X-Forwarded-For` Header (nimmt erste IP bei mehreren)
|
||
- - Fallback auf `request.remote_addr`
|
||
-3. **Debug-Logging** für IP-Erfassung hinzugefügt
|
||
-4. **Alle `request.remote_addr` Aufrufe** durch `get_client_ip()` ersetzt
|
||
-
|
||
-**Technische Details:**
|
||
-```python
|
||
-# ProxyFix für korrekte IP-Adressen
|
||
-app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||
-
|
||
-# Verbesserte IP-Erfassung
|
||
-def get_client_ip():
|
||
- if request.headers.get('X-Real-IP'):
|
||
- return request.headers.get('X-Real-IP')
|
||
- elif request.headers.get('X-Forwarded-For'):
|
||
- return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||
- else:
|
||
- return request.remote_addr
|
||
-```
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_adminpanel/app.py` - ProxyFix und verbesserte IP-Erfassung
|
||
-
|
||
-**Status:** ✅ Implementiert - Neue Aktionen erfassen jetzt echte Client-IPs
|
||
-
|
||
-### 2025-06-10 00:30: Docker ENV Legacy-Format Warnungen behoben
|
||
-
|
||
-**Problem:**
|
||
-- Docker Build zeigte Warnungen: "LegacyKeyValueFormat: ENV key=value should be used"
|
||
-- Veraltetes Format `ENV KEY VALUE` wurde in Dockerfiles verwendet
|
||
-
|
||
-**Lösung:**
|
||
-- Alle ENV-Anweisungen auf neues Format `ENV KEY=VALUE` umgestellt
|
||
-- Betraf hauptsächlich v2_postgres/Dockerfile mit 3 ENV-Zeilen
|
||
-
|
||
-**Geänderte Dateien:**
|
||
-- `v2_postgres/Dockerfile` - ENV-Format modernisiert
|
||
-
|
||
-**Beispiel der Änderung:**
|
||
-```dockerfile
|
||
-# Alt (Legacy):
|
||
-ENV LANG de_DE.UTF-8
|
||
-ENV LANGUAGE de_DE:de
|
||
-
|
||
-# Neu (Modern):
|
||
-ENV LANG=de_DE.UTF-8
|
||
-ENV LANGUAGE=de_DE:de
|
||
-```
|
||
-
|
||
-**Status:** ✅ Alle Dockerfiles verwenden jetzt das moderne ENV-Format
|
||
-
|
||
+# v2-Docker Projekt Journal
|
||
+
|
||
+## Letzte Änderungen (06.01.2025)
|
||
+
|
||
+### Gerätelimit-Feature implementiert
|
||
+- **Datenbank-Schema erweitert**:
|
||
+ - Neue Spalte `device_limit` in `licenses` Tabelle (Standard: 3, Range: 1-10)
|
||
+ - Neue Tabelle `device_registrations` für Hardware-ID Tracking
|
||
+ - Indizes für Performance-Optimierung hinzugefügt
|
||
+
|
||
+- **UI-Anpassungen**:
|
||
+ - Einzellizenz-Formular: Dropdown für Gerätelimit (1-10 Geräte)
|
||
+ - Batch-Formular: Gerätelimit pro Lizenz auswählbar
|
||
+ - Lizenz-Bearbeitung: Gerätelimit änderbar
|
||
+ - Lizenz-Anzeige: Zeigt aktive Geräte (z.B. "💻 2/3")
|
||
+
|
||
+- **Backend-Änderungen**:
|
||
+ - Lizenz-Erstellung speichert device_limit
|
||
+ - Batch-Erstellung berücksichtigt device_limit
|
||
+ - Lizenz-Update kann device_limit ändern
|
||
+ - API-Endpoints liefern Geräteinformationen
|
||
+
|
||
+- **Migration**:
|
||
+ - Skript `migrate_device_limit.sql` erstellt
|
||
+ - Setzt device_limit = 3 für alle bestehenden Lizenzen
|
||
+
|
||
+### Vollständig implementiert:
|
||
+✅ Device Management UI (Geräte pro Lizenz anzeigen/verwalten)
|
||
+✅ Device Validation Logic (Prüfung bei Geräte-Registrierung)
|
||
+✅ API-Endpoints für Geräte-Registrierung/Deregistrierung
|
||
+
|
||
+### API-Endpoints:
|
||
+- `GET /api/license/<id>/devices` - Listet alle Geräte einer Lizenz
|
||
+- `POST /api/license/<id>/register-device` - Registriert ein neues Gerät
|
||
+- `POST /api/license/<id>/deactivate-device/<device_id>` - Deaktiviert ein Gerät
|
||
+
|
||
+### Features:
|
||
+- Geräte-Registrierung mit Hardware-ID Validierung
|
||
+- Automatische Prüfung des Gerätelimits
|
||
+- Reaktivierung deaktivierter Geräte möglich
|
||
+- Geräte-Verwaltung UI mit Modal-Dialog
|
||
+- Anzeige von Gerätename, OS, IP, Registrierungsdatum
|
||
+- Admin kann Geräte manuell deaktivieren
|
||
+
|
||
+---
|
||
+
|
||
+## Projektübersicht
|
||
+Lizenzmanagement-System für Social Media Account-Erstellungssoftware mit Docker-basierter Architektur.
|
||
+
|
||
+### Technische Anforderungen
|
||
+- **Lokaler Betrieb**: Docker mit 4GB RAM und 40GB Speicher
|
||
+- **Internet-Zugriff**:
|
||
+ - Admin Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
+ - API Server: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
+- **Datenbank**: PostgreSQL mit 2 Admin-Usern
|
||
+- **Ziel**: PoC für spätere VPS-Migration
|
||
+
|
||
+---
|
||
+
|
||
+## Best Practices für Produktiv-Migration
|
||
+
|
||
+### Passwort-Management
|
||
+Für die Migration auf Hetzner/VPS müssen die Credentials sicher verwaltet werden:
|
||
+
|
||
+1. **Environment Variables erstellen:**
|
||
+ ```bash
|
||
+ # .env.example (ins Git Repository)
|
||
+ POSTGRES_USER=changeme
|
||
+ POSTGRES_PASSWORD=changeme
|
||
+ POSTGRES_DB=changeme
|
||
+ SECRET_KEY=generate-a-secure-key
|
||
+ ADMIN_USER_1=changeme
|
||
+ ADMIN_PASS_1=changeme
|
||
+ ADMIN_USER_2=changeme
|
||
+ ADMIN_PASS_2=changeme
|
||
+
|
||
+ # .env (NICHT ins Git, auf Server erstellen)
|
||
+ POSTGRES_USER=produktiv_user
|
||
+ POSTGRES_PASSWORD=sicheres_passwort_min_20_zeichen
|
||
+ POSTGRES_DB=v2docker_prod
|
||
+ SECRET_KEY=generierter_64_zeichen_key
|
||
+ # etc.
|
||
+ ```
|
||
+
|
||
+2. **Sichere Passwörter generieren:**
|
||
+ - Mindestens 20 Zeichen
|
||
+ - Mix aus Groß-/Kleinbuchstaben, Zahlen, Sonderzeichen
|
||
+ - Verschiedene Passwörter für Dev/Staging/Prod
|
||
+ - Password-Generator verwenden (z.B. `openssl rand -base64 32`)
|
||
+
|
||
+3. **Erweiterte Sicherheit (Optional):**
|
||
+ - HashiCorp Vault für zentrale Secret-Verwaltung
|
||
+ - Docker Secrets (für Docker Swarm)
|
||
+ - Cloud-Lösungen: AWS Secrets Manager, Azure Key Vault
|
||
+
|
||
+4. **Wichtige Checkliste:**
|
||
+ - [ ] `.env` in `.gitignore` aufnehmen
|
||
+ - [ ] Neue Credentials für Produktion generieren
|
||
+ - [ ] Backup der Credentials an sicherem Ort
|
||
+ - [ ] Regelmäßige Passwort-Rotation planen
|
||
+ - [ ] Keine Default-Passwörter verwenden
|
||
+
|
||
+---
|
||
+
|
||
+## Änderungsprotokoll
|
||
+
|
||
+### 2025-06-06 - Journal erstellt
|
||
+- Initialer Projektstand dokumentiert
|
||
+- Aufgabenliste priorisiert
|
||
+- Technische Anforderungen festgehalten
|
||
+
|
||
+### 2025-06-06 - UTF-8 Support implementiert
|
||
+- Flask App Konfiguration für UTF-8 hinzugefügt (JSON_AS_ASCII=False)
|
||
+- PostgreSQL Verbindung mit UTF-8 client_encoding
|
||
+- HTML Forms mit accept-charset="UTF-8"
|
||
+- Dockerfile mit deutschen Locale-Einstellungen (de_DE.UTF-8)
|
||
+- PostgreSQL Container mit UTF-8 Initialisierung
|
||
+- init.sql mit SET client_encoding = 'UTF8'
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- v2_adminpanel/app.py
|
||
+- v2_adminpanel/templates/index.html
|
||
+- v2_adminpanel/init.sql
|
||
+- v2_adminpanel/Dockerfile
|
||
+- v2/docker-compose.yaml
|
||
+
|
||
+**Nächster Test:**
|
||
+- Container neu bauen und starten
|
||
+- Kundennamen mit Umlauten testen (z.B. "Müller GmbH", "Björn Schäfer")
|
||
+- Email mit Umlauten testen
|
||
+
|
||
+### 2025-06-06 - Lizenzübersicht implementiert
|
||
+- Neue Route `/licenses` für Lizenzübersicht
|
||
+- SQL-Query mit JOIN zwischen licenses und customers
|
||
+- Status-Berechnung (aktiv, läuft bald ab, abgelaufen)
|
||
+- Farbcodierung für verschiedene Status
|
||
+- Navigation zwischen Lizenz erstellen und Übersicht
|
||
+
|
||
+**Neue Features:**
|
||
+- Anzeige aller Lizenzen mit Kundeninformationen
|
||
+- Status-Anzeige basierend auf Ablaufdatum
|
||
+- Unterscheidung zwischen Voll- und Testversion
|
||
+- Responsive Tabelle mit Bootstrap
|
||
+- Link von Dashboard zur Übersicht und zurück
|
||
+
|
||
+**Geänderte/Neue Dateien:**
|
||
+- v2_adminpanel/app.py (neue Route hinzugefügt)
|
||
+- v2_adminpanel/templates/licenses.html (neu erstellt)
|
||
+- v2_adminpanel/templates/index.html (Navigation ergänzt)
|
||
+
|
||
+**Nächster Test:**
|
||
+- Container neu starten
|
||
+- Mehrere Lizenzen mit verschiedenen Ablaufdaten erstellen
|
||
+- Lizenzübersicht unter /licenses aufrufen
|
||
+
|
||
+### 2025-06-06 - Lizenz bearbeiten/löschen implementiert
|
||
+- Neue Routen für Bearbeiten und Löschen von Lizenzen
|
||
+- Bearbeitungsformular mit vorausgefüllten Werten
|
||
+- Aktiv/Inaktiv-Status kann geändert werden
|
||
+- Lösch-Bestätigung per JavaScript confirm()
|
||
+- Kunde kann nicht geändert werden (nur Lizenzdetails)
|
||
+
|
||
+**Neue Features:**
|
||
+- `/license/edit/<id>` - Bearbeitungsformular
|
||
+- `/license/delete/<id>` - Lizenz löschen (POST)
|
||
+- Aktionen-Spalte in der Lizenzübersicht
|
||
+- Buttons für Bearbeiten und Löschen
|
||
+- Checkbox für Aktiv-Status
|
||
+
|
||
+**Geänderte/Neue Dateien:**
|
||
+- v2_adminpanel/app.py (edit_license und delete_license Routen)
|
||
+- v2_adminpanel/templates/licenses.html (Aktionen-Spalte hinzugefügt)
|
||
+- v2_adminpanel/templates/edit_license.html (neu erstellt)
|
||
+
|
||
+**Sicherheit:**
|
||
+- Login-Required für alle Aktionen
|
||
+- POST-only für Löschvorgänge
|
||
+- Bestätigungsdialog vor dem Löschen
|
||
+
|
||
+### 2025-06-06 - Kundenverwaltung implementiert
|
||
+- Komplette CRUD-Funktionalität für Kunden
|
||
+- Übersicht zeigt Anzahl aktiver/gesamter Lizenzen pro Kunde
|
||
+- Kunden können nur gelöscht werden, wenn sie keine Lizenzen haben
|
||
+- Bearbeitungsseite zeigt alle Lizenzen des Kunden
|
||
+
|
||
+**Neue Features:**
|
||
+- `/customers` - Kundenübersicht mit Statistiken
|
||
+- `/customer/edit/<id>` - Kunde bearbeiten (Name, E-Mail)
|
||
+- `/customer/delete/<id>` - Kunde löschen (nur ohne Lizenzen)
|
||
+- Navigation zwischen allen drei Hauptbereichen
|
||
+- Anzeige der Kundenlizenzen beim Bearbeiten
|
||
+
|
||
+**Geänderte/Neue Dateien:**
|
||
+- v2_adminpanel/app.py (customers, edit_customer, delete_customer Routen)
|
||
+- v2_adminpanel/templates/customers.html (neu erstellt)
|
||
+- v2_adminpanel/templates/edit_customer.html (neu erstellt)
|
||
+- v2_adminpanel/templates/index.html (Navigation erweitert)
|
||
+- v2_adminpanel/templates/licenses.html (Navigation erweitert)
|
||
+
|
||
+**Besonderheiten:**
|
||
+- Lösch-Button ist deaktiviert, wenn Kunde Lizenzen hat
|
||
+- Aktive Lizenzen werden separat gezählt (nicht abgelaufen + aktiv)
|
||
+- UTF-8 Support für Kundennamen mit Umlauten
|
||
+
|
||
+### 2025-06-06 - Dashboard mit Statistiken implementiert
|
||
+- Übersichtliches Dashboard als neue Startseite
|
||
+- Statistik-Karten mit wichtigen Kennzahlen
|
||
+- Listen für bald ablaufende und zuletzt erstellte Lizenzen
|
||
+- Routing angepasst: Dashboard (/) und Lizenz erstellen (/create)
|
||
+
|
||
+**Neue Features:**
|
||
+- Statistik-Karten: Kunden, Lizenzen gesamt, Aktive, Ablaufende
|
||
+- Aufteilung nach Lizenztypen (Vollversion/Testversion)
|
||
+- Aufteilung nach Status (Aktiv/Abgelaufen)
|
||
+- Top 10 bald ablaufende Lizenzen mit Restlaufzeit
|
||
+- Letzte 5 erstellte Lizenzen mit Status
|
||
+- Hover-Effekt auf Statistik-Karten
|
||
+- Einheitliche Navigation mit Dashboard-Link
|
||
+
|
||
+**Geänderte/Neue Dateien:**
|
||
+- v2_adminpanel/app.py (dashboard() komplett überarbeitet, create_license() Route)
|
||
+- v2_adminpanel/templates/dashboard.html (neu erstellt)
|
||
+- v2_adminpanel/templates/index.html (Navigation erweitert)
|
||
+- v2_adminpanel/templates/licenses.html (Navigation angepasst)
|
||
+- v2_adminpanel/templates/customers.html (Navigation angepasst)
|
||
+
|
||
+**Dashboard-Inhalte:**
|
||
+- 4 Hauptstatistiken als Karten
|
||
+- Lizenztyp-Verteilung
|
||
+- Status-Verteilung
|
||
+- Warnung für bald ablaufende Lizenzen
|
||
+- Übersicht der neuesten Aktivitäten
|
||
+
|
||
+### 2025-06-06 - Suchfunktion implementiert
|
||
+- Volltextsuche für Lizenzen und Kunden
|
||
+- Case-insensitive Suche mit LIKE-Operator
|
||
+- Suchergebnisse mit Hervorhebung des Suchbegriffs
|
||
+- Suche zurücksetzen Button
|
||
+
|
||
+**Neue Features:**
|
||
+- **Lizenzsuche**: Sucht in Lizenzschlüssel, Kundenname und E-Mail
|
||
+- **Kundensuche**: Sucht in Kundenname und E-Mail
|
||
+- Suchformular mit autofocus für schnelle Eingabe
|
||
+- Anzeige des aktiven Suchbegriffs
|
||
+- Unterschiedliche Meldungen für leere Ergebnisse
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- v2_adminpanel/app.py (licenses() und customers() mit Suchlogik erweitert)
|
||
+- v2_adminpanel/templates/licenses.html (Suchformular hinzugefügt)
|
||
+- v2_adminpanel/templates/customers.html (Suchformular hinzugefügt)
|
||
+
|
||
+**Technische Details:**
|
||
+- GET-Parameter für Suche
|
||
+- SQL LIKE mit LOWER() für Case-Insensitive Suche
|
||
+- Wildcards (%) für Teilstring-Suche
|
||
+- UTF-8 kompatibel für deutsche Umlaute
|
||
+
|
||
+### 2025-06-06 - Filter und Pagination implementiert
|
||
+- Erweiterte Filteroptionen für Lizenzübersicht
|
||
+- Pagination für große Datenmengen (20 Einträge pro Seite)
|
||
+- Filter bleiben bei Seitenwechsel erhalten
|
||
+
|
||
+**Neue Features für Lizenzen:**
|
||
+- **Filter nach Typ**: Alle, Vollversion, Testversion
|
||
+- **Filter nach Status**: Alle, Aktiv, Läuft bald ab, Abgelaufen, Deaktiviert
|
||
+- **Kombinierbar mit Suche**: Filter und Suche funktionieren zusammen
|
||
+- **Pagination**: Navigation durch mehrere Seiten
|
||
+- **Ergebnisanzeige**: Zeigt Anzahl gefilterter Ergebnisse
|
||
+
|
||
+**Neue Features für Kunden:**
|
||
+- **Pagination**: 20 Kunden pro Seite
|
||
+- **Seitennavigation**: Erste, Letzte, Vor, Zurück
|
||
+- **Kombiniert mit Suche**: Suchparameter bleiben erhalten
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- v2_adminpanel/app.py (licenses() und customers() mit Filter/Pagination erweitert)
|
||
+- v2_adminpanel/templates/licenses.html (Filter-Formular und Pagination hinzugefügt)
|
||
+- v2_adminpanel/templates/customers.html (Pagination hinzugefügt)
|
||
+
|
||
+**Technische Details:**
|
||
+- SQL WHERE-Klauseln für Filter
|
||
+- LIMIT/OFFSET für Pagination
|
||
+- URL-Parameter bleiben bei Navigation erhalten
|
||
+- Responsive Bootstrap-Komponenten
|
||
+
|
||
+### 2025-06-06 - Session-Tracking implementiert
|
||
+- Neue Tabelle für Session-Verwaltung
|
||
+- Anzeige aktiver und beendeter Sessions
|
||
+- Manuelles Beenden von Sessions möglich
|
||
+- Dashboard zeigt Anzahl aktiver Sessions
|
||
+
|
||
+**Neue Features:**
|
||
+- **Sessions-Tabelle**: Speichert Session-ID, IP, User-Agent, Zeitstempel
|
||
+- **Aktive Sessions**: Zeigt alle laufenden Sessions mit Inaktivitätszeit
|
||
+- **Session-Historie**: Letzte 24 Stunden beendeter Sessions
|
||
+- **Session beenden**: Admins können Sessions manuell beenden
|
||
+- **Farbcodierung**: Grün (aktiv), Gelb (>5 Min inaktiv), Rot (lange inaktiv)
|
||
+
|
||
+**Geänderte/Neue Dateien:**
|
||
+- v2_adminpanel/init.sql (sessions Tabelle hinzugefügt)
|
||
+- v2_adminpanel/app.py (sessions() und end_session() Routen)
|
||
+- v2_adminpanel/templates/sessions.html (neu erstellt)
|
||
+- v2_adminpanel/templates/dashboard.html (Session-Statistik)
|
||
+- Alle Templates (Session-Navigation hinzugefügt)
|
||
+
|
||
+**Technische Details:**
|
||
+- Heartbeat-basiertes Tracking (last_heartbeat)
|
||
+- Automatische Inaktivitätsberechnung
|
||
+- Session-Dauer Berechnung
|
||
+- Responsive Tabellen mit Bootstrap
|
||
+
|
||
+**Hinweis:**
|
||
+Die Session-Daten werden erst gefüllt, wenn der License Server API implementiert ist und Clients sich verbinden.
|
||
+
|
||
+### 2025-06-06 - Export-Funktion implementiert
|
||
+- CSV und Excel Export für Lizenzen und Kunden
|
||
+- Formatierte Ausgabe mit deutschen Datumsformaten
|
||
+- UTF-8 Unterstützung für Sonderzeichen
|
||
+
|
||
+**Neue Features:**
|
||
+- **Lizenz-Export**: Alle Lizenzen mit Kundeninformationen
|
||
+- **Kunden-Export**: Alle Kunden mit Lizenzstatistiken
|
||
+- **Format-Optionen**: Excel (.xlsx) und CSV (.csv)
|
||
+- **Deutsche Formatierung**: Datum als dd.mm.yyyy, Status auf Deutsch
|
||
+- **UTF-8 Export**: Korrekte Kodierung für Umlaute
|
||
+- **Export-Buttons**: Dropdown-Menüs in Lizenz- und Kundenübersicht
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- v2_adminpanel/app.py (export_licenses() und export_customers() Routen)
|
||
+- v2_adminpanel/requirements.txt (pandas und openpyxl hinzugefügt)
|
||
+- v2_adminpanel/templates/licenses.html (Export-Dropdown hinzugefügt)
|
||
+- v2_adminpanel/templates/customers.html (Export-Dropdown hinzugefügt)
|
||
+
|
||
+**Technische Details:**
|
||
+- Pandas für Datenverarbeitung
|
||
+- OpenPyXL für Excel-Export
|
||
+- CSV mit Semikolon-Trennung für deutsche Excel-Kompatibilität
|
||
+- Automatische Spaltenbreite in Excel
|
||
+- BOM für UTF-8 CSV (Excel-Kompatibilität)
|
||
+
|
||
+### 2025-06-06 - Audit-Log implementiert
|
||
+- Vollständiges Änderungsprotokoll für alle Aktionen
|
||
+- Filterbare Übersicht mit Pagination
|
||
+- Detaillierte Anzeige von Änderungen
|
||
+
|
||
+**Neue Features:**
|
||
+- **Audit-Log-Tabelle**: Speichert alle Änderungen mit Zeitstempel, Benutzer, IP
|
||
+- **Protokollierte Aktionen**: CREATE, UPDATE, DELETE, LOGIN, LOGOUT, EXPORT
|
||
+- **JSON-Speicherung**: Alte und neue Werte als JSONB für flexible Abfragen
|
||
+- **Filter-Optionen**: Nach Benutzer, Aktion und Entität
|
||
+- **Detail-Anzeige**: Aufklappbare Änderungsdetails
|
||
+- **Navigation**: Audit-Link in allen Templates
|
||
+
|
||
+**Geänderte/Neue Dateien:**
|
||
+- v2_adminpanel/init.sql (audit_log Tabelle mit Indizes)
|
||
+- v2_adminpanel/app.py (log_audit() Funktion und audit_log() Route)
|
||
+- v2_adminpanel/templates/audit_log.html (neu erstellt)
|
||
+- Alle Templates (Audit-Navigation hinzugefügt)
|
||
+
|
||
+**Technische Details:**
|
||
+- JSONB für strukturierte Datenspeicherung
|
||
+- Performance-Indizes auf timestamp, username und entity
|
||
+- Farbcodierung für verschiedene Aktionen
|
||
+- 50 Einträge pro Seite mit Pagination
|
||
+- IP-Adresse und User-Agent Tracking
|
||
+
|
||
+### 2025-06-06 - PostgreSQL UTF-8 Locale konfiguriert
|
||
+- Eigenes PostgreSQL Dockerfile für deutsche Locale
|
||
+- Sicherstellung der UTF-8 Unterstützung auf Datenbankebene
|
||
+
|
||
+**Neue Features:**
|
||
+- **PostgreSQL Dockerfile**: Installiert deutsche Locale (de_DE.UTF-8)
|
||
+- **Locale-Umgebungsvariablen**: LANG, LANGUAGE, LC_ALL gesetzt
|
||
+- **Docker Compose Update**: Verwendet jetzt eigenes PostgreSQL-Image
|
||
+
|
||
+**Neue Dateien:**
|
||
+- v2_postgres/Dockerfile (neu erstellt)
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- v2/docker-compose.yaml (postgres Service nutzt jetzt build statt image)
|
||
+
|
||
+**Technische Details:**
|
||
+- Basis-Image: postgres:14
|
||
+- Locale-Installation über apt-get
|
||
+- locale-gen für de_DE.UTF-8
|
||
+- Vollständige UTF-8 Unterstützung für deutsche Sonderzeichen
|
||
+
|
||
+### 2025-06-07 - Backup-Funktionalität implementiert
|
||
+- Verschlüsselte Backups mit manueller und automatischer Ausführung
|
||
+- Backup-Historie mit Download und Wiederherstellung
|
||
+- Dashboard-Integration für Backup-Status
|
||
+
|
||
+**Neue Features:**
|
||
+- **Backup-Erstellung**: Manuell und automatisch (täglich 3:00 Uhr)
|
||
+- **Verschlüsselung**: AES-256 mit Fernet, Key aus ENV oder automatisch generiert
|
||
+- **Komprimierung**: GZIP-Komprimierung vor Verschlüsselung
|
||
+- **Backup-Historie**: Vollständige Übersicht aller Backups
|
||
+- **Wiederherstellung**: Mit optionalem Verschlüsselungs-Passwort
|
||
+- **Download-Funktion**: Backups können heruntergeladen werden
|
||
+- **Dashboard-Widget**: Zeigt letztes Backup-Status
|
||
+- **E-Mail-Vorbereitung**: Struktur für Benachrichtigungen (deaktiviert)
|
||
+
|
||
+**Neue/Geänderte Dateien:**
|
||
+- v2_adminpanel/init.sql (backup_history Tabelle hinzugefügt)
|
||
+- v2_adminpanel/requirements.txt (cryptography, apscheduler hinzugefügt)
|
||
+- v2_adminpanel/app.py (Backup-Funktionen und Routen)
|
||
+- v2_adminpanel/templates/backups.html (neu erstellt)
|
||
+- v2_adminpanel/templates/dashboard.html (Backup-Status-Widget)
|
||
+- v2_adminpanel/Dockerfile (PostgreSQL-Client installiert)
|
||
+- v2/.env (EMAIL_ENABLED und BACKUP_ENCRYPTION_KEY)
|
||
+- Alle Templates (Backup-Navigation hinzugefügt)
|
||
+
|
||
+**Technische Details:**
|
||
+- Speicherort: C:\Users\Administrator\Documents\GitHub\v2-Docker\backups\
|
||
+- Dateiformat: backup_v2docker_YYYYMMDD_HHMMSS_encrypted.sql.gz.enc
|
||
+- APScheduler für automatische Backups
|
||
+- pg_dump/psql für Datenbank-Operationen
|
||
+- Audit-Log für alle Backup-Aktionen
|
||
+- Sicherheitsabfrage bei Wiederherstellung
|
||
+
|
||
+### 2025-06-07 - HTTPS/SSL und Internet-Zugriff implementiert
|
||
+- Nginx Reverse Proxy für externe Erreichbarkeit eingerichtet
|
||
+- SSL-Zertifikate von IONOS mit vollständiger Certificate Chain integriert
|
||
+- Netzwerkkonfiguration für feste IP-Adresse
|
||
+- DynDNS und Port-Forwarding konfiguriert
|
||
+
|
||
+**Neue Features:**
|
||
+- **Nginx Reverse Proxy**: Leitet HTTPS-Anfragen an Container weiter
|
||
+- **SSL-Zertifikate**: Wildcard-Zertifikat von IONOS für *.z5m7q9dk3ah2v1plx6ju.com
|
||
+- **Certificate Chain**: Server-, Intermediate- und Root-Zertifikate kombiniert
|
||
+- **Subdomain-Routing**: admin-panel-undso und api-software-undso
|
||
+- **Port-Forwarding**: FRITZ!Box 443 → 192.168.178.88
|
||
+- **Feste IP**: Windows-PC auf 192.168.178.88 konfiguriert
|
||
+
|
||
+**Neue/Geänderte Dateien:**
|
||
+- v2_nginx/nginx.conf (Reverse Proxy Konfiguration)
|
||
+- v2_nginx/Dockerfile (Nginx Container mit SSL)
|
||
+- v2_nginx/ssl/fullchain.pem (Certificate Chain)
|
||
+- v2_nginx/ssl/privkey.pem (Private Key)
|
||
+- v2/docker-compose.yaml (nginx Service hinzugefügt)
|
||
+- set-static-ip.ps1 (PowerShell Script für feste IP)
|
||
+- reset-to-dhcp.ps1 (PowerShell Script für DHCP)
|
||
+
|
||
+**Technische Details:**
|
||
+- SSL-Termination am Nginx Reverse Proxy
|
||
+- Backend-Kommunikation über Docker-internes Netzwerk
|
||
+- Admin-Panel nur noch über Nginx erreichbar (Port 443 nicht mehr exposed)
|
||
+- License-Server behält externen Port 8443 für direkte API-Zugriffe
|
||
+- Intermediate Certificates aus ZIP extrahiert und korrekt verkettet
|
||
+
|
||
+**Zugangsdaten:**
|
||
+- Admin-Panel: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
+- Benutzer 1: rac00n
|
||
+- Benutzer 2: w@rh@mm3r
|
||
+
|
||
+**Status:**
|
||
+- ✅ Admin-Panel extern erreichbar ohne SSL-Warnungen
|
||
+- ✅ Reverse Proxy funktioniert
|
||
+- ✅ SSL-Zertifikate korrekt konfiguriert
|
||
+- ✅ Netzwerk-Setup abgeschlossen
|
||
+
|
||
+### 2025-06-07 - Projekt-Cleanup durchgeführt
|
||
+- Redundante und überflüssige Dateien entfernt
|
||
+- Projektstruktur verbessert und organisiert
|
||
+
|
||
+**Durchgeführte Änderungen:**
|
||
+1. **Entfernte Dateien:**
|
||
+ - v2_adminpanel/templates/.env (Duplikat der Haupt-.env)
|
||
+ - v2_postgreSQL/ (leeres Verzeichnis)
|
||
+ - SSL-Zertifikate aus Root-Verzeichnis (7 Dateien)
|
||
+ - Ungenutzer `json` Import aus app.py
|
||
+
|
||
+2. **Organisatorische Verbesserungen:**
|
||
+ - PowerShell-Scripts in neuen `scripts/` Ordner verschoben
|
||
+ - SSL-Zertifikate nur noch in v2_nginx/ssl/
|
||
+ - Keine Konfigurationsdateien mehr in Template-Verzeichnissen
|
||
+
|
||
+**Technische Details:**
|
||
+- Docker-Container wurden gestoppt und nach Cleanup neu gestartet
|
||
+- Alle Services laufen wieder normal
|
||
+- Keine funktionalen Änderungen, nur Struktur-Verbesserungen
|
||
+
|
||
+**Ergebnis:**
|
||
+- Verbesserte Projektstruktur
|
||
+- Erhöhte Sicherheit (keine SSL-Zertifikate im Root)
|
||
+- Klarere Dateiorganisation
|
||
+
|
||
+### 2025-06-07 - SSL "Nicht sicher" Problem behoben
|
||
+- Chrome-Warnung trotz gültigem Zertifikat analysiert und behoben
|
||
+- Ursache: Selbstsigniertes Zertifikat in der Admin Panel Flask-App
|
||
+
|
||
+**Durchgeführte Änderungen:**
|
||
+1. **Admin Panel Konfiguration (app.py):**
|
||
+ - Von HTTPS mit selbstsigniertem Zertifikat auf HTTP Port 5000 umgestellt
|
||
+ - `ssl_context='adhoc'` entfernt
|
||
+ - Flask läuft jetzt auf `0.0.0.0:5000` statt HTTPS
|
||
+
|
||
+2. **Dockerfile Anpassung (v2_adminpanel/Dockerfile):**
|
||
+ - EXPOSE Port von 443 auf 5000 geändert
|
||
+ - Container exponiert jetzt HTTP statt HTTPS
|
||
+
|
||
+3. **Nginx Konfiguration (nginx.conf):**
|
||
+ - proxy_pass von `https://admin-panel:443` auf `http://admin-panel:5000` geändert
|
||
+ - `proxy_ssl_verify off` entfernt (nicht mehr benötigt)
|
||
+ - Sicherheits-Header für beide Domains hinzugefügt:
|
||
+ - Strict-Transport-Security (HSTS) - erzwingt HTTPS für 1 Jahr
|
||
+ - X-Content-Type-Options - verhindert MIME-Type Sniffing
|
||
+ - X-Frame-Options - Schutz vor Clickjacking
|
||
+ - X-XSS-Protection - aktiviert XSS-Filter
|
||
+ - Referrer-Policy - kontrolliert Referrer-Informationen
|
||
+
|
||
+**Technische Details:**
|
||
+- Externer Traffic nutzt weiterhin HTTPS mit gültigen IONOS-Zertifikaten
|
||
+- Interne Kommunikation zwischen Nginx und Admin Panel läuft über HTTP (sicher im Docker-Netzwerk)
|
||
+- Kein selbstsigniertes Zertifikat mehr in der Zertifikatskette
|
||
+- SSL-Termination erfolgt ausschließlich am Nginx Reverse Proxy
|
||
+
|
||
+**Docker Neustart:**
|
||
+- Container gestoppt (`docker-compose down`)
|
||
+- Images neu gebaut (`docker-compose build`)
|
||
+- Container neu gestartet (`docker-compose up -d`)
|
||
+- Alle Services laufen normal
|
||
+
|
||
+**Ergebnis:**
|
||
+- ✅ "Nicht sicher" Warnung in Chrome behoben
|
||
+- ✅ Saubere SSL-Konfiguration ohne Mixed Content
|
||
+- ✅ Verbesserte Sicherheits-Header implementiert
|
||
+- ✅ Admin Panel zeigt jetzt grünes Schloss-Symbol
|
||
+
|
||
+### 2025-06-07 - Sicherheitslücke geschlossen: License Server Port
|
||
+- Direkter Zugriff auf License Server Port 8443 entfernt
|
||
+- Sicherheitsanalyse der exponierten Ports durchgeführt
|
||
+
|
||
+**Identifiziertes Problem:**
|
||
+- License Server war direkt auf Port 8443 von außen erreichbar
|
||
+- Umging damit die Nginx-Sicherheitsschicht und Security Headers
|
||
+- Besonders kritisch, da nur Platzhalter ohne echte Sicherheit
|
||
+
|
||
+**Durchgeführte Änderung:**
|
||
+- Port-Mapping für License Server in docker-compose.yaml entfernt
|
||
+- Service ist jetzt nur noch über Nginx Reverse Proxy erreichbar
|
||
+- Gleiche Sicherheitskonfiguration wie Admin Panel
|
||
+
|
||
+**Aktuelle Port-Exposition:**
|
||
+- ✅ Nginx: Port 80/443 (benötigt für externen Zugriff)
|
||
+- ✅ PostgreSQL: Keine Ports exponiert (gut)
|
||
+- ✅ Admin Panel: Nur über Nginx erreichbar
|
||
+- ✅ License Server: Nur über Nginx erreichbar (vorher direkt auf 8443)
|
||
+
|
||
+**Weitere identifizierte Sicherheitsthemen:**
|
||
+1. Credentials im Klartext in .env Datei
|
||
+2. SSL-Zertifikate im Repository gespeichert
|
||
+3. License Server noch nicht implementiert
|
||
+
|
||
+**Empfehlung:** Docker-Container neu starten für Änderungsübernahme
|
||
+
|
||
+### 2025-06-07 - License Server Port 8443 wieder aktiviert
|
||
+- Port 8443 für direkten Zugriff auf License Server wieder geöffnet
|
||
+- Notwendig für Client-Software Lizenzprüfung
|
||
+
|
||
+**Begründung:**
|
||
+- Client-Software benötigt direkten Zugriff für Lizenzprüfung
|
||
+- Umgehung von möglichen Firewall-Blockaden auf Port 443
|
||
+- Weniger Latenz ohne Nginx-Proxy
|
||
+- Flexibilität für verschiedene Client-Implementierungen
|
||
+
|
||
+**Konfiguration:**
|
||
+- License Server erreichbar über:
|
||
+ - Direkt: Port 8443 (für Client-Software)
|
||
+ - Via Nginx: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com (für Browser/Tests)
|
||
+
|
||
+**Sicherheitshinweis:**
|
||
+- Port 8443 ist wieder direkt exponiert
|
||
+- License Server muss vor Produktivbetrieb implementiert werden mit:
|
||
+ - Eigener SSL-Konfiguration
|
||
+ - API-Key Authentifizierung
|
||
+ - Rate Limiting
|
||
+ - Input-Validierung
|
||
+
|
||
+**Status:**
|
||
+- Port-Mapping in docker-compose.yaml wiederhergestellt
|
||
+- Änderung erfordert Docker-Neustart
|
||
+
|
||
+### 2025-06-07 - Rate-Limiting und Brute-Force-Schutz implementiert
|
||
+- Umfassender Schutz vor Login-Angriffen mit IP-Sperre
|
||
+- Dashboard-Integration für Sicherheitsüberwachung
|
||
+
|
||
+**Implementierte Features:**
|
||
+1. **Rate-Limiting System:**
|
||
+ - 5 Login-Versuche erlaubt, danach 24h IP-Sperre
|
||
+ - Progressive Fehlermeldungen (zufällig aus 5 lustigen Varianten)
|
||
+ - CAPTCHA nach 2 Fehlversuchen (Google reCAPTCHA v2 vorbereitet)
|
||
+ - E-Mail-Benachrichtigung bei Sperrung (vorbereitet, deaktiviert für PoC)
|
||
+
|
||
+2. **Timing-Attack Schutz:**
|
||
+ - Mindestens 1 Sekunde Antwortzeit bei allen Login-Versuchen
|
||
+ - Gleiche Antwortzeit bei richtigem/falschem Username
|
||
+ - Verhindert Username-Enumeration
|
||
+
|
||
+3. **Lustige Fehlermeldungen (zufällig):**
|
||
+ - "NOPE!"
|
||
+ - "ACCESS DENIED, TRY HARDER"
|
||
+ - "WRONG! 🚫"
|
||
+ - "COMPUTER SAYS NO"
|
||
+ - "YOU FAILED"
|
||
+
|
||
+4. **Dashboard-Sicherheitswidget:**
|
||
+ - Sicherheitslevel-Anzeige (NORMAL/ERHÖHT/KRITISCH)
|
||
+ - Anzahl gesperrter IPs
|
||
+ - Fehlversuche heute
|
||
+ - Letzte 5 Sicherheitsereignisse mit Details
|
||
+
|
||
+5. **IP-Verwaltung:**
|
||
+ - Übersicht aller gesperrten IPs
|
||
+ - Manuelles Entsperren möglich
|
||
+ - Login-Versuche zurücksetzen
|
||
+ - Detaillierte Informationen pro IP
|
||
+
|
||
+6. **Audit-Log Erweiterungen:**
|
||
+ - LOGIN_SUCCESS - Erfolgreiche Anmeldung
|
||
+ - LOGIN_FAILED - Fehlgeschlagener Versuch
|
||
+ - LOGIN_BLOCKED - IP wurde gesperrt
|
||
+ - UNBLOCK_IP - IP manuell entsperrt
|
||
+ - CLEAR_ATTEMPTS - Versuche zurückgesetzt
|
||
+
|
||
+**Neue/Geänderte Dateien:**
|
||
+- v2_adminpanel/init.sql (login_attempts Tabelle)
|
||
+- v2_adminpanel/app.py (Rate-Limiting Logik, neue Routen)
|
||
+- v2_adminpanel/templates/login.html (Fehlermeldungs-Styling, CAPTCHA)
|
||
+- v2_adminpanel/templates/dashboard.html (Sicherheitswidget)
|
||
+- v2_adminpanel/templates/blocked_ips.html (neu - IP-Verwaltung)
|
||
+
|
||
+**Technische Details:**
|
||
+- IP-Ermittlung berücksichtigt Proxy-Header (X-Forwarded-For)
|
||
+- Fehlermeldungen mit Animation (shake-effect)
|
||
+- Farbcodierung: Rot für Fehler, Lila für Sperre, Orange für CAPTCHA
|
||
+- Automatische Bereinigung alter Einträge möglich
|
||
+
|
||
+**Sicherheitsverbesserungen:**
|
||
+- Schutz vor Brute-Force-Angriffen
|
||
+- Timing-Attack-Schutz implementiert
|
||
+- IP-basierte Sperrung für 24 Stunden
|
||
+- Audit-Trail für alle Sicherheitsereignisse
|
||
+
|
||
+**Hinweis für Produktion:**
|
||
+- CAPTCHA-Keys müssen in .env konfiguriert werden
|
||
+- E-Mail-Server für Benachrichtigungen einrichten
|
||
+- Rate-Limits können über Konstanten angepasst werden
|
||
+
|
||
+### 2025-06-07 - Session-Timeout mit Live-Timer implementiert
|
||
+- 5 Minuten Inaktivitäts-Timeout mit visueller Countdown-Anzeige
|
||
+- Automatische Session-Verlängerung bei Benutzeraktivität
|
||
+
|
||
+**Implementierte Features:**
|
||
+1. **Session-Timeout Backend:**
|
||
+ - Flask Session-Timeout auf 5 Minuten konfiguriert
|
||
+ - Heartbeat-Endpoint für Keep-Alive
|
||
+ - Automatisches Session-Update bei jeder Aktion
|
||
+
|
||
+2. **Live-Timer in der Navbar:**
|
||
+ - Countdown von 5:00 bis 0:00
|
||
+ - Position: Zwischen Logo und Username
|
||
+ - Farbwechsel nach verbleibender Zeit:
|
||
+ - Grün: > 2 Minuten
|
||
+ - Gelb: 1-2 Minuten
|
||
+ - Rot: < 1 Minute
|
||
+ - Blinkend: < 30 Sekunden
|
||
+
|
||
+3. **Benutzerinteraktion:**
|
||
+ - Timer wird bei jeder Aktivität zurückgesetzt
|
||
+ - Tracking von: Klicks, Tastatureingaben, Mausbewegungen
|
||
+ - Automatischer Heartbeat bei Aktivität
|
||
+ - Warnung bei < 1 Minute mit "Session verlängern" Button
|
||
+
|
||
+4. **Base-Template System:**
|
||
+ - Neue base.html als Basis für alle Admin-Seiten
|
||
+ - Alle Templates (außer login.html) nutzen jetzt base.html
|
||
+ - Einheitliches Layout und Timer auf allen Seiten
|
||
+
|
||
+**Neue/Geänderte Dateien:**
|
||
+- v2_adminpanel/app.py (Session-Konfiguration, Heartbeat-Endpoint)
|
||
+- v2_adminpanel/templates/base.html (neu - Base-Template mit Timer)
|
||
+- Alle anderen Templates aktualisiert für Template-Vererbung
|
||
+
|
||
+**Technische Details:**
|
||
+- JavaScript-basierter Countdown-Timer
|
||
+- AJAX-Heartbeat alle 5 Sekunden bei Aktivität
|
||
+- LocalStorage für Tab-Synchronisation möglich
|
||
+- Automatischer Logout bei 0:00
|
||
+- Fetch-Interceptor für automatische Session-Verlängerung
|
||
+
|
||
+**Sicherheitsverbesserung:**
|
||
+- Automatischer Logout nach 5 Minuten Inaktivität
|
||
+- Verhindert vergessene Sessions
|
||
+- Visuelles Feedback für Session-Status
|
||
+
|
||
+### 2025-06-07 - Session-Timeout Bug behoben
|
||
+- Problem: Session-Timeout funktionierte nicht korrekt - Session blieb länger als 5 Minuten aktiv
|
||
+- Ursache: login_required Decorator aktualisierte last_activity bei JEDEM Request
|
||
+
|
||
+**Durchgeführte Änderungen:**
|
||
+1. **login_required Decorator (app.py):**
|
||
+ - Prüft jetzt ob Session abgelaufen ist (5 Minuten seit last_activity)
|
||
+ - Aktualisiert last_activity NICHT mehr automatisch
|
||
+ - Führt AUTO_LOGOUT mit Audit-Log bei Timeout durch
|
||
+ - Speichert Username vor session.clear() für korrektes Logging
|
||
+
|
||
+2. **Heartbeat-Endpoint (app.py):**
|
||
+ - Geändert zu POST-only Endpoint
|
||
+ - Aktualisiert explizit last_activity wenn aufgerufen
|
||
+ - Wird nur bei aktiver Benutzerinteraktion aufgerufen
|
||
+
|
||
+3. **Frontend Timer (base.html):**
|
||
+ - Heartbeat wird als POST Request gesendet
|
||
+ - trackActivity() ruft extendSession() ohne vorheriges resetTimer() auf
|
||
+ - Timer wird erst nach erfolgreichem Heartbeat zurückgesetzt
|
||
+ - AJAX Interceptor ignoriert Heartbeat-Requests
|
||
+
|
||
+4. **Audit-Log Erweiterung:**
|
||
+ - Neue Aktion AUTO_LOGOUT hinzugefügt
|
||
+ - Orange Farbcodierung (#fd7e14)
|
||
+ - Zeigt Grund des Timeouts im Audit-Log
|
||
+
|
||
+**Ergebnis:**
|
||
+- ✅ Session läuft nach exakt 5 Minuten Inaktivität ab
|
||
+- ✅ Benutzeraktivität verlängert Session korrekt
|
||
+- ✅ AUTO_LOGOUT wird im Audit-Log protokolliert
|
||
+- ✅ Visueller Timer zeigt verbleibende Zeit
|
||
+
|
||
+### 2025-06-07 - Session-Timeout weitere Verbesserungen
|
||
+- Zusätzliche Fixes nach Test-Feedback implementiert
|
||
+
|
||
+**Weitere durchgeführte Änderungen:**
|
||
+1. **Fehlender Import behoben:**
|
||
+ - `flash` zu Flask-Imports hinzugefügt für Timeout-Warnmeldungen
|
||
+
|
||
+2. **Session-Cookie-Konfiguration erweitert (app.py):**
|
||
+ - SESSION_COOKIE_HTTPONLY = True (Sicherheit gegen XSS)
|
||
+ - SESSION_COOKIE_SECURE = False (intern HTTP, extern HTTPS via Nginx)
|
||
+ - SESSION_COOKIE_SAMESITE = 'Lax' (CSRF-Schutz)
|
||
+ - SESSION_COOKIE_NAME = 'admin_session' (eindeutiger Name)
|
||
+ - SESSION_REFRESH_EACH_REQUEST = False (verhindert automatische Verlängerung)
|
||
+
|
||
+3. **Session-Handling verbessert:**
|
||
+ - Entfernt: session.permanent = True aus login_required decorator
|
||
+ - Hinzugefügt: session.modified = True im Heartbeat für explizites Speichern
|
||
+ - Debug-Logging für Session-Timeout-Prüfung hinzugefügt
|
||
+
|
||
+4. **Nginx-Konfiguration:**
|
||
+ - Bereits korrekt konfiguriert für Heartbeat-Weiterleitung
|
||
+ - Proxy-Headers für korrekte IP-Weitergabe
|
||
+
|
||
+**Technische Details:**
|
||
+- Flask-Session mit Filesystem-Backend nutzt jetzt korrekte Cookie-Einstellungen
|
||
+- Session-Cookie wird nicht mehr automatisch bei jedem Request verlängert
|
||
+- Explizite Session-Modifikation nur bei Heartbeat-Requests
|
||
+- Debug-Logs zeigen Zeit seit letzter Aktivität für Troubleshooting
|
||
+
|
||
+**Status:**
|
||
+- ✅ Session-Timeout-Mechanismus vollständig implementiert
|
||
+- ✅ Debug-Logging für Session-Überwachung aktiv
|
||
+- ✅ Cookie-Sicherheitseinstellungen optimiert
|
||
+
|
||
+### 2025-06-07 - CAPTCHA Backend-Validierung implementiert
|
||
+- Google reCAPTCHA v2 Backend-Verifizierung hinzugefügt
|
||
+
|
||
+**Implementierte Features:**
|
||
+1. **verify_recaptcha() Funktion (app.py):**
|
||
+ - Validiert CAPTCHA-Response mit Google API
|
||
+ - Fallback: Wenn RECAPTCHA_SECRET_KEY nicht konfiguriert, wird CAPTCHA übersprungen (für PoC)
|
||
+ - Timeout von 5 Sekunden für API-Request
|
||
+ - Error-Handling für Netzwerkfehler
|
||
+ - Logging für Debugging und Fehleranalyse
|
||
+
|
||
+2. **Login-Route Erweiterungen:**
|
||
+ - CAPTCHA wird nach 2 Fehlversuchen angezeigt
|
||
+ - Prüfung ob CAPTCHA-Response vorhanden
|
||
+ - Validierung der CAPTCHA-Response gegen Google API
|
||
+ - Unterschiedliche Fehlermeldungen für fehlende/ungültige CAPTCHA
|
||
+ - Site Key wird aus Environment-Variable an Template übergeben
|
||
+
|
||
+3. **Environment-Konfiguration (.env):**
|
||
+ - RECAPTCHA_SITE_KEY (für Frontend)
|
||
+ - RECAPTCHA_SECRET_KEY (für Backend-Validierung)
|
||
+ - Beide auskommentiert für PoC-Phase
|
||
+
|
||
+4. **Dependencies:**
|
||
+ - requests Library zu requirements.txt hinzugefügt
|
||
+
|
||
+**Sicherheitsaspekte:**
|
||
+- CAPTCHA verhindert automatisierte Brute-Force-Angriffe
|
||
+- Timing-Attack-Schutz bleibt auch bei CAPTCHA-Prüfung aktiv
|
||
+- Bei Netzwerkfehlern wird CAPTCHA als bestanden gewertet (Verfügbarkeit vor Sicherheit)
|
||
+- Secret Key wird niemals im Frontend exponiert
|
||
+
|
||
+**Verwendung:**
|
||
+1. Google reCAPTCHA v2 Keys erstellen: https://www.google.com/recaptcha/admin
|
||
+2. Keys in .env eintragen:
|
||
+ ```
|
||
+ RECAPTCHA_SITE_KEY=your-site-key
|
||
+ RECAPTCHA_SECRET_KEY=your-secret-key
|
||
+ ```
|
||
+3. Container neu starten
|
||
+
|
||
+**Status:**
|
||
+- ✅ CAPTCHA-Frontend bereits vorhanden (login.html)
|
||
+- ✅ Backend-Validierung vollständig implementiert
|
||
+- ✅ Fallback für PoC-Betrieb ohne Google-Keys
|
||
+- ✅ Integration in Rate-Limiting-System
|
||
+- ⚠️ CAPTCHA-Keys noch nicht konfiguriert (für PoC deaktiviert)
|
||
+
|
||
+**Anleitung für Google reCAPTCHA Keys:**
|
||
+
|
||
+1. **Registrierung bei Google reCAPTCHA:**
|
||
+ - Gehe zu: https://www.google.com/recaptcha/admin/create
|
||
+ - Melde dich mit Google-Konto an
|
||
+ - Label eingeben: "v2-Docker Admin Panel"
|
||
+ - Typ wählen: "reCAPTCHA v2" → "Ich bin kein Roboter"-Kästchen
|
||
+ - Domains hinzufügen:
|
||
+ ```
|
||
+ admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
+ localhost
|
||
+ ```
|
||
+ - Nutzungsbedingungen akzeptieren
|
||
+ - Senden klicken
|
||
+
|
||
+2. **Keys erhalten:**
|
||
+ - Site Key (öffentlich für Frontend)
|
||
+ - Secret Key (geheim für Backend-Validierung)
|
||
+
|
||
+3. **Keys in .env eintragen:**
|
||
+ ```bash
|
||
+ RECAPTCHA_SITE_KEY=6Ld...
|
||
+ RECAPTCHA_SECRET_KEY=6Ld...
|
||
+ ```
|
||
+
|
||
+4. **Container neu starten:**
|
||
+ ```bash
|
||
+ docker-compose down
|
||
+ docker-compose up -d
|
||
+ ```
|
||
+
|
||
+**Kosten:**
|
||
+- Kostenlos bis 1 Million Anfragen pro Monat
|
||
+- Danach: $1.00 pro 1000 zusätzliche Anfragen
|
||
+- Für dieses Projekt reicht die kostenlose Version vollkommen aus
|
||
+
|
||
+**Test-Keys für Entwicklung:**
|
||
+- Site Key: `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI`
|
||
+- Secret Key: `6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe`
|
||
+- ⚠️ Diese Keys nur für lokale Tests verwenden, niemals produktiv!
|
||
+
|
||
+**Aktueller Status:**
|
||
+- Code ist vollständig implementiert und getestet
|
||
+- CAPTCHA wird nach 2 Fehlversuchen angezeigt
|
||
+- Ohne konfigurierte Keys wird CAPTCHA-Prüfung übersprungen
|
||
+- Für Produktion müssen nur die Keys in .env eingetragen werden
|
||
+
|
||
+### 2025-06-07 - License Key Generator implementiert
|
||
+- Automatische Generierung von Lizenzschlüsseln mit definiertem Format
|
||
+
|
||
+**Implementiertes Format:**
|
||
+`AF-YYYYMMFT-XXXX-YYYY-ZZZZ`
|
||
+- **AF** = Account Factory (feste Produktkennung)
|
||
+- **YYYY** = Jahr (z.B. 2025)
|
||
+- **MM** = Monat (z.B. 06)
|
||
+- **FT** = Lizenztyp (F=Fullversion, T=Testversion)
|
||
+- **XXXX-YYYY-ZZZZ** = Zufällige alphanumerische Zeichen (ohne verwirrende wie 0/O, 1/I/l)
|
||
+
|
||
+**Beispiele:**
|
||
+- Vollversion: `AF-202506F-A7K9-M3P2-X8R4`
|
||
+- Testversion: `AF-202512T-B2N5-K8L3-Q9W7`
|
||
+
|
||
+**Implementierte Features:**
|
||
+
|
||
+1. **Backend-Funktionen (app.py):**
|
||
+ - `generate_license_key()` - Generiert Keys mit kryptografisch sicherem Zufallsgenerator
|
||
+ - `validate_license_key()` - Validiert das Key-Format mit Regex
|
||
+ - Verwendet `secrets` statt `random` für Sicherheit
|
||
+ - Erlaubte Zeichen: ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (ohne verwirrende)
|
||
+
|
||
+2. **API-Endpoint:**
|
||
+ - POST `/api/generate-license-key` - JSON API für Key-Generierung
|
||
+ - Prüft auf Duplikate in der Datenbank (max. 10 Versuche)
|
||
+ - Audit-Log-Eintrag bei jeder Generierung
|
||
+ - Login-Required geschützt
|
||
+
|
||
+3. **Frontend-Verbesserungen (index.html):**
|
||
+ - Generate-Button neben License Key Input
|
||
+ - Placeholder und Pattern-Attribut für Format-Hinweis
|
||
+ - Auto-Uppercase bei manueller Eingabe
|
||
+ - Visuelles Feedback bei erfolgreicher Generierung
|
||
+ - Format-Hinweis unter dem Eingabefeld
|
||
+
|
||
+4. **JavaScript-Features:**
|
||
+ - AJAX-basierte Key-Generierung ohne Seiten-Reload
|
||
+ - Automatische Prüfung bei Lizenztyp-Änderung
|
||
+ - Ladeindikator während der Generierung
|
||
+ - Fehlerbehandlung mit Benutzer-Feedback
|
||
+ - Standard-Datum-Einstellungen (heute + 1 Jahr)
|
||
+
|
||
+5. **Validierung:**
|
||
+ - Server-seitige Format-Validierung beim Speichern
|
||
+ - Flash-Message bei ungültigem Format
|
||
+ - Automatische Großschreibung des Keys
|
||
+ - Pattern-Validierung im HTML-Formular
|
||
+
|
||
+6. **Weitere Fixes:**
|
||
+ - Form Action von "/" auf "/create" korrigiert
|
||
+ - Flash-Messages mit Bootstrap Toasts implementiert
|
||
+ - GENERATE_KEY Aktion zum Audit-Log hinzugefügt (Farbe: #20c997)
|
||
+
|
||
+**Technische Details:**
|
||
+- Keine vorhersagbaren Muster durch `secrets.choice()`
|
||
+- Datum im Key zeigt Erstellungszeitpunkt
|
||
+- Lizenztyp direkt im Key erkennbar
|
||
+- Kollisionsprüfung gegen Datenbank
|
||
+
|
||
+**Status:**
|
||
+- ✅ Backend-Generierung vollständig implementiert
|
||
+- ✅ Frontend mit Generate-Button und JavaScript
|
||
+- ✅ Validierung und Fehlerbehandlung
|
||
+- ✅ Audit-Log-Integration
|
||
+- ✅ Form-Action-Bug behoben
|
||
+
|
||
+### 2025-06-07 - Batch-Lizenzgenerierung implementiert
|
||
+- Mehrere Lizenzen auf einmal für einen Kunden erstellen
|
||
+
|
||
+**Implementierte Features:**
|
||
+
|
||
+1. **Batch-Formular (/batch):**
|
||
+ - Kunde und E-Mail eingeben
|
||
+ - Anzahl der Lizenzen (1-100)
|
||
+ - Lizenztyp (Vollversion/Testversion)
|
||
+ - Gültigkeitszeitraum für alle Lizenzen
|
||
+ - Vorschau-Modal zeigt Key-Format
|
||
+ - Standard-Datum-Einstellungen (heute + 1 Jahr)
|
||
+
|
||
+2. **Backend-Verarbeitung:**
|
||
+ - Route `/batch` für GET (Formular) und POST (Generierung)
|
||
+ - Generiert die angegebene Anzahl eindeutiger Keys
|
||
+ - Speichert alle in einer Transaktion
|
||
+ - Kunde wird automatisch angelegt (falls nicht vorhanden)
|
||
+ - ON CONFLICT für existierende Kunden
|
||
+ - Audit-Log-Eintrag mit CREATE_BATCH Aktion
|
||
+
|
||
+3. **Ergebnis-Seite:**
|
||
+ - Zeigt alle generierten Lizenzen in Tabellenform
|
||
+ - Kundeninformationen und Gültigkeitszeitraum
|
||
+ - Einzelne Keys können kopiert werden (📋 Button)
|
||
+ - Alle Keys auf einmal kopieren
|
||
+ - Druckfunktion für physische Ausgabe
|
||
+ - Link zur Lizenzübersicht mit Kundenfilter
|
||
+
|
||
+4. **Export-Funktionalität:**
|
||
+ - Route `/batch/export` für CSV-Download
|
||
+ - Speichert Batch-Daten in Session für Export
|
||
+ - CSV mit UTF-8 BOM für Excel-Kompatibilität
|
||
+ - Enthält Kundeninfo, Generierungsdatum und alle Keys
|
||
+ - Format: Nr;Lizenzschlüssel;Typ
|
||
+ - Dateiname: batch_licenses_KUNDE_TIMESTAMP.csv
|
||
+
|
||
+5. **Integration:**
|
||
+ - Batch-Button in Navigation (Dashboard, Einzellizenz-Seite)
|
||
+ - CREATE_BATCH Aktion im Audit-Log (Farbe: #6610f2)
|
||
+ - Session-basierte Export-Daten
|
||
+ - Flash-Messages für Feedback
|
||
+
|
||
+**Sicherheit:**
|
||
+- Limit von 100 Lizenzen pro Batch
|
||
+- Login-Required für alle Routen
|
||
+- Transaktionale Datenbank-Operationen
|
||
+- Validierung der Eingaben
|
||
+
|
||
+**Beispiel-Workflow:**
|
||
+1. Admin geht zu `/batch`
|
||
+2. Gibt Kunde "Firma GmbH", Anzahl "25", Typ "Vollversion" ein
|
||
+3. System generiert 25 eindeutige Keys
|
||
+4. Ergebnis-Seite zeigt alle Keys
|
||
+5. Admin kann CSV exportieren oder Keys kopieren
|
||
+6. Kunde erhält die Lizenzen
|
||
+
|
||
+**Status:**
|
||
+- ✅ Batch-Formular vollständig implementiert
|
||
+- ✅ Backend-Generierung mit Transaktionen
|
||
+- ✅ Export als CSV
|
||
+- ✅ Copy-to-Clipboard Funktionalität
|
||
+- ✅ Audit-Log-Integration
|
||
+- ✅ Navigation aktualisiert
|
||
+
|
||
+## 2025-06-06: Implementierung Searchable Dropdown für Kundenauswahl
|
||
+
|
||
+**Problem:**
|
||
+- Bei der Lizenzerstellung wurde immer ein neuer Kunde angelegt
|
||
+- Keine Möglichkeit, Lizenzen für bestehende Kunden zu erstellen
|
||
+- Bei vielen Kunden wäre ein normales Dropdown unübersichtlich
|
||
+
|
||
+**Lösung:**
|
||
+1. **Select2 Library** für searchable Dropdown integriert
|
||
+2. **API-Endpoint `/api/customers`** für die Kundensuche erstellt
|
||
+3. **Frontend angepasst:**
|
||
+ - Searchable Dropdown mit Live-Suche
|
||
+ - Option "Neuer Kunde" im Dropdown
|
||
+ - Eingabefelder erscheinen nur bei "Neuer Kunde"
|
||
+4. **Backend-Logik verbessert:**
|
||
+ - Prüfung ob neuer oder bestehender Kunde
|
||
+ - E-Mail-Duplikatsprüfung vor Kundenerstellung
|
||
+ - Separate Audit-Logs für Kunde und Lizenz
|
||
+5. **Datenbank:**
|
||
+ - UNIQUE Constraint auf E-Mail-Spalte hinzugefügt
|
||
+
|
||
+**Änderungen:**
|
||
+- `app.py`: Neuer API-Endpoint `/api/customers`, angepasste Routes `/create` und `/batch`
|
||
+- `base.html`: Select2 CSS und JS eingebunden
|
||
+- `index.html`: Kundenauswahl mit Select2 implementiert
|
||
+- `batch_form.html`: Kundenauswahl mit Select2 implementiert
|
||
+- `init.sql`: UNIQUE Constraint für E-Mail
|
||
+
|
||
+**Status:**
|
||
+- ✅ API-Endpoint funktioniert mit Pagination
|
||
+- ✅ Select2 Dropdown mit Suchfunktion
|
||
+- ✅ Neue/bestehende Kunden können ausgewählt werden
|
||
+- ✅ E-Mail-Duplikate werden verhindert
|
||
+- ✅ Sowohl Einzellizenz als auch Batch unterstützt
|
||
+
|
||
+## 2025-06-06: Automatische Ablaufdatum-Berechnung
|
||
+
|
||
+**Problem:**
|
||
+- Manuelles Eingeben von Start- und Enddatum war umständlich
|
||
+- Fehleranfällig bei der Datumseingabe
|
||
+- Nicht intuitiv für Standard-Laufzeiten
|
||
+
|
||
+**Lösung:**
|
||
+1. **Frontend-Änderungen:**
|
||
+ - Startdatum + Laufzeit (Zahl) + Einheit (Tage/Monate/Jahre)
|
||
+ - Ablaufdatum wird automatisch berechnet und angezeigt (read-only)
|
||
+ - Standard: 1 Jahr Laufzeit voreingestellt
|
||
+2. **Backend-Validierung:**
|
||
+ - Server-seitige Berechnung zur Sicherheit
|
||
+ - Verwendung von `python-dateutil` für korrekte Monats-/Jahresberechnungen
|
||
+3. **Benutzerfreundlichkeit:**
|
||
+ - Sofortige Neuberechnung bei Änderungen
|
||
+ - Visuelle Hervorhebung des berechneten Datums
|
||
+
|
||
+**Änderungen:**
|
||
+- `index.html`: Laufzeit-Eingabe statt Ablaufdatum
|
||
+- `batch_form.html`: Laufzeit-Eingabe statt Ablaufdatum
|
||
+- `app.py`: Datum-Berechnung in `/create` und `/batch` Routes
|
||
+- `requirements.txt`: `python-dateutil` hinzugefügt
|
||
+
|
||
+**Status:**
|
||
+- ✅ Automatische Berechnung funktioniert
|
||
+- ✅ Frontend zeigt berechnetes Datum sofort an
|
||
+- ✅ Backend validiert die Berechnung
|
||
+- ✅ Standardwert (1 Jahr) voreingestellt
|
||
+
|
||
+## 2025-06-06: Bugfix - created_at für licenses Tabelle
|
||
+
|
||
+**Problem:**
|
||
+- Batch-Generierung schlug fehl mit "Fehler bei der Batch-Generierung!"
|
||
+- INSERT Statement versuchte `created_at` zu setzen, aber Spalte existierte nicht
|
||
+- Inkonsistenz: Einzellizenzen hatten kein created_at, Batch-Lizenzen versuchten es zu setzen
|
||
+
|
||
+**Lösung:**
|
||
+1. **Datenbank-Schema erweitert:**
|
||
+ - `created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` zur licenses Tabelle hinzugefügt
|
||
+ - Migration für bestehende Datenbanken implementiert
|
||
+ - Konsistent mit customers Tabelle
|
||
+2. **Code bereinigt:**
|
||
+ - Explizites `created_at` aus Batch-INSERT entfernt
|
||
+ - Datenbank setzt nun automatisch den Zeitstempel bei ALLEN Lizenzen
|
||
+
|
||
+**Änderungen:**
|
||
+- `init.sql`: created_at Spalte zur licenses Tabelle mit DEFAULT-Wert
|
||
+- `init.sql`: Migration für bestehende Datenbanken
|
||
+- `app.py`: Entfernt explizites created_at aus batch_licenses()
|
||
+
|
||
+**Status:**
|
||
+- ✅ Alle Lizenzen haben nun automatisch einen Erstellungszeitstempel
|
||
+- ✅ Batch-Generierung funktioniert wieder
|
||
+- ✅ Konsistente Zeitstempel für Audit-Zwecke
|
||
+
|
||
+## 2025-06-06: Status "Deaktiviert" für manuell abgeschaltete Lizenzen
|
||
+
|
||
+**Problem:**
|
||
+- Dashboard zeigte nur "aktiv" und "abgelaufen" als Status
|
||
+- Manuell deaktivierte Lizenzen (is_active = FALSE) wurden nicht korrekt angezeigt
|
||
+- Filter für "inactive" existierte, aber Status wurde nicht richtig berechnet
|
||
+
|
||
+**Lösung:**
|
||
+1. **Status-Berechnung erweitert:**
|
||
+ - CASE-Statement prüft zuerst `is_active = FALSE`
|
||
+ - Status "deaktiviert" wird vor anderen Status geprüft
|
||
+ - Reihenfolge: deaktiviert → abgelaufen → läuft bald ab → aktiv
|
||
+2. **Dashboard-Statistik erweitert:**
|
||
+ - Neue Zählung für deaktivierte Lizenzen
|
||
+ - Variable `inactive_licenses` im stats Dictionary
|
||
+
|
||
+**Änderungen:**
|
||
+- `app.py`: Dashboard - Status-Berechnung für letzte 5 Lizenzen
|
||
+- `app.py`: Lizenzübersicht - Status-Berechnung in der Hauptabfrage
|
||
+- `app.py`: Export - Status-Berechnung für CSV/Excel Export
|
||
+- `app.py`: Dashboard - Neue Statistik für deaktivierte Lizenzen
|
||
+
|
||
+**Status:**
|
||
+- ✅ "Deaktiviert" wird korrekt als Status angezeigt
|
||
+- ✅ Dashboard zeigt Anzahl deaktivierter Lizenzen
|
||
+- ✅ Export enthält korrekten Status
|
||
+- ✅ Konsistente Status-Anzeige überall
|
||
+
|
||
+## 2025-06-08: SSL-Sicherheit verbessert - Chrome Warnung behoben
|
||
+
|
||
+**Problem:**
|
||
+- Chrome zeigte Warnung "Die Verbindung zu dieser Website ist nicht sicher"
|
||
+- Nginx erlaubte schwache Cipher Suites (WEAK) ohne Perfect Forward Secrecy
|
||
+- Veraltete SSL-Konfiguration mit `ssl_ciphers HIGH:!aNULL:!MD5;`
|
||
+
|
||
+**Lösung:**
|
||
+1. **Moderne Cipher Suite Konfiguration:**
|
||
+ - Nur sichere ECDHE und DHE Cipher Suites
|
||
+ - Entfernung aller RSA-only Cipher Suites
|
||
+ - Perfect Forward Secrecy für alle Verbindungen
|
||
+2. **SSL-Optimierungen:**
|
||
+ - Session Cache aktiviert (1 Tag Timeout)
|
||
+ - OCSP Stapling für bessere Performance
|
||
+ - DH Parameters (2048 bit) für zusätzliche Sicherheit
|
||
+3. **Resolver-Konfiguration:**
|
||
+ - Google DNS Server für OCSP Stapling
|
||
+
|
||
+**Änderungen:**
|
||
+- `v2_nginx/nginx.conf`: Komplett überarbeitete SSL-Konfiguration
|
||
+- `v2_nginx/ssl/dhparam.pem`: Neue 2048-bit DH Parameters generiert
|
||
+- `v2_nginx/Dockerfile`: COPY Befehl für dhparam.pem hinzugefügt
|
||
+
|
||
+**Status:**
|
||
+- ✅ Nur noch sichere Cipher Suites aktiv
|
||
+- ✅ Perfect Forward Secrecy gewährleistet
|
||
+- ✅ OCSP Stapling aktiviert
|
||
+- ✅ Chrome Sicherheitswarnung behoben
|
||
+
|
||
+**Hinweis:** Nach dem Rebuild des nginx Containers wird die Verbindung als sicher angezeigt.
|
||
+
|
||
+## 2025-06-08: CAPTCHA-Login-Bug behoben
|
||
+
|
||
+**Problem:**
|
||
+- Nach 2 fehlgeschlagenen Login-Versuchen wurde CAPTCHA angezeigt
|
||
+- Da keine CAPTCHA-Keys konfiguriert waren (für PoC), konnte man sich nicht mehr einloggen
|
||
+- Selbst mit korrektem Passwort war Login blockiert
|
||
+- Fehlermeldung "CAPTCHA ERFORDERLICH!" erschien immer
|
||
+
|
||
+**Lösung:**
|
||
+1. **CAPTCHA-Prüfung nur wenn Keys vorhanden:**
|
||
+ - `recaptcha_site_key` wird vor CAPTCHA-Prüfung geprüft
|
||
+ - Wenn keine Keys konfiguriert → kein CAPTCHA-Check
|
||
+ - CAPTCHA wird nur angezeigt wenn Keys existieren
|
||
+2. **Template-Anpassungen:**
|
||
+ - login.html zeigt CAPTCHA nur wenn `recaptcha_site_key` vorhanden
|
||
+ - Kein Test-Key mehr als Fallback
|
||
+3. **Konsistente Logik:**
|
||
+ - show_captcha prüft jetzt auch ob Keys vorhanden sind
|
||
+ - Bei GET und POST Requests gleiche Logik
|
||
+
|
||
+**Änderungen:**
|
||
+- `v2_adminpanel/app.py`: CAPTCHA-Check nur wenn `RECAPTCHA_SITE_KEY` existiert
|
||
+- `v2_adminpanel/templates/login.html`: CAPTCHA nur anzeigen wenn Keys vorhanden
|
||
+
|
||
+**Status:**
|
||
+- ✅ Login funktioniert wieder nach 2+ Fehlversuchen
|
||
+- ✅ CAPTCHA wird nur angezeigt wenn Keys konfiguriert sind
|
||
+- ✅ Für PoC-Phase ohne CAPTCHA nutzbar
|
||
+- ✅ Produktiv-ready wenn CAPTCHA-Keys eingetragen werden
|
||
+
|
||
+### 2025-06-08: Zeitzone auf Europe/Berlin umgestellt
|
||
+
|
||
+**Problem:**
|
||
+- Alle Zeitstempel wurden in UTC gespeichert und angezeigt
|
||
+- Backup-Dateinamen zeigten UTC-Zeit statt deutsche Zeit
|
||
+- Verwirrung bei Zeitangaben im Admin Panel und Logs
|
||
+
|
||
+**Lösung:**
|
||
+1. **Docker Container Zeitzone konfiguriert:**
|
||
+ - Alle Dockerfiles mit `TZ=Europe/Berlin` und tzdata Installation
|
||
+ - PostgreSQL mit `PGTZ=Europe/Berlin` für Datenbank-Zeitzone
|
||
+ - Explizite Zeitzone-Dateien in /etc/localtime und /etc/timezone
|
||
+
|
||
+2. **Python Code angepasst:**
|
||
+ - Import von `zoneinfo.ZoneInfo` für Zeitzonenunterstützung
|
||
+ - Alle `datetime.now()` Aufrufe mit `ZoneInfo("Europe/Berlin")`
|
||
+ - `.replace(tzinfo=None)` für Kompatibilität mit timezone-unaware Timestamps
|
||
+
|
||
+3. **PostgreSQL Konfiguration:**
|
||
+ - `SET timezone = 'Europe/Berlin';` in init.sql
|
||
+ - Umgebungsvariablen TZ und PGTZ in docker-compose.yaml
|
||
+
|
||
+4. **docker-compose.yaml erweitert:**
|
||
+ - `TZ: Europe/Berlin` für alle Services
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/Dockerfile`: Zeitzone und tzdata hinzugefügt
|
||
+- `v2_postgres/Dockerfile`: Zeitzone und tzdata hinzugefügt
|
||
+- `v2_nginx/Dockerfile`: Zeitzone und tzdata hinzugefügt
|
||
+- `v2_lizenzserver/Dockerfile`: Zeitzone und tzdata hinzugefügt
|
||
+- `v2_adminpanel/app.py`: 14 datetime.now() Aufrufe mit Zeitzone versehen
|
||
+- `v2_adminpanel/init.sql`: PostgreSQL Zeitzone gesetzt
|
||
+- `v2/docker-compose.yaml`: TZ Environment-Variable für alle Services
|
||
+
|
||
+**Ergebnis:**
|
||
+- ✅ Alle neuen Zeitstempel werden in deutscher Zeit (Europe/Berlin) gespeichert
|
||
+- ✅ Backup-Dateinamen zeigen korrekte deutsche Zeit
|
||
+- ✅ Admin Panel zeigt alle Zeiten in deutscher Zeitzone
|
||
+- ✅ Automatische Anpassung bei Sommer-/Winterzeit
|
||
+- ✅ Konsistente Zeitangaben über alle Komponenten
|
||
+
|
||
+**Hinweis:** Nach diesen Änderungen müssen die Docker Container neu gebaut werden:
|
||
+```bash
|
||
+docker-compose down
|
||
+docker-compose build
|
||
+docker-compose up -d
|
||
+```
|
||
+
|
||
+### 2025-06-08: Zeitzone-Fix - PostgreSQL Timestamps
|
||
+
|
||
+**Problem nach erster Implementierung:**
|
||
+- Trotz Zeitzoneneinstellung wurden Zeiten immer noch in UTC angezeigt
|
||
+- Grund: PostgreSQL Tabellen verwendeten `TIMESTAMP WITHOUT TIME ZONE`
|
||
+
|
||
+**Zusätzliche Lösung:**
|
||
+1. **Datenbankschema angepasst:**
|
||
+ - Alle `TIMESTAMP` Spalten auf `TIMESTAMP WITH TIME ZONE` geändert
|
||
+ - Betrifft: created_at, timestamp, started_at, ended_at, last_heartbeat, etc.
|
||
+ - Migration für bestehende Datenbanken berücksichtigt
|
||
+
|
||
+2. **SQL-Abfragen vereinfacht:**
|
||
+ - `AT TIME ZONE 'Europe/Berlin'` nicht mehr nötig
|
||
+ - PostgreSQL handhabt Zeitzonenkonvertierung automatisch
|
||
+
|
||
+**Geänderte Datei:**
|
||
+- `v2_adminpanel/init.sql`: Alle TIMESTAMP Felder mit WITH TIME ZONE
|
||
+
|
||
+**Wichtig:** Bei bestehenden Installationen muss die Datenbank neu initialisiert oder manuell migriert werden:
|
||
+```sql
|
||
+ALTER TABLE customers ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE;
|
||
+ALTER TABLE licenses ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE;
|
||
+ALTER TABLE sessions ALTER COLUMN started_at TYPE TIMESTAMP WITH TIME ZONE;
|
||
+ALTER TABLE sessions ALTER COLUMN last_heartbeat TYPE TIMESTAMP WITH TIME ZONE;
|
||
+ALTER TABLE sessions ALTER COLUMN ended_at TYPE TIMESTAMP WITH TIME ZONE;
|
||
+ALTER TABLE audit_log ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE;
|
||
+ALTER TABLE backup_history ALTER COLUMN created_at TYPE TIMESTAMP WITH TIME ZONE;
|
||
+ALTER TABLE login_attempts ALTER COLUMN first_attempt TYPE TIMESTAMP WITH TIME ZONE;
|
||
+ALTER TABLE login_attempts ALTER COLUMN last_attempt TYPE TIMESTAMP WITH TIME ZONE;
|
||
+ALTER TABLE login_attempts ALTER COLUMN blocked_until TYPE TIMESTAMP WITH TIME ZONE;
|
||
+```
|
||
+
|
||
+### 2025-06-08: UI/UX Überarbeitung - Phase 1 (Navigation)
|
||
+
|
||
+**Problem:**
|
||
+- Inkonsistente Navigation zwischen verschiedenen Seiten
|
||
+- Zu viele Navigationspunkte im Dashboard
|
||
+- Verwirrende Benutzerführung
|
||
+
|
||
+**Lösung:**
|
||
+1. **Dashboard vereinfacht:**
|
||
+ - Nur noch 3 Buttons: Neue Lizenz, Batch-Lizenzen, Log
|
||
+ - Statistik-Karten wurden klickbar gemacht (verlinken zu jeweiligen Seiten)
|
||
+ - "Audit" wurde zu "Log" umbenannt
|
||
+
|
||
+2. **Navigation konsistent gemacht:**
|
||
+ - Navbar-Brand "AccountForger - Admin Panel" ist jetzt klickbar und führt zum Dashboard
|
||
+ - Keine Log-Links mehr in Unterseiten
|
||
+ - Konsistente "Dashboard" Buttons in allen Unterseiten
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/templates/base.html`: Navbar-Brand klickbar gemacht
|
||
+- `v2_adminpanel/templates/dashboard.html`: Navigation reduziert, Karten klickbar
|
||
+- `v2_adminpanel/templates/*.html`: Konsistente Dashboard-Links
|
||
+
|
||
+### 2025-06-08: UI/UX Überarbeitung - Phase 2 (Visuelle Verbesserungen)
|
||
+
|
||
+**Implementierte Verbesserungen:**
|
||
+1. **Größere Icons in Statistik-Karten:**
|
||
+ - Icon-Größe auf 3rem erhöht
|
||
+ - Bessere visuelle Hierarchie
|
||
+
|
||
+2. **Donut-Chart für Lizenzen:**
|
||
+ - Chart.js Integration für Lizenzstatistik
|
||
+ - Zeigt Verhältnis Aktiv/Abgelaufen
|
||
+ - UPDATE: Später wieder entfernt auf Benutzerwunsch
|
||
+
|
||
+3. **Pulse-Effekt für aktive Sessions:**
|
||
+ - CSS-Animation für aktive Sessions
|
||
+ - Visueller Indikator für Live-Aktivität
|
||
+
|
||
+4. **Progress-Bar für Backup-Status:**
|
||
+ - Zeigt visuell den Erfolg des letzten Backups
|
||
+ - Inkl. Dateigröße und Dauer
|
||
+
|
||
+5. **Konsistente Farbcodierung:**
|
||
+ - CSS-Variablen für Statusfarben
|
||
+ - Globale Klassen für konsistente Darstellung
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/templates/base.html`: Globale CSS-Variablen und Statusklassen
|
||
+- `v2_adminpanel/templates/dashboard.html`: Visuelle Verbesserungen implementiert
|
||
+
|
||
+### 2025-06-08: UI/UX Überarbeitung - Phase 3 (Tabellen-Optimierungen)
|
||
+
|
||
+**Problem:**
|
||
+- Tabellen waren schwer zu navigieren bei vielen Einträgen
|
||
+- Keine Möglichkeit für Bulk-Operationen
|
||
+- Umständliches Kopieren von Lizenzschlüsseln
|
||
+
|
||
+**Lösung:**
|
||
+1. **Sticky Headers:**
|
||
+ - Tabellenköpfe bleiben beim Scrollen sichtbar
|
||
+ - CSS-Klasse `.table-sticky` mit `position: sticky`
|
||
+
|
||
+2. **Inline-Actions:**
|
||
+ - Copy-Button direkt neben Lizenzschlüsseln
|
||
+ - Toggle-Switches für Aktiv/Inaktiv-Status
|
||
+ - Visuelles Feedback bei Aktionen
|
||
+
|
||
+3. **Bulk-Actions:**
|
||
+ - Checkboxen für Mehrfachauswahl
|
||
+ - "Select All" Funktionalität
|
||
+ - Bulk-Actions Bar mit Aktivieren/Deaktivieren/Löschen
|
||
+ - JavaScript für dynamische Anzeige
|
||
+
|
||
+4. **API-Endpoints hinzugefügt:**
|
||
+ - `/api/license/<id>/toggle` - Toggle einzelner Lizenzstatus
|
||
+ - `/api/licenses/bulk-activate` - Mehrere Lizenzen aktivieren
|
||
+ - `/api/licenses/bulk-deactivate` - Mehrere Lizenzen deaktivieren
|
||
+ - `/api/licenses/bulk-delete` - Mehrere Lizenzen löschen
|
||
+
|
||
+5. **Beispieldaten eingefügt:**
|
||
+ - 15 Testkunden
|
||
+ - 18 Lizenzen (verschiedene Status)
|
||
+ - Sessions, Audit-Logs, Login-Attempts
|
||
+ - Backup-Historie
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/templates/base.html`: CSS für Sticky-Tables und Bulk-Actions
|
||
+- `v2_adminpanel/templates/licenses.html`: Komplette Tabellen-Überarbeitung
|
||
+- `v2_adminpanel/app.py`: 4 neue API-Endpoints für Toggle und Bulk-Operationen
|
||
+- `v2_adminpanel/sample_data.sql`: Umfangreiche Testdaten erstellt
|
||
+
|
||
+**Bugfix:**
|
||
+- API-Endpoints versuchten `updated_at` zu setzen, obwohl die Spalte nicht existiert
|
||
+- Entfernt aus allen 3 betroffenen Endpoints
|
||
+
|
||
+**Status:**
|
||
+- ✅ Sticky Headers funktionieren
|
||
+- ✅ Copy-Buttons mit Clipboard-API
|
||
+- ✅ Toggle-Switches ändern Lizenzstatus
|
||
+- ✅ Bulk-Operationen vollständig implementiert
|
||
+- ✅ Testdaten erfolgreich eingefügt
|
||
+
|
||
+### 2025-06-08: UI/UX Überarbeitung - Phase 4 (Sortierbare Tabellen)
|
||
+
|
||
+**Problem:**
|
||
+- Keine Möglichkeit, Tabellen nach verschiedenen Spalten zu sortieren
|
||
+- Besonders bei großen Datenmengen schwer zu navigieren
|
||
+
|
||
+**Lösung - Hybrid-Ansatz:**
|
||
+1. **Client-seitige Sortierung für kleine Tabellen:**
|
||
+ - Dashboard (3 kleine Übersichtstabellen)
|
||
+ - Blocked IPs (typischerweise wenige Einträge)
|
||
+ - Backups (begrenzte Anzahl)
|
||
+ - JavaScript-basierte Sortierung ohne Reload
|
||
+
|
||
+2. **Server-seitige Sortierung für große Tabellen:**
|
||
+ - Licenses (potenziell tausende Einträge)
|
||
+ - Customers (viele Kunden möglich)
|
||
+ - Audit Log (wächst kontinuierlich)
|
||
+ - Sessions (viele aktive/beendete Sessions)
|
||
+ - URL-Parameter für Sortierung mit SQL ORDER BY
|
||
+
|
||
+**Implementierung:**
|
||
+1. **Client-seitige Sortierung:**
|
||
+ - Generische JavaScript-Funktion in base.html
|
||
+ - CSS-Klasse `.sortable-table` für betroffene Tabellen
|
||
+ - Sortier-Indikatoren (↑↓↕) bei Hover/Active
|
||
+ - Unterstützung für Text, Zahlen und deutsche Datumsformate
|
||
+
|
||
+2. **Server-seitige Sortierung:**
|
||
+ - Query-Parameter `sort` und `order` in Routes
|
||
+ - Whitelist für erlaubte Sortierfelder (SQL-Injection-Schutz)
|
||
+ - Makro-Funktionen für sortierbare Header
|
||
+ - Sortier-Parameter in Pagination-Links erhalten
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/templates/base.html`: CSS und JavaScript für Sortierung
|
||
+- `v2_adminpanel/templates/dashboard.html`: Client-seitige Sortierung
|
||
+- `v2_adminpanel/templates/blocked_ips.html`: Client-seitige Sortierung
|
||
+- `v2_adminpanel/templates/backups.html`: Client-seitige Sortierung
|
||
+- `v2_adminpanel/templates/licenses.html`: Server-seitige Sortierung
|
||
+- `v2_adminpanel/templates/customers.html`: Server-seitige Sortierung
|
||
+- `v2_adminpanel/templates/audit_log.html`: Server-seitige Sortierung
|
||
+- `v2_adminpanel/templates/sessions.html`: Server-seitige Sortierung (2 Tabellen)
|
||
+- `v2_adminpanel/app.py`: 4 Routes erweitert für Sortierung
|
||
+
|
||
+**Besonderheiten:**
|
||
+- Sessions-Seite hat zwei unabhängige Tabellen mit eigenen Sortierparametern
|
||
+- Intelligente Datentyp-Erkennung (numeric, date) für korrekte Sortierung
|
||
+- Visuelle Sortier-Indikatoren zeigen aktuelle Sortierung
|
||
+- Alle anderen Filter und Suchparameter bleiben bei Sortierung erhalten
|
||
+
|
||
+**Status:**
|
||
+- ✅ Client-seitige Sortierung für kleine Tabellen
|
||
+- ✅ Server-seitige Sortierung für große Tabellen
|
||
+- ✅ Sortier-Indikatoren und visuelle Rückmeldung
|
||
+- ✅ SQL-Injection-Schutz durch Whitelisting
|
||
+- ✅ Vollständige Integration mit bestehenden Features
|
||
+
|
||
+### 2025-06-08: Bugfix - Sortierlogik korrigiert
|
||
+
|
||
+**Problem:**
|
||
+- Sortierung funktionierte nicht korrekt
|
||
+- Beim Klick auf Spaltenköpfe wurde immer absteigend sortiert
|
||
+- Toggle zwischen ASC/DESC funktionierte nicht
|
||
+
|
||
+**Ursachen:**
|
||
+1. **Falsche Bedingungslogik**: Die ursprüngliche Implementierung verwendete eine fehlerhafte Ternär-Bedingung
|
||
+2. **Berechnete Felder**: Das 'status' Feld in der Lizenztabelle konnte nicht direkt sortiert werden
|
||
+
|
||
+**Lösung:**
|
||
+1. **Sortierlogik korrigiert:**
|
||
+ - Bei neuer Spalte: Immer aufsteigend (ASC) beginnen
|
||
+ - Bei gleicher Spalte: Toggle zwischen ASC und DESC
|
||
+ - Implementiert durch bedingte Links in den Makros
|
||
+
|
||
+2. **Spezialbehandlung für berechnete Felder:**
|
||
+ - Status-Feld verwendet CASE-Statement in ORDER BY
|
||
+ - Wiederholt die gleiche Logik wie im SELECT
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/templates/licenses.html`: Sortierlogik korrigiert
|
||
+- `v2_adminpanel/templates/customers.html`: Sortierlogik korrigiert
|
||
+- `v2_adminpanel/templates/audit_log.html`: Sortierlogik korrigiert
|
||
+- `v2_adminpanel/templates/sessions.html`: Sortierlogik für beide Tabellen korrigiert
|
||
+- `v2_adminpanel/app.py`: Spezialbehandlung für Status-Feld in licenses Route
|
||
+
|
||
+**Verhalten nach Fix:**
|
||
+- ✅ Erster Klick auf Spalte: Aufsteigend sortieren
|
||
+- ✅ Zweiter Klick: Absteigend sortieren
|
||
+- ✅ Weitere Klicks: Toggle zwischen ASC/DESC
|
||
+- ✅ Sortierung funktioniert korrekt mit Pagination und Filtern
|
||
+
|
||
+### 2025-06-09: Port 8443 geschlossen - API nur noch über Nginx
|
||
+
|
||
+**Problem:**
|
||
+- Doppelte Verfügbarkeit des License Servers (Port 8443 + Nginx) machte keinen Sinn
|
||
+- Inkonsistente Sicherheitskonfiguration (Nginx hatte Security Headers, Port 8443 nicht)
|
||
+- Doppelte SSL-Konfiguration nötig
|
||
+- Verwirrung welcher Zugangsweg genutzt werden soll
|
||
+
|
||
+**Lösung:**
|
||
+- Port-Mapping für License Server in docker-compose.yaml entfernt
|
||
+- API nur noch über Nginx erreichbar: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
+- Interne Kommunikation zwischen Nginx und License Server bleibt bestehen
|
||
+
|
||
+**Vorteile:**
|
||
+- ✅ Einheitliche Sicherheitskonfiguration (Security Headers, HSTS)
|
||
+- ✅ Zentrale SSL-Verwaltung nur in Nginx
|
||
+- ✅ Möglichkeit für Rate Limiting und zentrales Logging
|
||
+- ✅ Keine zusätzlichen offenen Ports (nur 80/443)
|
||
+- ✅ Professionellere API-URL ohne Port-Angabe
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2/docker-compose.yaml`: Port-Mapping "8443:8443" entfernt
|
||
+
|
||
+**Hinweis für Client-Software:**
|
||
+- API-Endpunkte sind weiterhin unter https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com erreichbar
|
||
+- Keine Änderung der API-URLs nötig, nur Port 8443 ist nicht mehr direkt zugänglich
|
||
+
|
||
+**Status:**
|
||
+- ✅ Port 8443 geschlossen
|
||
+- ✅ API nur noch über Nginx Reverse Proxy erreichbar
|
||
+- ✅ Sicherheit erhöht durch zentrale Verwaltung
|
||
+
|
||
+### 2025-06-09: Live-Filtering implementiert
|
||
+
|
||
+**Problem:**
|
||
+- Benutzer mussten immer auf "Filter anwenden" klicken
|
||
+- Umständliche Bedienung, besonders bei mehreren Filterkriterien
|
||
+- Nicht zeitgemäße User Experience
|
||
+
|
||
+**Lösung:**
|
||
+- JavaScript Event-Listener für automatisches Filtern
|
||
+- Text-Eingaben: 300ms Debouncing (verzögerte Suche nach Tipp-Pause)
|
||
+- Dropdowns: Sofortiges Filtern bei Änderung
|
||
+- "Filter anwenden" Button entfernt, nur "Zurücksetzen" bleibt
|
||
+
|
||
+**Implementierte Live-Filter:**
|
||
+1. **Lizenzübersicht** (licenses.html):
|
||
+ - Suchfeld mit Debouncing
|
||
+ - Typ-Dropdown (Vollversion/Testversion)
|
||
+ - Status-Dropdown (Aktiv/Ablaufend/Abgelaufen/Deaktiviert)
|
||
+
|
||
+2. **Kundenübersicht** (customers.html):
|
||
+ - Suchfeld mit Debouncing
|
||
+ - "Suchen" Button entfernt
|
||
+
|
||
+3. **Audit-Log** (audit_log.html):
|
||
+ - Benutzer-Textfeld mit Debouncing
|
||
+ - Aktion-Dropdown
|
||
+ - Entität-Dropdown
|
||
+
|
||
+**Technische Details:**
|
||
+- `addEventListener('input')` für Textfelder
|
||
+- `addEventListener('change')` für Select-Elemente
|
||
+- `setTimeout()` mit 300ms für Debouncing
|
||
+- Automatisches `form.submit()` bei Änderungen
|
||
+
|
||
+**Vorteile:**
|
||
+- ✅ Schnellere und intuitivere Bedienung
|
||
+- ✅ Weniger Klicks erforderlich
|
||
+- ✅ Moderne User Experience
|
||
+- ✅ Besonders hilfreich bei komplexen Filterkriterien
|
||
+
|
||
+**Status:**
|
||
+- ✅ Live-Filtering auf allen Hauptseiten implementiert
|
||
+- ✅ Debouncing verhindert zu viele Server-Requests
|
||
+- ✅ Zurücksetzen-Button bleibt für schnelles Löschen aller Filter
|
||
+
|
||
+### 2025-06-09: Resource Pool System implementiert (Phase 1 & 2)
|
||
+
|
||
+**Ziel:**
|
||
+Ein Pool-System für Domains, IPv4-Adressen und Telefonnummern, wobei bei jeder Lizenzerstellung 1-10 Ressourcen pro Typ zugewiesen werden. Ressourcen haben 3 Status: available, allocated, quarantine.
|
||
+
|
||
+**Phase 1 - Datenbank-Schema (✅ Abgeschlossen):**
|
||
+1. **Neue Tabellen erstellt:**
|
||
+ - `resource_pools` - Haupttabelle für alle Ressourcen
|
||
+ - `resource_history` - Vollständige Historie aller Aktionen
|
||
+ - `resource_metrics` - Performance-Tracking und ROI-Berechnung
|
||
+ - `license_resources` - Zuordnung zwischen Lizenzen und Ressourcen
|
||
+
|
||
+2. **Erweiterte licenses Tabelle:**
|
||
+ - `domain_count`, `ipv4_count`, `phone_count` Spalten hinzugefügt
|
||
+ - Constraints: 0-10 pro Resource-Typ
|
||
+
|
||
+3. **Indizes für Performance:**
|
||
+ - Status, Type, Allocated License, Quarantine Date
|
||
+
|
||
+**Phase 2 - Backend-Implementierung (✅ Abgeschlossen):**
|
||
+1. **Resource Management Routes:**
|
||
+ - `/resources` - Hauptübersicht mit Statistiken
|
||
+ - `/resources/add` - Bulk-Import von Ressourcen
|
||
+ - `/resources/quarantine/<id>` - Ressourcen sperren
|
||
+ - `/resources/release` - Quarantäne aufheben
|
||
+ - `/resources/history/<id>` - Komplette Historie
|
||
+ - `/resources/metrics` - Performance Dashboard
|
||
+ - `/resources/report` - Report-Generator
|
||
+
|
||
+2. **API-Endpunkte:**
|
||
+ - `/api/resources/allocate` - Ressourcen-Zuweisung
|
||
+ - `/api/resources/check-availability` - Verfügbarkeit prüfen
|
||
+
|
||
+3. **Integration in Lizenzerstellung:**
|
||
+ - `create_license()` erweitert um Resource-Allocation
|
||
+ - `batch_licenses()` mit Ressourcen-Prüfung für gesamten Batch
|
||
+ - Transaktionale Sicherheit bei Zuweisung
|
||
+
|
||
+4. **Dashboard-Integration:**
|
||
+ - Resource-Statistiken in Dashboard eingebaut
|
||
+ - Warning-Level basierend auf Verfügbarkeit
|
||
+
|
||
+5. **Navigation erweitert:**
|
||
+ - Resources-Link in Navbar hinzugefügt
|
||
+
|
||
+**Was noch zu tun ist:**
|
||
+
|
||
+### Phase 3 - UI-Komponenten (🔄 Ausstehend):
|
||
+1. **Templates erstellen:**
|
||
+ - `resources.html` - Hauptübersicht mit Drag&Drop
|
||
+ - `add_resources.html` - Formular für Bulk-Import
|
||
+ - `resource_history.html` - Historie-Anzeige
|
||
+ - `resource_metrics.html` - Performance Dashboard
|
||
+
|
||
+2. **Formulare erweitern:**
|
||
+ - `index.html` - Resource-Dropdowns hinzufügen
|
||
+ - `batch_form.html` - Resource-Dropdowns hinzufügen
|
||
+
|
||
+3. **Dashboard-Widget:**
|
||
+ - Resource Pool Statistik mit Ampelsystem
|
||
+ - Warnung bei niedrigem Bestand
|
||
+
|
||
+### Phase 4 - Erweiterte Features (🔄 Ausstehend):
|
||
+1. **Quarantäne-Workflow:**
|
||
+ - Gründe: abuse, defect, maintenance, blacklisted, expired
|
||
+ - Automatische Tests vor Freigabe
|
||
+ - Genehmigungsprozess
|
||
+
|
||
+2. **Performance-Metrics:**
|
||
+ - Täglicher Cronjob für Metriken
|
||
+ - ROI-Berechnung
|
||
+ - Issue-Tracking
|
||
+
|
||
+3. **Report-Generator:**
|
||
+ - Auslastungsreport
|
||
+ - Performance-Report
|
||
+ - Compliance-Report
|
||
+
|
||
+### Phase 5 - Backup erweitern (🔄 Ausstehend):
|
||
+- Neue Tabellen in Backup einbeziehen:
|
||
+ - resource_pools
|
||
+ - resource_history
|
||
+ - resource_metrics
|
||
+ - license_resources
|
||
+
|
||
+### Phase 6 - Testing & Migration (🔄 Ausstehend):
|
||
+1. **Test-Daten generieren:**
|
||
+ - 500 Test-Domains
|
||
+ - 200 Test-IPs
|
||
+ - 100 Test-Telefonnummern
|
||
+
|
||
+2. **Migrations-Script:**
|
||
+ - Bestehende Lizenzen auf default resource_count setzen
|
||
+
|
||
+### Phase 7 - Dokumentation (🔄 Ausstehend):
|
||
+- API-Dokumentation für License Server
|
||
+- Admin-Handbuch für Resource Management
|
||
+
|
||
+**Technische Details:**
|
||
+- 3-Status-System: available/allocated/quarantine
|
||
+- Transaktionale Ressourcen-Zuweisung mit FOR UPDATE Lock
|
||
+- Vollständige Historie mit IP-Tracking
|
||
+- Drag&Drop UI für Resource-Management geplant
|
||
+- Automatische Warnung bei < 50 verfügbaren Ressourcen
|
||
+
|
||
+**Status:**
|
||
+- ✅ Datenbank-Schema komplett
|
||
+- ✅ Backend-Routen implementiert
|
||
+- ✅ Integration in Lizenzerstellung
|
||
+- ❌ UI-Templates fehlen noch
|
||
+- ❌ Erweiterte Features ausstehend
|
||
+- ❌ Testing und Migration offen
|
||
+
|
||
+### 2025-06-09: Resource Pool System UI-Implementierung (Phase 3 & 4)
|
||
+
|
||
+**Phase 3 - UI-Komponenten (✅ Abgeschlossen):**
|
||
+
|
||
+1. **Neue Templates erstellt:**
|
||
+ - `resources.html` - Hauptübersicht mit Statistiken, Filter, Live-Suche, Pagination
|
||
+ - `add_resources.html` - Bulk-Import Formular mit Validierung
|
||
+ - `resource_history.html` - Timeline-Ansicht der Historie mit Details
|
||
+ - `resource_metrics.html` - Performance Dashboard mit Charts
|
||
+ - `resource_report.html` - Report-Generator UI
|
||
+
|
||
+2. **Erweiterte Formulare:**
|
||
+ - `index.html` - Resource-Count Dropdowns (0-10) mit Live-Verfügbarkeitsprüfung
|
||
+ - `batch_form.html` - Resource-Count mit Batch-Berechnung (zeigt Gesamtbedarf)
|
||
+
|
||
+3. **Dashboard-Widget:**
|
||
+ - Resource Pool Statistik mit Ampelsystem implementiert
|
||
+ - Zeigt verfügbare/zugeteilte/quarantäne Ressourcen
|
||
+ - Warnung bei niedrigem Bestand (<50)
|
||
+ - Fortschrittsbalken für visuelle Darstellung
|
||
+
|
||
+4. **Backend-Anpassungen:**
|
||
+ - `resource_history` Route korrigiert für Object-Style Template-Zugriff
|
||
+ - `resources_metrics` Route vollständig implementiert mit Charts-Daten
|
||
+ - `resources_report` Route erweitert für Template-Anzeige und Downloads
|
||
+ - Dashboard erweitert um Resource-Statistiken
|
||
+
|
||
+**Phase 4 - Erweiterte Features (✅ Teilweise):**
|
||
+1. **Report-Generator:**
|
||
+ - Template für Report-Auswahl erstellt
|
||
+ - 4 Report-Typen: Usage, Performance, Compliance, Inventory
|
||
+ - Export als Excel, CSV oder PDF-Vorschau
|
||
+ - Zeitraum-Auswahl mit Validierung
|
||
+
|
||
+**Technische Details der Implementierung:**
|
||
+- Live-Filtering ohne Reload durch JavaScript
|
||
+- AJAX-basierte Verfügbarkeitsprüfung
|
||
+- Bootstrap 5 für konsistentes Design
|
||
+- Chart.js für Metriken-Visualisierung
|
||
+- Responsives Design für alle Templates
|
||
+- Copy-to-Clipboard für Resource-Werte
|
||
+- Modal-Dialoge für Quarantäne-Aktionen
|
||
+
|
||
+**Was noch fehlt:**
|
||
+
|
||
+### Phase 5 - Backup erweitern (🔄 Ausstehend):
|
||
+- Resource-Tabellen in pg_dump einbeziehen:
|
||
+ - resource_pools
|
||
+ - resource_history
|
||
+ - resource_metrics
|
||
+ - license_resources
|
||
+
|
||
+### Phase 6 - Testing & Migration (🔄 Ausstehend):
|
||
+1. **Test-Daten generieren:**
|
||
+ - Script für 500 Test-Domains
|
||
+ - 200 Test-IPv4-Adressen
|
||
+ - 100 Test-Telefonnummern
|
||
+ - Realistische Verteilung über Status
|
||
+
|
||
+2. **Migrations-Script:**
|
||
+ - Bestehende Lizenzen auf Default resource_count setzen
|
||
+ - UPDATE licenses SET domain_count=1, ipv4_count=1, phone_count=1 WHERE ...
|
||
+
|
||
+### Phase 7 - Dokumentation (🔄 Ausstehend):
|
||
+- API-Dokumentation für Resource-Endpunkte
|
||
+- Admin-Handbuch für Resource Management
|
||
+- Troubleshooting-Guide
|
||
+
|
||
+**Offene Punkte für Produktion:**
|
||
+1. Drag&Drop für Resource-Verwaltung (Nice-to-have)
|
||
+2. Automatische Quarantäne-Aufhebung nach Zeitablauf
|
||
+3. E-Mail-Benachrichtigungen bei niedrigem Bestand
|
||
+4. API für externe Resource-Prüfung
|
||
+5. Bulk-Delete für Ressourcen
|
||
+6. Resource-Import aus CSV/Excel
|
||
+
|
||
+### 2025-06-09: Resource Pool System finalisiert
|
||
+
|
||
+**Problem:**
|
||
+- Resource Pool System war nur teilweise implementiert
|
||
+- UI-Templates waren vorhanden, aber nicht dokumentiert
|
||
+- Test-Daten und Migration fehlten
|
||
+- Backup-Integration unklar
|
||
+
|
||
+**Analyse und Lösung:**
|
||
+1. **Status-Überprüfung durchgeführt:**
|
||
+ - Alle 5 UI-Templates existierten bereits (resources.html, add_resources.html, etc.)
|
||
+ - Resource-Dropdowns waren bereits in index.html und batch_form.html integriert
|
||
+ - Dashboard-Widget war bereits implementiert
|
||
+ - Backup-System inkludiert bereits alle Tabellen (pg_dump ohne -t Parameter)
|
||
+
|
||
+2. **Fehlende Komponenten erstellt:**
|
||
+ - Test-Daten Script: `test_data_resources.sql`
|
||
+ - 500 Test-Domains (400 verfügbar, 50 zugeteilt, 50 in Quarantäne)
|
||
+ - 200 Test-IPv4-Adressen (150 verfügbar, 30 zugeteilt, 20 in Quarantäne)
|
||
+ - 100 Test-Telefonnummern (70 verfügbar, 20 zugeteilt, 10 in Quarantäne)
|
||
+ - Resource History und Metrics für realistische Daten
|
||
+
|
||
+ - Migration Script: `migrate_existing_licenses.sql`
|
||
+ - Setzt Default Resource Counts (Vollversion: 2, Testversion: 1, Inaktiv: 0)
|
||
+ - Weist automatisch verfügbare Ressourcen zu
|
||
+ - Erstellt Audit-Log Einträge
|
||
+ - Gibt detaillierten Migrationsbericht aus
|
||
+
|
||
+**Neue Dateien:**
|
||
+- `v2_adminpanel/test_data_resources.sql` - Testdaten für Resource Pool
|
||
+- `v2_adminpanel/migrate_existing_licenses.sql` - Migration für bestehende Lizenzen
|
||
+
|
||
+**Status:**
|
||
+- ✅ Resource Pool System vollständig implementiert und dokumentiert
|
||
+- ✅ Alle UI-Komponenten vorhanden und funktionsfähig
|
||
+- ✅ Integration in Lizenz-Formulare abgeschlossen
|
||
+- ✅ Dashboard-Widget zeigt Resource-Statistiken
|
||
+- ✅ Backup-System inkludiert Resource-Tabellen
|
||
+- ✅ Test-Daten und Migration bereitgestellt
|
||
+
|
||
+**Nächste Schritte:**
|
||
+1. Test-Daten einspielen: `psql -U adminuser -d meinedatenbank -f test_data_resources.sql`
|
||
+2. Migration ausführen: `psql -U adminuser -d meinedatenbank -f migrate_existing_licenses.sql`
|
||
+3. License Server API implementieren (Hauptaufgabe)
|
||
+
|
||
+### 2025-06-09: Bugfix - Resource Pool Tabellen fehlten
|
||
+
|
||
+**Problem:**
|
||
+- Admin Panel zeigte "Internal Server Error"
|
||
+- Dashboard Route versuchte auf `resource_pools` Tabelle zuzugreifen
|
||
+- Tabelle existierte nicht in der Datenbank
|
||
+
|
||
+**Ursache:**
|
||
+- Bei bereits existierender Datenbank wird init.sql nicht erneut ausgeführt
|
||
+- Resource Pool Tabellen wurden erst später zum init.sql hinzugefügt
|
||
+- Docker Container verwendeten noch die alte Datenbankstruktur
|
||
+
|
||
+**Lösung:**
|
||
+1. Separates Script `create_resource_tables.sql` erstellt
|
||
+2. Script manuell in der Datenbank ausgeführt
|
||
+3. Alle 4 Resource-Tabellen erfolgreich erstellt:
|
||
+ - resource_pools
|
||
+ - resource_history
|
||
+ - resource_metrics
|
||
+ - license_resources
|
||
+
|
||
+**Status:**
|
||
+- ✅ Admin Panel funktioniert wieder
|
||
+- ✅ Dashboard zeigt Resource Pool Statistiken
|
||
+- ✅ Alle Resource-Funktionen verfügbar
|
||
+
|
||
+**Empfehlung für Neuinstallationen:**
|
||
+- Bei frischer Installation funktioniert alles automatisch
|
||
+- Bei bestehenden Installationen: `create_resource_tables.sql` ausführen
|
||
+
|
||
+### 2025-06-09: Navigation vereinfacht
|
||
+
|
||
+**Änderung:**
|
||
+- Navigationspunkte aus der schwarzen Navbar entfernt
|
||
+- Links zu Lizenzen, Kunden, Ressourcen, Sessions, Backups und Log entfernt
|
||
+
|
||
+**Grund:**
|
||
+- Cleaner Look mit nur Logo, Timer und Logout
|
||
+- Alle Funktionen sind weiterhin über das Dashboard erreichbar
|
||
+- Bessere Übersichtlichkeit und weniger Ablenkung
|
||
+
|
||
+**Geänderte Datei:**
|
||
+- `v2_adminpanel/templates/base.html` - Navbar-Links auskommentiert
|
||
+
|
||
+**Status:**
|
||
+- ✅ Navbar zeigt nur noch Logo, Session-Timer und Logout
|
||
+- ✅ Navigation erfolgt über Dashboard und Buttons auf den jeweiligen Seiten
|
||
+- ✅ Alle Funktionen bleiben erreichbar
|
||
+
|
||
+### 2025-06-09: Bugfix - Resource Report Einrückungsfehler
|
||
+
|
||
+**Problem:**
|
||
+- Resource Report Route zeigte "Internal Server Error"
|
||
+- UnboundLocalError: `report_type` wurde verwendet bevor es definiert war
|
||
+
|
||
+**Ursache:**
|
||
+- Fehlerhafte Einrückung in der `resources_report()` Funktion
|
||
+- `elif` und `else` Blöcke waren falsch eingerückt
|
||
+- Variablen wurden außerhalb ihres Gültigkeitsbereichs verwendet
|
||
+
|
||
+**Lösung:**
|
||
+- Korrekte Einrückung für alle Conditional-Blöcke wiederhergestellt
|
||
+- Alle Report-Typen (usage, performance, compliance, inventory) richtig strukturiert
|
||
+- Excel und CSV Export-Code korrekt eingerückt
|
||
+
|
||
+**Geänderte Datei:**
|
||
+- `v2_adminpanel/app.py` - resources_report() Funktion korrigiert
|
||
+
|
||
+**Status:**
|
||
+- ✅ Resource Report funktioniert wieder
|
||
+- ✅ Alle 4 Report-Typen verfügbar
|
||
+- ✅ Export als Excel und CSV möglich
|
||
+
|
||
+---
|
||
+
|
||
+## Zusammenfassung der heutigen Arbeiten (2025-06-09)
|
||
+
|
||
+### 1. Resource Pool System Finalisierung
|
||
+- **Ausgangslage**: Resource Pool war nur teilweise dokumentiert
|
||
+- **Überraschung**: UI-Templates waren bereits vorhanden (nicht dokumentiert)
|
||
+- **Ergänzt**:
|
||
+ - Test-Daten Script (`test_data_resources.sql`)
|
||
+ - Migration Script (`migrate_existing_licenses.sql`)
|
||
+- **Status**: ✅ Vollständig implementiert
|
||
+
|
||
+### 2. Database Migration Bug
|
||
+- **Problem**: Admin Panel zeigte "Internal Server Error"
|
||
+- **Ursache**: Resource Pool Tabellen fehlten in bestehender DB
|
||
+- **Lösung**: Separates Script `create_resource_tables.sql` erstellt
|
||
+- **Status**: ✅ Behoben
|
||
+
|
||
+### 3. UI Cleanup
|
||
+- **Änderung**: Navigation aus Navbar entfernt
|
||
+- **Effekt**: Cleaner Look, Navigation nur über Dashboard
|
||
+- **Status**: ✅ Implementiert
|
||
+
|
||
+### 4. Resource Report Bug
|
||
+- **Problem**: Einrückungsfehler in `resources_report()` Funktion
|
||
+- **Lösung**: Korrekte Einrückung wiederhergestellt
|
||
+- **Status**: ✅ Behoben
|
||
+
|
||
+### Neue Dateien erstellt heute:
|
||
+1. `v2_adminpanel/test_data_resources.sql` - 800 Test-Ressourcen
|
||
+
|
||
+### 2025-06-09: Bugfix - Resource Quarantäne Modal
|
||
+
|
||
+**Problem:**
|
||
+- Quarantäne-Button funktionierte nicht
|
||
+- Modal öffnete sich nicht beim Klick
|
||
+
|
||
+**Ursache:**
|
||
+- Bootstrap 5 vs Bootstrap 4 API-Inkompatibilität
|
||
+- Modal wurde mit Bootstrap 4 Syntax (`modal.modal('show')`) aufgerufen
|
||
+- jQuery wurde nach Bootstrap geladen
|
||
+
|
||
+**Lösung:**
|
||
+1. **JavaScript angepasst:**
|
||
+ - Von jQuery Modal-API zu nativer Bootstrap 5 Modal-API gewechselt
|
||
+ - `new bootstrap.Modal(element).show()` statt `$(element).modal('show')`
|
||
+
|
||
+2. **HTML-Struktur aktualisiert:**
|
||
+ - Modal-Close-Button: `data-bs-dismiss="modal"` statt `data-dismiss="modal"`
|
||
+ - `btn-close` Klasse statt custom close button
|
||
+ - Form-Klassen: `mb-3` statt `form-group`, `form-select` statt `form-control` für Select
|
||
+
|
||
+3. **Script-Reihenfolge korrigiert:**
|
||
+ - jQuery vor Bootstrap laden für korrekte Initialisierung
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/templates/resources.html`
|
||
+- `v2_adminpanel/templates/base.html`
|
||
+
|
||
+**Status:** ✅ Behoben
|
||
+
|
||
+### 2025-06-09: Resource Pool UI Redesign
|
||
+
|
||
+**Ziel:**
|
||
+- Komplettes Redesign des Resource Pool Managements für bessere Benutzerfreundlichkeit
|
||
+- Konsistentes Design mit dem Rest der Anwendung
|
||
+
|
||
+**Durchgeführte Änderungen:**
|
||
+
|
||
+1. **resources.html - Hauptübersicht:**
|
||
+ - Moderne Statistik-Karten mit Hover-Effekten
|
||
+ - Farbcodierte Progress-Bars mit Tooltips
|
||
+ - Verbesserte Tabelle mit Icons und Status-Badges
|
||
+ - Live-Filter mit sofortiger Suche
|
||
+ - Überarbeitete Quarantäne-Modal für Bootstrap 5
|
||
+ - Responsive Design mit Grid-Layout
|
||
+
|
||
+2. **add_resources.html - Ressourcen hinzufügen:**
|
||
+ - 3-Schritt Wizard-ähnliches Interface
|
||
+ - Visueller Ressourcentyp-Selector mit Icons
|
||
+ - Live-Validierung mit Echtzeit-Feedback
|
||
+ - Statistik-Anzeige (Gültig/Duplikate/Ungültig)
|
||
+ - Formatierte Beispiele mit Erklärungen
|
||
+ - Verbesserte Fehlerbehandlung
|
||
+
|
||
+3. **resource_history.html - Historie:**
|
||
+ - Zentrierte Resource-Anzeige mit großen Icons
|
||
+ - Info-Grid Layout für Details
|
||
+ - Modernisierte Timeline mit Hover-Effekten
|
||
+ - Farbcodierte Action-Icons
|
||
+ - Verbesserte Darstellung von Details
|
||
+
|
||
+4. **resource_metrics.html - Metriken:**
|
||
+ - Dashboard-Style Metrik-Karten mit Icon-Badges
|
||
+ - Modernisierte Charts mit besseren Farben
|
||
+ - Performance-Tabellen mit Progress-Bars
|
||
+ - Trend-Indikatoren für Performance
|
||
+ - Responsives Grid-Layout
|
||
+
|
||
+**Design-Verbesserungen:**
|
||
+- Konsistente Emoji-Icons für bessere visuelle Kommunikation
|
||
+- Einheitliche Farbgebung (Blau/Lila/Grün für Ressourcentypen)
|
||
+- Card-basiertes Layout mit Schatten und Hover-Effekten
|
||
+- Bootstrap 5 kompatible Komponenten
|
||
+- Verbesserte Typografie und Spacing
|
||
+
|
||
+**Technische Details:**
|
||
+- Bootstrap 5 Modal-API statt jQuery
|
||
+- CSS Grid für responsive Layouts
|
||
+- Moderne Chart.js Konfiguration
|
||
+- Optimierte JavaScript-Validierung
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/templates/resources.html`
|
||
+- `v2_adminpanel/templates/add_resources.html`
|
||
+- `v2_adminpanel/templates/resource_history.html`
|
||
+- `v2_adminpanel/templates/resource_metrics.html`
|
||
+
|
||
+**Status:** ✅ Abgeschlossen
|
||
+
|
||
+### 2025-06-09: Zusammenfassung der heutigen Arbeiten
|
||
+
|
||
+**Durchgeführte Aufgaben:**
|
||
+
|
||
+1. **Quarantäne-Funktion repariert:**
|
||
+ - Bootstrap 5 Modal-API implementiert
|
||
+ - data-bs-dismiss statt data-dismiss
|
||
+ - jQuery vor Bootstrap laden
|
||
+
|
||
+2. **Resource Pool UI komplett überarbeitet:**
|
||
+ - Alle 4 Templates modernisiert (resources, add_resources, resource_history, resource_metrics)
|
||
+ - Konsistentes Design mit Emoji-Icons
|
||
+ - Einheitliche Farbgebung (Blau/Lila/Grün)
|
||
+ - Bootstrap 5 kompatible Komponenten
|
||
+ - Responsive Grid-Layouts
|
||
+
|
||
+**Aktuelle Projekt-Status:**
|
||
+- ✅ Admin Panel voll funktionsfähig
|
||
+- ✅ Resource Pool Management mit modernem UI
|
||
+- ✅ PostgreSQL mit allen Tabellen
|
||
+- ✅ Nginx Reverse Proxy mit SSL
|
||
+- ❌ Lizenzserver noch nicht implementiert (nur Platzhalter)
|
||
+
|
||
+**Nächste Schritte:**
|
||
+- Lizenzserver implementieren
|
||
+- API-Endpunkte für Lizenzvalidierung
|
||
+- Heartbeat-System für Sessions
|
||
+- Versionsprüfung implementieren
|
||
+1. `v2_adminpanel/templates/base.html` - Navigation entfernt
|
||
+2. `v2_adminpanel/app.py` - Resource Report Einrückung korrigiert
|
||
+3. `JOURNAL.md` - Alle Änderungen dokumentiert
|
||
+
|
||
+### Offene Hauptaufgabe:
|
||
+- **License Server API** - Noch komplett zu implementieren
|
||
+ - `/api/version` - Versionscheck
|
||
+ - `/api/validate` - Lizenzvalidierung
|
||
+ - `/api/heartbeat` - Session-Management
|
||
+
|
||
+### 2025-06-09: Resource Pool Internal Error behoben
|
||
+
|
||
+**Problem:**
|
||
+- Internal Server Error beim Zugriff auf `/resources`
|
||
+- NameError: name 'datetime' is not defined in Template
|
||
+
|
||
+**Ursache:**
|
||
+- Fehlende `datetime` und `timedelta` Objekte im Template-Kontext
|
||
+- Falsche Array-Indizes in resources.html für activity-Daten
|
||
+
|
||
+**Lösung:**
|
||
+1. **app.py (Zeile 2797-2798):**
|
||
+ - `datetime=datetime` und `timedelta=timedelta` zu render_template hinzugefügt
|
||
+
|
||
+2. **resources.html (Zeile 484-490):**
|
||
+ - Array-Indizes korrigiert:
|
||
+ - activity[0] = action
|
||
+ - activity[1] = action_by
|
||
+ - activity[2] = action_at
|
||
+ - activity[3] = resource_type
|
||
+ - activity[4] = resource_value
|
||
+ - activity[5] = details
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/app.py`
|
||
+- `v2_adminpanel/templates/resources.html`
|
||
+
|
||
+**Status:** ✅ Behoben - Resource Pool funktioniert wieder einwandfrei
|
||
+
|
||
+### 2025-06-09: Passwort-Änderung und 2FA implementiert
|
||
+
|
||
+**Ziel:**
|
||
+- Benutzer können ihr Passwort ändern
|
||
+- Zwei-Faktor-Authentifizierung (2FA) mit TOTP
|
||
+- Komplett kostenlose Lösung ohne externe Services
|
||
+
|
||
+**Implementierte Features:**
|
||
+
|
||
+1. **Datenbank-Erweiterung:**
|
||
+ - Neue `users` Tabelle mit Passwort-Hash und 2FA-Feldern
|
||
+ - Unterstützung für TOTP-Secrets und Backup-Codes
|
||
+ - Migration von Environment-Variablen zu Datenbank
|
||
+
|
||
+2. **Passwort-Management:**
|
||
+ - Sichere Passwort-Hashes mit bcrypt
|
||
+ - Passwort-Änderung mit Verifikation des alten Passworts
|
||
+ - Passwort-Stärke-Indikator im Frontend
|
||
+
|
||
+3. **2FA-Implementation:**
|
||
+ - TOTP-basierte 2FA (Google Authenticator, Authy kompatibel)
|
||
+ - QR-Code-Generierung für einfaches Setup
|
||
+ - 8 Backup-Codes für Notfallzugriff
|
||
+ - Backup-Codes als Textdatei downloadbar
|
||
+
|
||
+4. **Neue Routen:**
|
||
+ - `/profile` - Benutzerprofil mit Passwort und 2FA-Verwaltung
|
||
+ - `/verify-2fa` - 2FA-Verifizierung beim Login
|
||
+ - `/profile/setup-2fa` - 2FA-Einrichtung mit QR-Code
|
||
+ - `/profile/enable-2fa` - 2FA-Aktivierung
|
||
+ - `/profile/disable-2fa` - 2FA-Deaktivierung
|
||
+ - `/profile/change-password` - Passwort ändern
|
||
+
|
||
+5. **Sicherheits-Features:**
|
||
+ - Fallback zu Environment-Variablen für Rückwärtskompatibilität
|
||
+ - Session-Management für 2FA-Verifizierung
|
||
+ - Fehlgeschlagene 2FA-Versuche werden protokolliert
|
||
+ - Verwendete Backup-Codes werden entfernt
|
||
+
|
||
+**Verwendete Libraries (alle kostenlos):**
|
||
+- `bcrypt` - Passwort-Hashing
|
||
+- `pyotp` - TOTP-Generierung und Verifizierung
|
||
+- `qrcode[pil]` - QR-Code-Generierung
|
||
+
|
||
+**Migration:**
|
||
+- Script `migrate_users.py` erstellt für Migration existierender Benutzer
|
||
+- Erhält bestehende Credentials aus Environment-Variablen
|
||
+- Erstellt Datenbank-Einträge mit gehashten Passwörtern
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/init.sql` - Users-Tabelle hinzugefügt
|
||
+- `v2_adminpanel/requirements.txt` - Neue Dependencies
|
||
+- `v2_adminpanel/app.py` - Auth-Funktionen und neue Routen
|
||
+- `v2_adminpanel/migrate_users.py` - Migrations-Script (neu)
|
||
+- `v2_adminpanel/templates/base.html` - Profil-Link hinzugefügt
|
||
+- `v2_adminpanel/templates/profile.html` - Profil-Seite (neu)
|
||
+- `v2_adminpanel/templates/verify_2fa.html` - 2FA-Verifizierung (neu)
|
||
+- `v2_adminpanel/templates/setup_2fa.html` - 2FA-Setup (neu)
|
||
+- `v2_adminpanel/templates/backup_codes.html` - Backup-Codes Anzeige (neu)
|
||
+
|
||
+**Status:** ✅ Vollständig implementiert
|
||
+
|
||
+### 2025-06-09: Internal Server Error behoben und UI-Design angepasst
|
||
+
|
||
+### 2025-06-09: Journal-Bereinigung und Projekt-Cleanup
|
||
+
|
||
+**Durchgeführte Aufgaben:**
|
||
+
|
||
+1. **Überflüssige SQL-Dateien gelöscht:**
|
||
+ - `create_resource_tables.sql` - War nur für Migrations nötig
|
||
+ - `migrate_existing_licenses.sql` - Keine alten Installationen vorhanden
|
||
+ - `sample_data.sql` - Testdaten nicht mehr benötigt
|
||
+ - `test_data_resources.sql` - Testdaten nicht mehr benötigt
|
||
+
|
||
+2. **Journal aktualisiert:**
|
||
+ - Veraltete Todo-Liste korrigiert (viele Features bereits implementiert)
|
||
+ - Passwörter aus Zugangsdaten entfernt (Sicherheit)
|
||
+ - "Bekannte Probleme" auf aktuellen Stand gebracht
|
||
+ - Neuer Abschnitt "Best Practices für Produktiv-Migration" hinzugefügt
|
||
+
|
||
+3. **Status-Klärungen:**
|
||
+ - Alle Daten sind Testdaten (PoC-Phase)
|
||
+ - 2FA ist implementiert und funktionsfähig
|
||
+ - Resource Pool System ist vollständig implementiert
|
||
+ - Port 8443 ist geschlossen (nur über Nginx erreichbar)
|
||
+
|
||
+**Noch zu erledigen:**
|
||
+- Nginx Config anpassen (proxy_pass von https:// auf http://)
|
||
+- License Server API implementieren (Hauptaufgabe)
|
||
+
|
||
+**Problem:**
|
||
+- Internal Server Error nach Login wegen fehlender `users` Tabelle
|
||
+- UI-Design der neuen 2FA-Seiten passte nicht zum Rest der Anwendung
|
||
+
|
||
+**Lösung:**
|
||
+
|
||
+1. **Datenbank-Fix:**
|
||
+ - Users-Tabelle wurde nicht automatisch erstellt
|
||
+ - Manuell mit SQL-Script nachgeholt
|
||
+ - Migration erfolgreich durchgeführt
|
||
+ - Beide Admin-User (rac00n, w@rh@mm3r) migriert
|
||
+
|
||
+2. **UI-Design Überarbeitung:**
|
||
+ - Profile-Seite im Dashboard-Stil mit Cards und Hover-Effekten
|
||
+ - 2FA-Setup mit nummerierten Schritten und modernem Card-Design
|
||
+ - Backup-Codes Seite mit Animation und verbessertem Layout
|
||
+ - Konsistente Farbgebung und Icons
|
||
+ - Verbesserte Benutzerführung mit visuellen Hinweisen
|
||
+
|
||
+**Design-Features:**
|
||
+- Card-basiertes Layout mit Schatten-Effekten
|
||
+- Hover-Animationen für bessere Interaktivität
|
||
+- Farbcodierte Sicherheitsstatus-Anzeigen
|
||
+- Passwort-Stärke-Indikator mit visueller Rückmeldung
|
||
+- Responsive Design für alle Bildschirmgrößen
|
||
+- Print-optimiertes Layout für Backup-Codes
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/create_users_table.sql` - SQL für Users-Tabelle (temporär)
|
||
+
|
||
+### 2025-06-09: Journal-Umstrukturierung
|
||
+
|
||
+**Durchgeführte Änderungen:**
|
||
+
|
||
+1. **Dokumentation aufgeteilt:**
|
||
+ - `JOURNAL.md` - Enthält nur noch chronologische Änderungen (wie ein Tagebuch)
|
||
+ - `THE_ROAD_SO_FAR.md` - Neues Dokument mit aktuellem Status und Roadmap
|
||
+
|
||
+2. **THE_ROAD_SO_FAR.md erstellt mit:**
|
||
+ - Aktueller Status (was läuft bereits)
|
||
+ - Nächste Schritte (Priorität Hoch)
|
||
+ - Offene Aufgaben (Priorität Mittel)
|
||
+ - Nice-to-have Features
|
||
+ - Bekannte Probleme
|
||
+ - Deployment-Hinweise
|
||
+
|
||
+3. **JOURNAL.md bereinigt:**
|
||
+ - Todo-Listen entfernt (jetzt in THE_ROAD_SO_FAR.md)
|
||
+ - Nur noch chronologische Einträge
|
||
+ - Fokus auf "Was wurde gemacht" statt "Was muss gemacht werden"
|
||
+
|
||
+**Vorteile der Aufteilung:**
|
||
+- Journal bleibt übersichtlich und wächst linear
|
||
+- Status und Todos sind immer aktuell an einem Ort
|
||
+- Klare Trennung zwischen Historie und Planung
|
||
+- Einfacher für neue Entwickler einzusteigen
|
||
+
|
||
+### 2025-06-09: Nginx Config angepasst
|
||
+
|
||
+**Änderung:**
|
||
+- proxy_pass für License Server von `https://license-server:8443` auf `http://license-server:8443` geändert
|
||
+- `proxy_ssl_verify off` entfernt (nicht mehr nötig bei HTTP)
|
||
+- WebSocket-Support hinzugefügt (für zukünftige Features)
|
||
+
|
||
+**Grund:**
|
||
+- License Server läuft intern auf HTTP (wie Admin Panel)
|
||
+- SSL-Termination erfolgt nur am Nginx
|
||
+- Vereinfachte Konfiguration ohne doppelte SSL-Verschlüsselung
|
||
+
|
||
+**Hinweis:**
|
||
+Docker-Container müssen neu gestartet werden, damit die Änderung wirksam wird:
|
||
+```bash
|
||
+docker-compose down
|
||
+docker-compose up -d
|
||
+```
|
||
+- `v2_adminpanel/templates/profile.html` - Komplett überarbeitet
|
||
+- `v2_adminpanel/templates/setup_2fa.html` - Neues Step-by-Step Design
|
||
+- `v2_adminpanel/templates/backup_codes.html` - Modernisiertes Layout
|
||
+
|
||
+**Status:** ✅ Abgeschlossen - Login funktioniert, UI im konsistenten Design
|
||
+
|
||
+### 2025-06-09: Lizenzschlüssel-Format geändert
|
||
+
|
||
+**Änderung:**
|
||
+- Altes Format: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ` (z.B. AF-202506F-V55Y-9DWE-GL5G)
|
||
+- Neues Format: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ` (z.B. AF-F-202506-V55Y-9DWE-GL5G)
|
||
+
|
||
+**Vorteile:**
|
||
+- Klarere Struktur mit separatem Typ-Indikator
|
||
+- Einfacher zu lesen und zu verstehen
|
||
+- Typ (F/T) sofort im zweiten Block erkennbar
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/app.py`:
|
||
+ - `generate_license_key()` - Generiert Keys im neuen Format
|
||
+ - `validate_license_key()` - Validiert Keys mit neuem Regex-Pattern
|
||
+- `v2_adminpanel/templates/index.html`:
|
||
+ - Placeholder und Pattern für Input-Feld angepasst
|
||
+ - JavaScript charAt() Position für Typ-Prüfung korrigiert
|
||
+- `v2_adminpanel/templates/batch_form.html`:
|
||
+ - Vorschau-Format für Batch-Generierung angepasst
|
||
+
|
||
+**Hinweis:** Alte Keys im bisherigen Format bleiben ungültig. Bei Bedarf könnte eine Migration oder Dual-Support implementiert werden.
|
||
+
|
||
+**Status:** ✅ Implementiert
|
||
+
|
||
+### 2025-06-09: Datenbank-Migration der Lizenzschlüssel
|
||
+
|
||
+**Durchgeführt:**
|
||
+- Alle bestehenden Lizenzschlüssel in der Datenbank auf das neue Format migriert
|
||
+- 18 Lizenzschlüssel erfolgreich konvertiert (16 Full, 2 Test)
|
||
+
|
||
+**Migration:**
|
||
+- Von: `AF-YYYYMMFT-XXXX-YYYY-ZZZZ`
|
||
+- Nach: `AF-F-YYYYMM-XXXX-YYYY-ZZZZ`
|
||
+
|
||
+**Beispiele:**
|
||
+- Alt: `AF-202506F-V55Y-9DWE-GL5G`
|
||
+- Neu: `AF-F-202506-V55Y-9DWE-GL5G`
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/migrate_license_keys.sql` - Migrations-Script (temporär)
|
||
+- `v2_adminpanel/fix_license_keys.sql` - Korrektur-Script (temporär)
|
||
+
|
||
+**Status:** ✅ Alle Lizenzschlüssel erfolgreich migriert
|
||
+
|
||
+### 2025-06-09: Kombinierte Kunden-Lizenz-Ansicht implementiert
|
||
+
|
||
+**Problem:**
|
||
+- Umständliche Navigation zwischen Kunden- und Lizenzseiten
|
||
+- Viel Hin-und-Her-Springen bei der Verwaltung
|
||
+- Kontext-Verlust beim Wechseln zwischen Ansichten
|
||
+
|
||
+**Lösung:**
|
||
+Master-Detail View mit 2-Spalten Layout implementiert
|
||
+
|
||
+**Phase 1-3 abgeschlossen:**
|
||
+1. **Backend-Implementierung:**
|
||
+ - Neue Route `/customers-licenses` für kombinierte Ansicht
|
||
+ - API-Endpoints für AJAX: `/api/customer/<id>/licenses`, `/api/customer/<id>/quick-stats`
|
||
+ - API-Endpoint `/api/license/<id>/quick-edit` für Inline-Bearbeitung
|
||
+ - Optimierte SQL-Queries mit JOIN für Performance
|
||
+
|
||
+2. **Template-Erstellung:**
|
||
+ - Neues Template `customers_licenses.html` mit Master-Detail Layout
|
||
+ - Links: Kundenliste (30%) mit Suchfeld
|
||
+ - Rechts: Lizenzen des ausgewählten Kunden (70%)
|
||
+ - Responsive Design (Mobile: untereinander)
|
||
+ - JavaScript für dynamisches Laden ohne Seitenreload
|
||
+ - Keyboard-Navigation (↑↓ für Kundenwechsel)
|
||
+
|
||
+3. **Integration:**
|
||
+ - Dashboard: Neuer Button "Kunden & Lizenzen"
|
||
+ - Customers-Seite: Link zur kombinierten Ansicht
|
||
+ - Licenses-Seite: Link zur kombinierten Ansicht
|
||
+ - Lizenz-Erstellung: Unterstützung für vorausgewählten Kunden
|
||
+ - API /api/customers erweitert für Einzelabruf per ID
|
||
+
|
||
+**Features:**
|
||
+- Live-Suche in Kundenliste
|
||
+- Quick-Actions: Copy License Key, Toggle Status
|
||
+- Modal für neue Lizenz direkt aus Kundenansicht
|
||
+- URL-Update ohne Reload für Bookmarking
|
||
+- Loading-States während AJAX-Calls
|
||
+- Visuelles Feedback (aktiver Kunde hervorgehoben)
|
||
+
|
||
+**Noch ausstehend:**
|
||
+- Phase 4: Inline-Edit für Lizenzdetails
|
||
+- Phase 5: Erweiterte Error-Handling und Polish
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/app.py` - Neue Routen und API-Endpoints
|
||
+- `v2_adminpanel/templates/customers_licenses.html` - Neues Template
|
||
+- `v2_adminpanel/templates/dashboard.html` - Neuer Button
|
||
+- `v2_adminpanel/templates/customers.html` - Link zur kombinierten Ansicht
|
||
+- `v2_adminpanel/templates/licenses.html` - Link zur kombinierten Ansicht
|
||
+- `v2_adminpanel/templates/index.html` - Unterstützung für preselected_customer_id
|
||
+
|
||
+**Status:** ✅ Grundfunktionalität implementiert und funktionsfähig
|
||
+
|
||
+### 2025-06-09: Kombinierte Ansicht - Fertigstellung und TODOs aktualisiert
|
||
+
|
||
+**Abgeschlossen:**
|
||
+- Phase 1-3 der kombinierten Kunden-Lizenz-Ansicht vollständig implementiert
|
||
+- Master-Detail Layout funktioniert einwandfrei
|
||
+- AJAX-basiertes Laden ohne Seitenreload
|
||
+- Keyboard-Navigation mit Pfeiltasten
|
||
+- Quick-Actions für Copy und Toggle Status
|
||
+- Integration in alle relevanten Seiten
|
||
+
|
||
+**THE_ROAD_SO_FAR.md aktualisiert:**
|
||
+- Kombinierte Ansicht als "Erledigt" markiert
|
||
+- Von "In Arbeit" zu "Abgeschlossen" verschoben
|
||
+- Status dokumentiert
|
||
+
|
||
+**Verbesserung gegenüber vorher:**
|
||
+- Kein Hin-und-Her-Springen mehr zwischen Seiten
|
||
+- Kontext bleibt erhalten beim Arbeiten mit Kunden
|
||
+- Schnellere Navigation und bessere Übersicht
|
||
+- Deutlich verbesserte User Experience
|
||
+
|
||
+**Optional für später (Phase 4-5):**
|
||
+- Inline-Edit für weitere Felder
|
||
+- Erweiterte Quick-Actions
|
||
+- Session-basierte Filter-Persistenz
|
||
+
|
||
+Die Hauptproblematik der umständlichen Navigation ist damit gelöst!
|
||
+
|
||
+### 2025-06-09: Test-Flag für Lizenzen implementiert
|
||
+
|
||
+**Ziel:**
|
||
+- Klare Trennung zwischen Testdaten und echten Produktivdaten
|
||
+- Testdaten sollen von der Software ignoriert werden können
|
||
+- Bessere Übersicht im Admin Panel
|
||
+
|
||
+**Durchgeführte Änderungen:**
|
||
+
|
||
+1. **Datenbank-Schema (init.sql):**
|
||
+ - Neue Spalte `is_test BOOLEAN DEFAULT FALSE` zur `licenses` Tabelle hinzugefügt
|
||
+ - Migration für bestehende Daten: Alle werden als `is_test = TRUE` markiert
|
||
+ - Index `idx_licenses_is_test` für bessere Performance
|
||
+
|
||
+2. **Backend (app.py):**
|
||
+ - Dashboard-Queries filtern Testdaten mit `WHERE is_test = FALSE` aus
|
||
+ - Lizenz-Erstellung: Neues Checkbox-Feld für Test-Markierung
|
||
+ - Lizenz-Bearbeitung: Test-Status kann geändert werden
|
||
+ - Export: Optional mit/ohne Testdaten (`?include_test=true`)
|
||
+ - Bulk-Operationen: Nur auf Live-Daten anwendbar
|
||
+ - Neue Filter in Lizenzliste: "🧪 Testdaten" und "🚀 Live-Daten"
|
||
+
|
||
+3. **Frontend Templates:**
|
||
+ - **index.html**: Checkbox "Als Testdaten markieren" bei Lizenzerstellung
|
||
+ - **edit_license.html**: Checkbox zum Ändern des Test-Status
|
||
+ - **licenses.html**: Badge 🧪 für Testdaten, neue Filteroptionen
|
||
+ - **dashboard.html**: Info-Box zeigt Anzahl der Testdaten
|
||
+ - **batch_form.html**: Option für Batch-Test-Lizenzen
|
||
+
|
||
+4. **Audit-Log Integration:**
|
||
+ - `is_test` Feld wird bei CREATE/UPDATE geloggt
|
||
+ - Nachvollziehbarkeit von Test/Live-Status-Änderungen
|
||
+
|
||
+**Technische Details:**
|
||
+- Testdaten werden in allen Statistiken ausgefiltert
|
||
+- License Server API wird Lizenzen mit `is_test = TRUE` ignorieren
|
||
+- Resource Pool bleibt unverändert (kann Test- und Live-Ressourcen verwalten)
|
||
+
|
||
+**Migration der bestehenden Daten:**
|
||
+```sql
|
||
+UPDATE licenses SET is_test = TRUE; -- Alle aktuellen Daten sind Testdaten
|
||
+```
|
||
+
|
||
+**Status:** ✅ Implementiert
|
||
+
|
||
+### 2025-06-09: Test-Flag für Kunden und Resource Pools erweitert
|
||
+
|
||
+**Ziel:**
|
||
+- Konsistentes Test-Daten-Management über alle Entitäten
|
||
+- Kunden und Resource Pools können ebenfalls als Testdaten markiert werden
|
||
+- Automatische Verknüpfung: Test-Kunde → Test-Lizenzen → Test-Ressourcen
|
||
+
|
||
+**Durchgeführte Änderungen:**
|
||
+
|
||
+1. **Datenbank-Schema erweitert:**
|
||
+ - `customers.is_test BOOLEAN DEFAULT FALSE` hinzugefügt
|
||
+ - `resource_pools.is_test BOOLEAN DEFAULT FALSE` hinzugefügt
|
||
+ - Indizes für bessere Performance erstellt
|
||
+ - Migrations in init.sql integriert
|
||
+
|
||
+2. **Backend (app.py) - Erweiterte Logik:**
|
||
+ - Dashboard: Separate Zähler für Test-Kunden und Test-Ressourcen
|
||
+ - Kunde-Erstellung: Erbt Test-Status von Lizenz
|
||
+ - Test-Kunde erzwingt Test-Lizenzen
|
||
+ - Resource-Zuweisung: Test-Lizenzen bekommen nur Test-Ressourcen
|
||
+ - Customer-Management mit is_test Filter
|
||
+
|
||
+3. **Frontend Updates:**
|
||
+ - **customers.html**: 🧪 Badge für Test-Kunden
|
||
+ - **edit_customer.html**: Checkbox für Test-Status
|
||
+ - **dashboard.html**: Erweiterte Test-Statistik (Lizenzen, Kunden, Ressourcen)
|
||
+
|
||
+4. **Geschäftslogik:**
|
||
+ - Wenn neuer Kunde bei Test-Lizenz erstellt wird → automatisch Test-Kunde
|
||
+ - Wenn Test-Kunde gewählt wird → Lizenz automatisch als Test markiert
|
||
+ - Resource Pool Allocation prüft Test-Status für korrekte Zuweisung
|
||
+
|
||
+**Migration der bestehenden Daten:**
|
||
+```sql
|
||
+UPDATE customers SET is_test = TRUE; -- 5 Kunden
|
||
+UPDATE resource_pools SET is_test = TRUE; -- 20 Ressourcen
|
||
+```
|
||
+
|
||
+**Technische Details:**
|
||
+- Konsistente Test/Live-Trennung über alle Ebenen
|
||
+- Dashboard-Statistiken zeigen nur Live-Daten
|
||
+- Test-Ressourcen werden nur Test-Lizenzen zugewiesen
|
||
+- Alle bestehenden Daten sind jetzt als Test markiert
|
||
+
|
||
+**Status:** ✅ Vollständig implementiert
|
||
+
|
||
+### 2025-06-09 (17:20 - 18:13): Kunden-Lizenz-Verwaltung konsolidiert
|
||
+
|
||
+**Problem:**
|
||
+- Kombinierte Ansicht `/customers-licenses` hatte Formatierungs- und Funktionsprobleme
|
||
+- Kunden wurden nicht angezeigt
|
||
+- Bootstrap Icons fehlten
|
||
+- JavaScript-Fehler beim Modal
|
||
+- Inkonsistentes Design im Vergleich zu anderen Seiten
|
||
+- Testkunden-Filter wurde beim Navigieren nicht beibehalten
|
||
+
|
||
+**Durchgeführte Änderungen:**
|
||
+
|
||
+1. **Frontend-Fixes (base.html):**
|
||
+ - Bootstrap Icons CSS hinzugefügt: `bootstrap-icons@1.11.3/font/bootstrap-icons.min.css`
|
||
+ - Bootstrap JavaScript Bundle bereits vorhanden, Reihenfolge optimiert
|
||
+
|
||
+2. **customers_licenses.html komplett überarbeitet:**
|
||
+ - Container-Klasse von `container-fluid` auf `container py-5` geändert
|
||
+ - Emojis und Button-Styling vereinheitlicht (👥 Kunden & Lizenzen)
|
||
+ - Export-Dropdown wie in anderen Ansichten implementiert
|
||
+ - Card-Styling mit Schatten für einheitliches Design
|
||
+ - Checkbox "Testkunden anzeigen" mit Status-Beibehaltung
|
||
+ - JavaScript-Funktionen korrigiert:
|
||
+ - copyToClipboard mit event.currentTarget
|
||
+ - showNewLicenseModal mit Bootstrap Modal
|
||
+ - Header-Update beim AJAX-Kundenwechsel
|
||
+ - URL-Parameter `show_test` wird überall beibehalten
|
||
+
|
||
+3. **Backend-Anpassungen (app.py):**
|
||
+ - customers_licenses Route: Optional Testkunden anzeigen mit `show_test` Parameter
|
||
+ - Redirects von `/customers` und `/licenses` auf `/customers-licenses` implementiert
|
||
+ - Alte Route-Funktionen entfernt (kein toter Code mehr)
|
||
+ - edit_license und edit_customer: Redirects behalten show_test Parameter bei
|
||
+ - Dashboard-Links zeigen jetzt auf kombinierte Ansicht
|
||
+
|
||
+4. **Navigation optimiert:**
|
||
+ - Dashboard: Klick auf Kunden/Lizenzen-Statistik führt zur kombinierten Ansicht
|
||
+ - Alle Edit-Links behalten den show_test Parameter bei
|
||
+ - Konsistente User Experience beim Navigieren
|
||
+
|
||
+**Technische Details:**
|
||
+- AJAX-Loading für dynamisches Laden der Lizenzen
|
||
+- Keyboard-Navigation (↑↓) für Kundenliste
|
||
+- Responsive Design mit Bootstrap Grid
|
||
+- Modal-Dialoge für Bestätigungen
|
||
+- Live-Suche in der Kundenliste
|
||
+
|
||
+**Resultat:**
|
||
+- ✅ Einheitliches Design mit anderen Admin-Panel-Seiten
|
||
+- ✅ Alle Funktionen arbeiten korrekt
|
||
+- ✅ Testkunden-Filter bleibt erhalten
|
||
+- ✅ Keine redundanten Views mehr
|
||
+- ✅ Zentrale Verwaltung für Kunden und Lizenzen
|
||
+
|
||
+**Status:** ✅ Vollständig implementiert
|
||
+
|
||
+### 2025-06-09: Test-Daten Checkbox Persistenz implementiert
|
||
+
|
||
+**Problem:**
|
||
+- Die "Testkunden anzeigen" Checkbox in `/customers-licenses` verlor ihren Status beim Navigieren zwischen Seiten
|
||
+- Wenn Benutzer zu anderen Seiten (Resources, Audit Log, etc.) wechselten und zurückkehrten, war die Checkbox wieder deaktiviert
|
||
+- Benutzer mussten die Checkbox jedes Mal neu aktivieren, was umständlich war
|
||
+
|
||
+**Lösung:**
|
||
+- Globale JavaScript-Funktion `preserveShowTestParameter()` in base.html implementiert
|
||
+- Die Funktion prüft beim Laden jeder Seite, ob `show_test=true` in der URL ist
|
||
+- Wenn ja, wird dieser Parameter automatisch an alle internen Links angehängt
|
||
+- Backend-Route `/create` wurde angepasst, um den Parameter bei Redirects beizubehalten
|
||
+
|
||
+**Technische Details:**
|
||
+1. **base.html** - JavaScript-Funktion hinzugefügt:
|
||
+ - Läuft beim `DOMContentLoaded` Event
|
||
+ - Findet alle Links die mit "/" beginnen
|
||
+ - Fügt `show_test=true` Parameter hinzu wenn nicht bereits vorhanden
|
||
+ - Überspringt Fragment-Links (#) und Links die bereits den Parameter haben
|
||
+
|
||
+2. **app.py** - Route-Anpassung:
|
||
+ - `/create` Route behält jetzt `show_test` Parameter bei Redirects bei
|
||
+ - Andere Routen (edit_license, edit_customer) behalten Parameter bereits bei
|
||
+
|
||
+**Vorteile:**
|
||
+- ✅ Konsistente User Experience beim Navigieren
|
||
+- ✅ Keine manuelle Anpassung aller Links nötig
|
||
+- ✅ Funktioniert automatisch für alle zukünftigen Links
|
||
+- ✅ Minimaler Code-Overhead
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/templates/base.html`
|
||
+- `v2_adminpanel/app.py`
|
||
+
|
||
+**Status:** ✅ Vollständig implementiert
|
||
+
|
||
+### 2025-06-09: Bearbeiten-Button Fehler behoben
|
||
+
|
||
+**Problem:**
|
||
+- Der "Bearbeiten" Button neben dem Kundennamen in der `/customers-licenses` Ansicht verursachte einen Internal Server Error
|
||
+- Die URL-Konstruktion war fehlerhaft wenn kein `show_test` Parameter vorhanden war
|
||
+- Die edit_customer.html Template hatte falsche Array-Indizes und veraltete Links
|
||
+
|
||
+**Ursache:**
|
||
+1. Die href-Attribute wurden falsch konstruiert:
|
||
+ - Alt: `/customer/edit/ID{% if show_test %}?ref=customers-licenses&show_test=true{% endif %}`
|
||
+ - Problem: Ohne show_test fehlte das `?ref=customers-licenses` komplett
|
||
+
|
||
+2. Die SQL-Abfrage in edit_customer() holte nur 4 Felder, aber das Template erwartete 5:
|
||
+ - Query: `SELECT id, name, email, is_test`
|
||
+ - Template erwartete: `customer[3]` = created_at und `customer[4]` = is_test
|
||
+
|
||
+3. Veraltete Links zu `/customers` statt `/customers-licenses`
|
||
+
|
||
+**Lösung:**
|
||
+1. URL-Konstruktion korrigiert in beiden Fällen:
|
||
+ - Neu: `/customer/edit/ID?ref=customers-licenses{% if show_test %}&show_test=true{% endif %}`
|
||
+
|
||
+2. SQL-Query erweitert um created_at:
|
||
+ - Neu: `SELECT id, name, email, created_at, is_test`
|
||
+
|
||
+3. Template-Indizes korrigiert:
|
||
+ - is_test Checkbox nutzt jetzt `customer[4]`
|
||
+
|
||
+4. Navigation-Links aktualisiert:
|
||
+ - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/templates/customers_licenses.html` (Zeilen 103 und 295)
|
||
+- `v2_adminpanel/app.py` (edit_customer Route)
|
||
+- `v2_adminpanel/templates/edit_customer.html`
|
||
+
|
||
+**Status:** ✅ Behoben
|
||
+
|
||
+### 2025-06-09: Unnötigen Lizenz-Erstellungs-Popup entfernt
|
||
+
|
||
+**Änderung:**
|
||
+- Der Bestätigungs-Popup "Möchten Sie eine neue Lizenz für KUNDENNAME erstellen?" wurde entfernt
|
||
+- Klick auf "Neue Lizenz" Button führt jetzt direkt zur Lizenzerstellung
|
||
+
|
||
+**Technische Details:**
|
||
+- Modal-HTML komplett entfernt
|
||
+- `showNewLicenseModal()` Funktion vereinfacht - navigiert jetzt direkt zu `/create?customer_id=X`
|
||
+- URL-Parameter (wie `show_test`) werden dabei beibehalten
|
||
+
|
||
+**Vorteile:**
|
||
+- ✅ Ein Klick weniger für Benutzer
|
||
+- ✅ Schnellerer Workflow
|
||
+- ✅ Weniger Code zu warten
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/templates/customers_licenses.html`
|
||
+
|
||
+**Status:** ✅ Implementiert
|
||
+
|
||
+### 2025-06-09: Testkunden-Checkbox bleibt jetzt bei Lizenz/Kunden-Bearbeitung erhalten
|
||
+
|
||
+**Problem:**
|
||
+- Bei "Lizenz bearbeiten" ging der "Testkunden anzeigen" Haken verloren beim Zurückkehren
|
||
+- Die Navigation-Links in edit_license.html zeigten auf `/licenses` statt `/customers-licenses`
|
||
+- Der show_test Parameter wurde nur über den unsicheren Referrer übertragen
|
||
+
|
||
+**Lösung:**
|
||
+1. **Navigation-Links korrigiert**:
|
||
+ - Alle Links zeigen jetzt auf `/customers-licenses` mit show_test Parameter
|
||
+ - Betrifft: "Zurück zur Übersicht" und "Abbrechen" Buttons
|
||
+
|
||
+2. **Hidden Form Field hinzugefügt**:
|
||
+ - Sowohl in edit_license.html als auch edit_customer.html
|
||
+ - Überträgt den show_test Parameter sicher beim POST
|
||
+
|
||
+3. **Route-Logik verbessert**:
|
||
+ - Parameter wird aus Form-Daten ODER GET-Parametern gelesen
|
||
+ - Nicht mehr auf unsicheren Referrer angewiesen
|
||
+ - Funktioniert sowohl bei Speichern als auch Abbrechen
|
||
+
|
||
+**Technische Details:**
|
||
+- Templates prüfen `request.args.get('show_test')` für Navigation
|
||
+- Hidden Input: `<input type="hidden" name="show_test" value="true">`
|
||
+- Routes: `show_test = request.form.get('show_test') or request.args.get('show_test')`
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/templates/edit_license.html`
|
||
+- `v2_adminpanel/templates/edit_customer.html`
|
||
+- `v2_adminpanel/app.py` (edit_license und edit_customer Routen)
|
||
+
|
||
+**Status:** ✅ Vollständig implementiert
|
||
+
|
||
+### 2025-06-09 22:02: Konsistente Sortierung bei Status-Toggle
|
||
+
|
||
+**Problem:**
|
||
+- Beim Klicken auf den An/Aus-Knopf (Status-Toggle) in der Kunden & Lizenzen Ansicht änderte sich die Reihenfolge der Lizenzen
|
||
+- Dies war verwirrend für Benutzer, da die Position der gerade bearbeiteten Lizenz springen konnte
|
||
+
|
||
+**Ursache:**
|
||
+- Die Sortierung `ORDER BY l.created_at DESC` war nicht stabil genug
|
||
+- Bei gleichem Erstellungszeitpunkt konnte die Datenbank die Reihenfolge inkonsistent zurückgeben
|
||
+
|
||
+**Lösung:**
|
||
+- Sekundäres Sortierkriterium hinzugefügt: `ORDER BY l.created_at DESC, l.id DESC`
|
||
+- Dies stellt sicher, dass bei gleichem Erstellungsdatum nach ID sortiert wird
|
||
+- Die Reihenfolge bleibt jetzt konsistent, auch nach Status-Änderungen
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/app.py`:
|
||
+ - Zeile 2278: `/customers-licenses` Route
|
||
+ - Zeile 2319: `/api/customer/<int:customer_id>/licenses` API-Route
|
||
+
|
||
+### 2025-06-10 00:01: Verbesserte Integration zwischen Kunden & Lizenzen und Resource Pool
|
||
+
|
||
+**Problem:**
|
||
+- Umständliche Navigation zwischen Kunden & Lizenzen und Resource Pool Bereichen
|
||
+- Keine direkte Verbindung zwischen beiden Ansichten
|
||
+- Benutzer mussten ständig zwischen verschiedenen Seiten hin- und herspringen
|
||
+
|
||
+**Implementierte Lösung - 5 Phasen:**
|
||
+
|
||
+1. **Phase 1: Ressourcen-Details in Kunden & Lizenzen Ansicht**
|
||
+ - API `/api/customer/{id}/licenses` erweitert um konkrete Ressourcen-Informationen
|
||
+ - Neue API `/api/license/{id}/resources` für detaillierte Ressourcen einer Lizenz
|
||
+ - Anzeige der zugewiesenen Ressourcen mit Info-Buttons und Modal-Dialogen
|
||
+ - Klickbare Links zu Ressourcen-Details im Resource Pool
|
||
+
|
||
+2. **Phase 2: Quick-Actions für Ressourcenverwaltung**
|
||
+ - "Ressourcen verwalten" Button (Zahnrad-Icon) bei jeder Lizenz
|
||
+ - Modal mit Übersicht aller zugewiesenen Ressourcen
|
||
+ - Vorbereitung für Quarantäne-Funktionen und Ressourcen-Austausch
|
||
+
|
||
+3. **Phase 3: Ressourcen-Preview bei Lizenzerstellung**
|
||
+ - Live-Anzeige verfügbarer Ressourcen beim Ändern der Anzahl
|
||
+ - Erweiterte Verfügbarkeitsanzeige mit Badges (OK/Niedrig/Kritisch)
|
||
+ - Warnungen bei niedrigem Bestand mit visuellen Hinweisen
|
||
+ - Fortschrittsbalken zur Visualisierung der Verfügbarkeit
|
||
+
|
||
+4. **Phase 4: Dashboard-Integration**
|
||
+ - Resource Pool Widget mit erweiterten Links
|
||
+ - Kritische Warnungen bei < 50 Ressourcen mit "Auffüllen" Button
|
||
+ - Direkte Navigation zu gefilterten Ansichten (nach Typ/Status)
|
||
+ - Verbesserte visuelle Darstellung mit Tooltips
|
||
+
|
||
+5. **Phase 5: Bidirektionale Navigation**
|
||
+ - Von Resource Pool: Links zu Kunden/Lizenzen bei zugewiesenen Ressourcen
|
||
+ - "Zurück zu Kunden" Button wenn von Kunden & Lizenzen kommend
|
||
+ - Navigation-Links im Dashboard für schnellen Zugriff
|
||
+ - SQL-Query erweitert um customer_id für direkte Verlinkung
|
||
+
|
||
+**Technische Details:**
|
||
+- JavaScript-Funktionen für Modal-Dialoge und Ressourcen-Details
|
||
+- Erweiterte SQL-Queries mit JOINs für Ressourcen-Informationen
|
||
+- Bootstrap 5 Tooltips und Modals für bessere UX
|
||
+- Globale Variable `currentLicenses` für Caching der Lizenzdaten
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/app.py` - Neue APIs und erweiterte Queries
|
||
+- `v2_adminpanel/templates/customers_licenses.html` - Ressourcen-Details und Modals
|
||
+- `v2_adminpanel/templates/index.html` - Erweiterte Verfügbarkeitsanzeige
|
||
+- `v2_adminpanel/templates/dashboard.html` - Verbesserte Resource Pool Integration
|
||
+- `v2_adminpanel/templates/resources.html` - Bidirektionale Navigation
|
||
+
|
||
+**Status:** ✅ Alle 5 Phasen erfolgreich implementiert
|
||
+
|
||
+### 2025-06-10 00:15: IP-Adressen-Erfassung hinter Reverse Proxy korrigiert
|
||
+
|
||
+**Problem:**
|
||
+- Flask-App erfasste nur die Docker-interne IP-Adresse von Nginx (172.19.0.5)
|
||
+- Echte Client-IPs wurden nicht in Audit-Logs und Login-Attempts gespeichert
|
||
+- Nginx setzte die Header korrekt, aber Flask las sie nicht aus
|
||
+
|
||
+**Ursache:**
|
||
+- Flask verwendet standardmäßig nur `request.remote_addr`
|
||
+- Dies gibt bei einem Reverse Proxy nur die Proxy-IP zurück
|
||
+- Die Header `X-Real-IP` und `X-Forwarded-For` wurden ignoriert
|
||
+
|
||
+**Lösung:**
|
||
+1. **ProxyFix Middleware** hinzugefügt für korrekte Header-Verarbeitung
|
||
+2. **get_client_ip() Funktion** angepasst:
|
||
+ - Prüft zuerst `X-Real-IP` Header
|
||
+ - Dann `X-Forwarded-For` Header (nimmt erste IP bei mehreren)
|
||
+ - Fallback auf `request.remote_addr`
|
||
+3. **Debug-Logging** für IP-Erfassung hinzugefügt
|
||
+4. **Alle `request.remote_addr` Aufrufe** durch `get_client_ip()` ersetzt
|
||
+
|
||
+**Technische Details:**
|
||
+```python
|
||
+# ProxyFix für korrekte IP-Adressen
|
||
+app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||
+
|
||
+# Verbesserte IP-Erfassung
|
||
+def get_client_ip():
|
||
+ if request.headers.get('X-Real-IP'):
|
||
+ return request.headers.get('X-Real-IP')
|
||
+ elif request.headers.get('X-Forwarded-For'):
|
||
+ return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||
+ else:
|
||
+ return request.remote_addr
|
||
+```
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_adminpanel/app.py` - ProxyFix und verbesserte IP-Erfassung
|
||
+
|
||
+**Status:** ✅ Implementiert - Neue Aktionen erfassen jetzt echte Client-IPs
|
||
+
|
||
+### 2025-06-10 00:30: Docker ENV Legacy-Format Warnungen behoben
|
||
+
|
||
+**Problem:**
|
||
+- Docker Build zeigte Warnungen: "LegacyKeyValueFormat: ENV key=value should be used"
|
||
+- Veraltetes Format `ENV KEY VALUE` wurde in Dockerfiles verwendet
|
||
+
|
||
+**Lösung:**
|
||
+- Alle ENV-Anweisungen auf neues Format `ENV KEY=VALUE` umgestellt
|
||
+- Betraf hauptsächlich v2_postgres/Dockerfile mit 3 ENV-Zeilen
|
||
+
|
||
+**Geänderte Dateien:**
|
||
+- `v2_postgres/Dockerfile` - ENV-Format modernisiert
|
||
+
|
||
+**Beispiel der Änderung:**
|
||
+```dockerfile
|
||
+# Alt (Legacy):
|
||
+ENV LANG de_DE.UTF-8
|
||
+ENV LANGUAGE de_DE:de
|
||
+
|
||
+# Neu (Modern):
|
||
+ENV LANG=de_DE.UTF-8
|
||
+ENV LANGUAGE=de_DE:de
|
||
+```
|
||
+
|
||
+**Status:** ✅ Alle Dockerfiles verwenden jetzt das moderne ENV-Format
|
||
+
|
||
**Status:** ✅ Behoben
|
||
\ No newline at end of file
|
||
diff --git a/v2/.env b/v2/.env
|
||
index 3a27e3f..e834125 100644
|
||
--- a/v2/.env
|
||
+++ b/v2/.env
|
||
@@ -1,56 +1,56 @@
|
||
-# PostgreSQL-Datenbank
|
||
-POSTGRES_DB=meinedatenbank
|
||
-POSTGRES_USER=adminuser
|
||
-POSTGRES_PASSWORD=supergeheimespasswort
|
||
-
|
||
-# Admin-Panel Zugangsdaten
|
||
-ADMIN1_USERNAME=rac00n
|
||
-ADMIN1_PASSWORD=1248163264
|
||
-ADMIN2_USERNAME=w@rh@mm3r
|
||
-ADMIN2_PASSWORD=Warhammer123!
|
||
-
|
||
-# Lizenzserver API Key für Authentifizierung
|
||
-
|
||
-
|
||
-# Domains (können von der App ausgewertet werden, z. B. für Links oder CORS)
|
||
-API_DOMAIN=api-software-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
-ADMIN_PANEL_DOMAIN=admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
-
|
||
-# ===================== OPTIONALE VARIABLEN =====================
|
||
-
|
||
-# JWT für API-Auth
|
||
-# JWT_SECRET=geheimer_token_schlüssel
|
||
-
|
||
-# E-Mail Konfiguration (z. B. bei Ablaufwarnungen)
|
||
-# MAIL_SERVER=smtp.meinedomain.de
|
||
-# MAIL_PORT=587
|
||
-# MAIL_USERNAME=deinemail
|
||
-# MAIL_PASSWORD=geheim
|
||
-# MAIL_FROM=no-reply@meinedomain.de
|
||
-
|
||
-# Logging
|
||
-# LOG_LEVEL=info
|
||
-
|
||
-# Erlaubte CORS-Domains (für Web-Frontend)
|
||
-# ALLOWED_ORIGINS=https://admin.meinedomain.de
|
||
-
|
||
-# ===================== VERSION =====================
|
||
-
|
||
-# Serverseitig gepflegte aktuelle Software-Version
|
||
-# Diese wird vom Lizenzserver genutzt, um die Kundenversion zu vergleichen
|
||
-LATEST_CLIENT_VERSION=1.0.0
|
||
-
|
||
-# ===================== BACKUP KONFIGURATION =====================
|
||
-
|
||
-# E-Mail für Backup-Benachrichtigungen
|
||
-EMAIL_ENABLED=false
|
||
-
|
||
-# Backup-Verschlüsselung (optional, wird automatisch generiert wenn leer)
|
||
-# BACKUP_ENCRYPTION_KEY=
|
||
-
|
||
-# ===================== CAPTCHA KONFIGURATION =====================
|
||
-
|
||
-# Google reCAPTCHA v2 Keys (https://www.google.com/recaptcha/admin)
|
||
-# Für PoC-Phase auskommentiert - CAPTCHA wird übersprungen wenn Keys fehlen
|
||
-# RECAPTCHA_SITE_KEY=your-site-key-here
|
||
-# RECAPTCHA_SECRET_KEY=your-secret-key-here
|
||
+# PostgreSQL-Datenbank
|
||
+POSTGRES_DB=meinedatenbank
|
||
+POSTGRES_USER=adminuser
|
||
+POSTGRES_PASSWORD=supergeheimespasswort
|
||
+
|
||
+# Admin-Panel Zugangsdaten
|
||
+ADMIN1_USERNAME=rac00n
|
||
+ADMIN1_PASSWORD=1248163264
|
||
+ADMIN2_USERNAME=w@rh@mm3r
|
||
+ADMIN2_PASSWORD=Warhammer123!
|
||
+
|
||
+# Lizenzserver API Key für Authentifizierung
|
||
+
|
||
+
|
||
+# Domains (können von der App ausgewertet werden, z. B. für Links oder CORS)
|
||
+API_DOMAIN=api-software-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
+ADMIN_PANEL_DOMAIN=admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com
|
||
+
|
||
+# ===================== OPTIONALE VARIABLEN =====================
|
||
+
|
||
+# JWT für API-Auth
|
||
+# JWT_SECRET=geheimer_token_schlüssel
|
||
+
|
||
+# E-Mail Konfiguration (z. B. bei Ablaufwarnungen)
|
||
+# MAIL_SERVER=smtp.meinedomain.de
|
||
+# MAIL_PORT=587
|
||
+# MAIL_USERNAME=deinemail
|
||
+# MAIL_PASSWORD=geheim
|
||
+# MAIL_FROM=no-reply@meinedomain.de
|
||
+
|
||
+# Logging
|
||
+# LOG_LEVEL=info
|
||
+
|
||
+# Erlaubte CORS-Domains (für Web-Frontend)
|
||
+# ALLOWED_ORIGINS=https://admin.meinedomain.de
|
||
+
|
||
+# ===================== VERSION =====================
|
||
+
|
||
+# Serverseitig gepflegte aktuelle Software-Version
|
||
+# Diese wird vom Lizenzserver genutzt, um die Kundenversion zu vergleichen
|
||
+LATEST_CLIENT_VERSION=1.0.0
|
||
+
|
||
+# ===================== BACKUP KONFIGURATION =====================
|
||
+
|
||
+# E-Mail für Backup-Benachrichtigungen
|
||
+EMAIL_ENABLED=false
|
||
+
|
||
+# Backup-Verschlüsselung (optional, wird automatisch generiert wenn leer)
|
||
+# BACKUP_ENCRYPTION_KEY=
|
||
+
|
||
+# ===================== CAPTCHA KONFIGURATION =====================
|
||
+
|
||
+# Google reCAPTCHA v2 Keys (https://www.google.com/recaptcha/admin)
|
||
+# Für PoC-Phase auskommentiert - CAPTCHA wird übersprungen wenn Keys fehlen
|
||
+# RECAPTCHA_SITE_KEY=your-site-key-here
|
||
+# RECAPTCHA_SECRET_KEY=your-secret-key-here
|
||
diff --git a/v2/docker-compose.yaml b/v2/docker-compose.yaml
|
||
index 5ab8942..fe2acee 100644
|
||
--- a/v2/docker-compose.yaml
|
||
+++ b/v2/docker-compose.yaml
|
||
@@ -1,90 +1,90 @@
|
||
-services:
|
||
- postgres:
|
||
- build:
|
||
- context: ../v2_postgres
|
||
- container_name: db
|
||
- restart: always
|
||
- env_file: .env
|
||
- environment:
|
||
- POSTGRES_HOST: postgres
|
||
- POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=de_DE.UTF-8'
|
||
- POSTGRES_COLLATE: 'de_DE.UTF-8'
|
||
- POSTGRES_CTYPE: 'de_DE.UTF-8'
|
||
- TZ: Europe/Berlin
|
||
- PGTZ: Europe/Berlin
|
||
- volumes:
|
||
- # Persistente Speicherung der Datenbank auf dem Windows-Host
|
||
- - postgres_data:/var/lib/postgresql/data
|
||
- # Init-Skript für Tabellen
|
||
- - ../v2_adminpanel/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||
- networks:
|
||
- - internal_net
|
||
- deploy:
|
||
- resources:
|
||
- limits:
|
||
- cpus: '2'
|
||
- memory: 4g
|
||
-
|
||
- license-server:
|
||
- build:
|
||
- context: ../v2_lizenzserver
|
||
- container_name: license-server
|
||
- restart: always
|
||
- # Port-Mapping entfernt - nur noch über Nginx erreichbar
|
||
- env_file: .env
|
||
- environment:
|
||
- TZ: Europe/Berlin
|
||
- depends_on:
|
||
- - postgres
|
||
- networks:
|
||
- - internal_net
|
||
- deploy:
|
||
- resources:
|
||
- limits:
|
||
- cpus: '2'
|
||
- memory: 4g
|
||
-
|
||
- admin-panel:
|
||
- build:
|
||
- context: ../v2_adminpanel
|
||
- container_name: admin-panel
|
||
- restart: always
|
||
- # Port-Mapping entfernt - nur über nginx erreichbar
|
||
- env_file: .env
|
||
- environment:
|
||
- TZ: Europe/Berlin
|
||
- depends_on:
|
||
- - postgres
|
||
- networks:
|
||
- - internal_net
|
||
- volumes:
|
||
- # Backup-Verzeichnis
|
||
- - ../backups:/app/backups
|
||
- deploy:
|
||
- resources:
|
||
- limits:
|
||
- cpus: '2'
|
||
- memory: 4g
|
||
-
|
||
- nginx:
|
||
- build:
|
||
- context: ../v2_nginx
|
||
- container_name: nginx-proxy
|
||
- restart: always
|
||
- ports:
|
||
- - "80:80"
|
||
- - "443:443"
|
||
- environment:
|
||
- TZ: Europe/Berlin
|
||
- depends_on:
|
||
- - admin-panel
|
||
- - license-server
|
||
- networks:
|
||
- - internal_net
|
||
-
|
||
-networks:
|
||
- internal_net:
|
||
- driver: bridge
|
||
-
|
||
-volumes:
|
||
- postgres_data:
|
||
+services:
|
||
+ postgres:
|
||
+ build:
|
||
+ context: ../v2_postgres
|
||
+ container_name: db
|
||
+ restart: always
|
||
+ env_file: .env
|
||
+ environment:
|
||
+ POSTGRES_HOST: postgres
|
||
+ POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=de_DE.UTF-8'
|
||
+ POSTGRES_COLLATE: 'de_DE.UTF-8'
|
||
+ POSTGRES_CTYPE: 'de_DE.UTF-8'
|
||
+ TZ: Europe/Berlin
|
||
+ PGTZ: Europe/Berlin
|
||
+ volumes:
|
||
+ # Persistente Speicherung der Datenbank auf dem Windows-Host
|
||
+ - postgres_data:/var/lib/postgresql/data
|
||
+ # Init-Skript für Tabellen
|
||
+ - ../v2_adminpanel/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||
+ networks:
|
||
+ - internal_net
|
||
+ deploy:
|
||
+ resources:
|
||
+ limits:
|
||
+ cpus: '2'
|
||
+ memory: 4g
|
||
+
|
||
+ license-server:
|
||
+ build:
|
||
+ context: ../v2_lizenzserver
|
||
+ container_name: license-server
|
||
+ restart: always
|
||
+ # Port-Mapping entfernt - nur noch über Nginx erreichbar
|
||
+ env_file: .env
|
||
+ environment:
|
||
+ TZ: Europe/Berlin
|
||
+ depends_on:
|
||
+ - postgres
|
||
+ networks:
|
||
+ - internal_net
|
||
+ deploy:
|
||
+ resources:
|
||
+ limits:
|
||
+ cpus: '2'
|
||
+ memory: 4g
|
||
+
|
||
+ admin-panel:
|
||
+ build:
|
||
+ context: ../v2_adminpanel
|
||
+ container_name: admin-panel
|
||
+ restart: always
|
||
+ # Port-Mapping entfernt - nur über nginx erreichbar
|
||
+ env_file: .env
|
||
+ environment:
|
||
+ TZ: Europe/Berlin
|
||
+ depends_on:
|
||
+ - postgres
|
||
+ networks:
|
||
+ - internal_net
|
||
+ volumes:
|
||
+ # Backup-Verzeichnis
|
||
+ - ../backups:/app/backups
|
||
+ deploy:
|
||
+ resources:
|
||
+ limits:
|
||
+ cpus: '2'
|
||
+ memory: 4g
|
||
+
|
||
+ nginx:
|
||
+ build:
|
||
+ context: ../v2_nginx
|
||
+ container_name: nginx-proxy
|
||
+ restart: always
|
||
+ ports:
|
||
+ - "80:80"
|
||
+ - "443:443"
|
||
+ environment:
|
||
+ TZ: Europe/Berlin
|
||
+ depends_on:
|
||
+ - admin-panel
|
||
+ - license-server
|
||
+ networks:
|
||
+ - internal_net
|
||
+
|
||
+networks:
|
||
+ internal_net:
|
||
+ driver: bridge
|
||
+
|
||
+volumes:
|
||
+ postgres_data:
|
||
diff --git a/v2_adminpanel/Dockerfile b/v2_adminpanel/Dockerfile
|
||
index cee53bf..dde0146 100644
|
||
--- a/v2_adminpanel/Dockerfile
|
||
+++ b/v2_adminpanel/Dockerfile
|
||
@@ -1,33 +1,33 @@
|
||
-FROM python:3.11-slim
|
||
-
|
||
-# Locale für deutsche Sprache und UTF-8 setzen
|
||
-ENV LANG=de_DE.UTF-8
|
||
-ENV LC_ALL=de_DE.UTF-8
|
||
-ENV PYTHONIOENCODING=utf-8
|
||
-
|
||
-# Zeitzone auf Europe/Berlin setzen
|
||
-ENV TZ=Europe/Berlin
|
||
-
|
||
-WORKDIR /app
|
||
-
|
||
-# System-Dependencies inkl. PostgreSQL-Tools installieren
|
||
-RUN apt-get update && apt-get install -y \
|
||
- locales \
|
||
- postgresql-client \
|
||
- tzdata \
|
||
- && sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \
|
||
- && locale-gen \
|
||
- && update-locale LANG=de_DE.UTF-8 \
|
||
- && ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \
|
||
- && echo "Europe/Berlin" > /etc/timezone \
|
||
- && apt-get clean \
|
||
- && rm -rf /var/lib/apt/lists/*
|
||
-
|
||
-COPY requirements.txt .
|
||
-RUN pip install --no-cache-dir -r requirements.txt
|
||
-
|
||
-COPY . .
|
||
-
|
||
-EXPOSE 5000
|
||
-
|
||
-CMD ["python", "app.py"]
|
||
+FROM python:3.11-slim
|
||
+
|
||
+# Locale für deutsche Sprache und UTF-8 setzen
|
||
+ENV LANG=de_DE.UTF-8
|
||
+ENV LC_ALL=de_DE.UTF-8
|
||
+ENV PYTHONIOENCODING=utf-8
|
||
+
|
||
+# Zeitzone auf Europe/Berlin setzen
|
||
+ENV TZ=Europe/Berlin
|
||
+
|
||
+WORKDIR /app
|
||
+
|
||
+# System-Dependencies inkl. PostgreSQL-Tools installieren
|
||
+RUN apt-get update && apt-get install -y \
|
||
+ locales \
|
||
+ postgresql-client \
|
||
+ tzdata \
|
||
+ && sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \
|
||
+ && locale-gen \
|
||
+ && update-locale LANG=de_DE.UTF-8 \
|
||
+ && ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \
|
||
+ && echo "Europe/Berlin" > /etc/timezone \
|
||
+ && apt-get clean \
|
||
+ && rm -rf /var/lib/apt/lists/*
|
||
+
|
||
+COPY requirements.txt .
|
||
+RUN pip install --no-cache-dir -r requirements.txt
|
||
+
|
||
+COPY . .
|
||
+
|
||
+EXPOSE 5000
|
||
+
|
||
+CMD ["python", "app.py"]
|
||
diff --git a/v2_adminpanel/__pycache__/app.cpython-312.pyc b/v2_adminpanel/__pycache__/app.cpython-312.pyc
|
||
index ae11b74..99427c4 100644
|
||
Binary files a/v2_adminpanel/__pycache__/app.cpython-312.pyc and b/v2_adminpanel/__pycache__/app.cpython-312.pyc differ
|
||
diff --git a/v2_adminpanel/app.py b/v2_adminpanel/app.py
|
||
index 0622714..4e6204a 100644
|
||
--- a/v2_adminpanel/app.py
|
||
+++ b/v2_adminpanel/app.py
|
||
@@ -77,10 +77,24 @@ logging.basicConfig(level=logging.INFO)
|
||
# Import and register blueprints
|
||
from routes.auth_routes import auth_bp
|
||
from routes.admin_routes import admin_bp
|
||
+from routes.license_routes import license_bp
|
||
+from routes.customer_routes import customer_bp
|
||
+from routes.resource_routes import resource_bp
|
||
+from routes.session_routes import session_bp
|
||
+from routes.batch_routes import batch_bp
|
||
+from routes.api_routes import api_bp
|
||
+from routes.export_routes import export_bp
|
||
|
||
-# Temporarily comment out blueprints to avoid conflicts
|
||
-# app.register_blueprint(auth_bp)
|
||
-# app.register_blueprint(admin_bp)
|
||
+# Register blueprints
|
||
+app.register_blueprint(auth_bp)
|
||
+app.register_blueprint(admin_bp)
|
||
+app.register_blueprint(license_bp)
|
||
+app.register_blueprint(customer_bp)
|
||
+app.register_blueprint(resource_bp)
|
||
+app.register_blueprint(session_bp)
|
||
+app.register_blueprint(batch_bp)
|
||
+app.register_blueprint(api_bp)
|
||
+app.register_blueprint(export_bp)
|
||
|
||
|
||
# Scheduled Backup Job
|
||
@@ -136,213 +150,213 @@ def verify_recaptcha(response):
|
||
return False
|
||
|
||
|
||
-@app.route("/login", methods=["GET", "POST"])
|
||
-def login():
|
||
+# @app.route("/login", methods=["GET", "POST"])
|
||
+# def login():
|
||
# Timing-Attack Schutz - Start Zeit merken
|
||
- start_time = time.time()
|
||
+ # start_time = time.time()
|
||
|
||
# IP-Adresse ermitteln
|
||
- ip_address = get_client_ip()
|
||
+ # ip_address = get_client_ip()
|
||
|
||
# Prüfen ob IP gesperrt ist
|
||
- is_blocked, blocked_until = check_ip_blocked(ip_address)
|
||
- if is_blocked:
|
||
- time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600
|
||
- error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten."
|
||
- return render_template("login.html", error=error_msg, error_type="blocked")
|
||
+ # is_blocked, blocked_until = check_ip_blocked(ip_address)
|
||
+ # if is_blocked:
|
||
+ # time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600
|
||
+ # error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten."
|
||
+ # return render_template("login.html", error=error_msg, error_type="blocked")
|
||
|
||
# Anzahl bisheriger Versuche
|
||
- attempt_count = get_login_attempts(ip_address)
|
||
+ # attempt_count = get_login_attempts(ip_address)
|
||
|
||
- if request.method == "POST":
|
||
- username = request.form.get("username")
|
||
- password = request.form.get("password")
|
||
- captcha_response = request.form.get("g-recaptcha-response")
|
||
+ # if request.method == "POST":
|
||
+ # username = request.form.get("username")
|
||
+ # password = request.form.get("password")
|
||
+ # captcha_response = request.form.get("g-recaptcha-response")
|
||
|
||
# CAPTCHA-Prüfung nur wenn Keys konfiguriert sind
|
||
- recaptcha_site_key = config.RECAPTCHA_SITE_KEY
|
||
- if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key:
|
||
- if not captcha_response:
|
||
+ # recaptcha_site_key = config.RECAPTCHA_SITE_KEY
|
||
+ # if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key:
|
||
+ # if not captcha_response:
|
||
# Timing-Attack Schutz
|
||
- elapsed = time.time() - start_time
|
||
- if elapsed < 1.0:
|
||
- time.sleep(1.0 - elapsed)
|
||
- return render_template("login.html",
|
||
- error="CAPTCHA ERFORDERLICH!",
|
||
- show_captcha=True,
|
||
- error_type="captcha",
|
||
- attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
- recaptcha_site_key=recaptcha_site_key)
|
||
+ # elapsed = time.time() - start_time
|
||
+ # if elapsed < 1.0:
|
||
+ # time.sleep(1.0 - elapsed)
|
||
+ # return render_template("login.html",
|
||
+ # error="CAPTCHA ERFORDERLICH!",
|
||
+ # show_captcha=True,
|
||
+ # error_type="captcha",
|
||
+ # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
+ # recaptcha_site_key=recaptcha_site_key)
|
||
|
||
# CAPTCHA validieren
|
||
- if not verify_recaptcha(captcha_response):
|
||
+ # if not verify_recaptcha(captcha_response):
|
||
# Timing-Attack Schutz
|
||
- elapsed = time.time() - start_time
|
||
- if elapsed < 1.0:
|
||
- time.sleep(1.0 - elapsed)
|
||
- return render_template("login.html",
|
||
- error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.",
|
||
- show_captcha=True,
|
||
- error_type="captcha",
|
||
- attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
- recaptcha_site_key=recaptcha_site_key)
|
||
+ # elapsed = time.time() - start_time
|
||
+ # if elapsed < 1.0:
|
||
+ # time.sleep(1.0 - elapsed)
|
||
+ # return render_template("login.html",
|
||
+ # error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.",
|
||
+ # show_captcha=True,
|
||
+ # error_type="captcha",
|
||
+ # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
+ # recaptcha_site_key=recaptcha_site_key)
|
||
|
||
# Check user in database first, fallback to env vars
|
||
- user = get_user_by_username(username)
|
||
- login_success = False
|
||
- needs_2fa = False
|
||
+ # user = get_user_by_username(username)
|
||
+ # login_success = False
|
||
+ # needs_2fa = False
|
||
|
||
- if user:
|
||
+ # if user:
|
||
# Database user authentication
|
||
- if verify_password(password, user['password_hash']):
|
||
- login_success = True
|
||
- needs_2fa = user['totp_enabled']
|
||
- else:
|
||
+ # if verify_password(password, user['password_hash']):
|
||
+ # login_success = True
|
||
+ # needs_2fa = user['totp_enabled']
|
||
+ # else:
|
||
# Fallback to environment variables for backward compatibility
|
||
- if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]:
|
||
- login_success = True
|
||
+ # if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]:
|
||
+ # login_success = True
|
||
|
||
# Timing-Attack Schutz - Mindestens 1 Sekunde warten
|
||
- elapsed = time.time() - start_time
|
||
- if elapsed < 1.0:
|
||
- time.sleep(1.0 - elapsed)
|
||
+ # elapsed = time.time() - start_time
|
||
+ # if elapsed < 1.0:
|
||
+ # time.sleep(1.0 - elapsed)
|
||
|
||
- if login_success:
|
||
+ # if login_success:
|
||
# Erfolgreicher Login
|
||
- if needs_2fa:
|
||
+ # if needs_2fa:
|
||
# Store temporary session for 2FA verification
|
||
- session['temp_username'] = username
|
||
- session['temp_user_id'] = user['id']
|
||
- session['awaiting_2fa'] = True
|
||
- return redirect(url_for('verify_2fa'))
|
||
- else:
|
||
+ # session['temp_username'] = username
|
||
+ # session['temp_user_id'] = user['id']
|
||
+ # session['awaiting_2fa'] = True
|
||
+ # return redirect(url_for('verify_2fa'))
|
||
+ # else:
|
||
# Complete login without 2FA
|
||
- session.permanent = True # Aktiviert das Timeout
|
||
- session['logged_in'] = True
|
||
- session['username'] = username
|
||
- session['user_id'] = user['id'] if user else None
|
||
- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- reset_login_attempts(ip_address)
|
||
- log_audit('LOGIN_SUCCESS', 'user',
|
||
- additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}")
|
||
- return redirect(url_for('dashboard'))
|
||
- else:
|
||
+ # session.permanent = True # Aktiviert das Timeout
|
||
+ # session['logged_in'] = True
|
||
+ # session['username'] = username
|
||
+ # session['user_id'] = user['id'] if user else None
|
||
+ # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ # reset_login_attempts(ip_address)
|
||
+ # log_audit('LOGIN_SUCCESS', 'user',
|
||
+ # additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}")
|
||
+ # return redirect(url_for('dashboard'))
|
||
+ # else:
|
||
# Fehlgeschlagener Login
|
||
- error_message = record_failed_attempt(ip_address, username)
|
||
- new_attempt_count = get_login_attempts(ip_address)
|
||
+ # error_message = record_failed_attempt(ip_address, username)
|
||
+ # new_attempt_count = get_login_attempts(ip_address)
|
||
|
||
# Prüfen ob jetzt gesperrt
|
||
- is_now_blocked, _ = check_ip_blocked(ip_address)
|
||
- if is_now_blocked:
|
||
- log_audit('LOGIN_BLOCKED', 'security',
|
||
- additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt")
|
||
+ # is_now_blocked, _ = check_ip_blocked(ip_address)
|
||
+ # if is_now_blocked:
|
||
+ # log_audit('LOGIN_BLOCKED', 'security',
|
||
+ # additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt")
|
||
|
||
- return render_template("login.html",
|
||
- error=error_message,
|
||
- show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY),
|
||
- error_type="failed",
|
||
- attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count),
|
||
- recaptcha_site_key=config.RECAPTCHA_SITE_KEY)
|
||
+ # return render_template("login.html",
|
||
+ # error=error_message,
|
||
+ # show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY),
|
||
+ # error_type="failed",
|
||
+ # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count),
|
||
+ # recaptcha_site_key=config.RECAPTCHA_SITE_KEY)
|
||
|
||
# GET Request
|
||
- return render_template("login.html",
|
||
- show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY),
|
||
- attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
- recaptcha_site_key=config.RECAPTCHA_SITE_KEY)
|
||
+ # return render_template("login.html",
|
||
+ # show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY),
|
||
+ # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
+ # recaptcha_site_key=config.RECAPTCHA_SITE_KEY)
|
||
|
||
-@app.route("/logout")
|
||
-def logout():
|
||
- username = session.get('username', 'unknown')
|
||
- log_audit('LOGOUT', 'user', additional_info=f"Abmeldung")
|
||
- session.pop('logged_in', None)
|
||
- session.pop('username', None)
|
||
- session.pop('user_id', None)
|
||
- session.pop('temp_username', None)
|
||
- session.pop('temp_user_id', None)
|
||
- session.pop('awaiting_2fa', None)
|
||
- return redirect(url_for('login'))
|
||
+# @app.route("/logout")
|
||
+# def logout():
|
||
+ # username = session.get('username', 'unknown')
|
||
+ # log_audit('LOGOUT', 'user', additional_info=f"Abmeldung")
|
||
+ # session.pop('logged_in', None)
|
||
+ # session.pop('username', None)
|
||
+ # session.pop('user_id', None)
|
||
+ # session.pop('temp_username', None)
|
||
+ # session.pop('temp_user_id', None)
|
||
+ # session.pop('awaiting_2fa', None)
|
||
+ # return redirect(url_for('login'))
|
||
|
||
-@app.route("/verify-2fa", methods=["GET", "POST"])
|
||
-def verify_2fa():
|
||
- if not session.get('awaiting_2fa'):
|
||
- return redirect(url_for('login'))
|
||
+# @app.route("/verify-2fa", methods=["GET", "POST"])
|
||
+# def verify_2fa():
|
||
+ # if not session.get('awaiting_2fa'):
|
||
+ # return redirect(url_for('login'))
|
||
|
||
- if request.method == "POST":
|
||
- token = request.form.get('token', '').replace(' ', '')
|
||
- username = session.get('temp_username')
|
||
- user_id = session.get('temp_user_id')
|
||
+ # if request.method == "POST":
|
||
+ # token = request.form.get('token', '').replace(' ', '')
|
||
+ # username = session.get('temp_username')
|
||
+ # user_id = session.get('temp_user_id')
|
||
|
||
- if not username or not user_id:
|
||
- flash('Session expired. Please login again.', 'error')
|
||
- return redirect(url_for('login'))
|
||
+ # if not username or not user_id:
|
||
+ # flash('Session expired. Please login again.', 'error')
|
||
+ # return redirect(url_for('login'))
|
||
|
||
- user = get_user_by_username(username)
|
||
- if not user:
|
||
- flash('User not found.', 'error')
|
||
- return redirect(url_for('login'))
|
||
+ # user = get_user_by_username(username)
|
||
+ # if not user:
|
||
+ # flash('User not found.', 'error')
|
||
+ # return redirect(url_for('login'))
|
||
|
||
# Check if it's a backup code
|
||
- if len(token) == 8 and token.isupper():
|
||
+ # if len(token) == 8 and token.isupper():
|
||
# Try backup code
|
||
- backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else []
|
||
- if verify_backup_code(token, backup_codes):
|
||
+ # backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else []
|
||
+ # if verify_backup_code(token, backup_codes):
|
||
# Remove used backup code
|
||
- code_hash = hash_backup_code(token)
|
||
- backup_codes.remove(code_hash)
|
||
+ # code_hash = hash_backup_code(token)
|
||
+ # backup_codes.remove(code_hash)
|
||
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s",
|
||
- (json.dumps(backup_codes), user_id))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
+ # conn = get_connection()
|
||
+ # cur = conn.cursor()
|
||
+ # cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s",
|
||
+ # (json.dumps(backup_codes), user_id))
|
||
+ # conn.commit()
|
||
+ # cur.close()
|
||
+ # conn.close()
|
||
|
||
# Complete login
|
||
- session.permanent = True
|
||
- session['logged_in'] = True
|
||
- session['username'] = username
|
||
- session['user_id'] = user_id
|
||
- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- session.pop('temp_username', None)
|
||
- session.pop('temp_user_id', None)
|
||
- session.pop('awaiting_2fa', None)
|
||
+ # session.permanent = True
|
||
+ # session['logged_in'] = True
|
||
+ # session['username'] = username
|
||
+ # session['user_id'] = user_id
|
||
+ # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ # session.pop('temp_username', None)
|
||
+ # session.pop('temp_user_id', None)
|
||
+ # session.pop('awaiting_2fa', None)
|
||
|
||
- flash('Login successful using backup code. Please generate new backup codes.', 'warning')
|
||
- log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code")
|
||
- return redirect(url_for('dashboard'))
|
||
- else:
|
||
+ # flash('Login successful using backup code. Please generate new backup codes.', 'warning')
|
||
+ # log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code")
|
||
+ # return redirect(url_for('dashboard'))
|
||
+ # else:
|
||
# Try TOTP token
|
||
- if verify_totp(user['totp_secret'], token):
|
||
+ # if verify_totp(user['totp_secret'], token):
|
||
# Complete login
|
||
- session.permanent = True
|
||
- session['logged_in'] = True
|
||
- session['username'] = username
|
||
- session['user_id'] = user_id
|
||
- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- session.pop('temp_username', None)
|
||
- session.pop('temp_user_id', None)
|
||
- session.pop('awaiting_2fa', None)
|
||
+ # session.permanent = True
|
||
+ # session['logged_in'] = True
|
||
+ # session['username'] = username
|
||
+ # session['user_id'] = user_id
|
||
+ # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ # session.pop('temp_username', None)
|
||
+ # session.pop('temp_user_id', None)
|
||
+ # session.pop('awaiting_2fa', None)
|
||
|
||
- log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful")
|
||
- return redirect(url_for('dashboard'))
|
||
+ # log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful")
|
||
+ # return redirect(url_for('dashboard'))
|
||
|
||
# Failed verification
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s",
|
||
- (datetime.now(), user_id))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- flash('Invalid authentication code. Please try again.', 'error')
|
||
- log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt")
|
||
-
|
||
- return render_template('verify_2fa.html')
|
||
+ # conn = get_connection()
|
||
+ # cur = conn.cursor()
|
||
+ # cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s",
|
||
+ # (datetime.now(), user_id))
|
||
+ # conn.commit()
|
||
+ # cur.close()
|
||
+ # conn.close()
|
||
+
|
||
+ # flash('Invalid authentication code. Please try again.', 'error')
|
||
+ # log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt")
|
||
+
|
||
+ # return render_template('verify_2fa.html')
|
||
|
||
-@app.route("/profile")
|
||
-@login_required
|
||
+# @app.route("/profile")
|
||
+# @login_required
|
||
def profile():
|
||
user = get_user_by_username(session['username'])
|
||
if not user:
|
||
@@ -351,8 +365,8 @@ def profile():
|
||
return redirect(url_for('dashboard'))
|
||
return render_template('profile.html', user=user)
|
||
|
||
-@app.route("/profile/change-password", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/profile/change-password", methods=["POST"])
|
||
+# @login_required
|
||
def change_password():
|
||
current_password = request.form.get('current_password')
|
||
new_password = request.form.get('new_password')
|
||
@@ -389,8 +403,8 @@ def change_password():
|
||
flash('Password changed successfully.', 'success')
|
||
return redirect(url_for('profile'))
|
||
|
||
-@app.route("/profile/setup-2fa")
|
||
-@login_required
|
||
+# @app.route("/profile/setup-2fa")
|
||
+# @login_required
|
||
def setup_2fa():
|
||
user = get_user_by_username(session['username'])
|
||
|
||
@@ -409,8 +423,8 @@ def setup_2fa():
|
||
totp_secret=totp_secret,
|
||
qr_code=qr_code)
|
||
|
||
-@app.route("/profile/enable-2fa", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/profile/enable-2fa", methods=["POST"])
|
||
+# @login_required
|
||
def enable_2fa():
|
||
token = request.form.get('token', '').replace(' ', '')
|
||
totp_secret = session.get('temp_totp_secret')
|
||
@@ -447,8 +461,8 @@ def enable_2fa():
|
||
# Show backup codes
|
||
return render_template('backup_codes.html', backup_codes=backup_codes)
|
||
|
||
-@app.route("/profile/disable-2fa", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/profile/disable-2fa", methods=["POST"])
|
||
+# @login_required
|
||
def disable_2fa():
|
||
password = request.form.get('password')
|
||
user = get_user_by_username(session['username'])
|
||
@@ -474,8 +488,8 @@ def disable_2fa():
|
||
flash('2FA has been disabled for your account.', 'success')
|
||
return redirect(url_for('profile'))
|
||
|
||
-@app.route("/heartbeat", methods=['POST'])
|
||
-@login_required
|
||
+# @app.route("/heartbeat", methods=['POST'])
|
||
+# @login_required
|
||
def heartbeat():
|
||
"""Endpoint für Session Keep-Alive - aktualisiert last_activity"""
|
||
# Aktualisiere last_activity nur wenn explizit angefordert
|
||
@@ -489,8 +503,8 @@ def heartbeat():
|
||
'username': session.get('username')
|
||
})
|
||
|
||
-@app.route("/api/generate-license-key", methods=['POST'])
|
||
-@login_required
|
||
+# @app.route("/api/generate-license-key", methods=['POST'])
|
||
+# @login_required
|
||
def api_generate_key():
|
||
"""API Endpoint zur Generierung eines neuen Lizenzschlüssels"""
|
||
try:
|
||
@@ -534,8 +548,8 @@ def api_generate_key():
|
||
'error': 'Fehler bei der Key-Generierung'
|
||
}), 500
|
||
|
||
-@app.route("/api/customers", methods=['GET'])
|
||
-@login_required
|
||
+# @app.route("/api/customers", methods=['GET'])
|
||
+# @login_required
|
||
def api_customers():
|
||
"""API Endpoint für die Kundensuche mit Select2"""
|
||
try:
|
||
@@ -645,8 +659,8 @@ def api_customers():
|
||
'error': 'Fehler bei der Kundensuche'
|
||
}), 500
|
||
|
||
-@app.route("/")
|
||
-@login_required
|
||
+# @app.route("/")
|
||
+# @login_required
|
||
def dashboard():
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -875,8 +889,8 @@ def dashboard():
|
||
resource_warning=resource_warning,
|
||
username=session.get('username'))
|
||
|
||
-@app.route("/create", methods=["GET", "POST"])
|
||
-@login_required
|
||
+# @app.route("/create", methods=["GET", "POST"])
|
||
+# @login_required
|
||
def create_license():
|
||
if request.method == "POST":
|
||
customer_id = request.form.get("customer_id")
|
||
@@ -1106,8 +1120,8 @@ def create_license():
|
||
preselected_customer_id = request.args.get('customer_id', type=int)
|
||
return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id)
|
||
|
||
-@app.route("/batch", methods=["GET", "POST"])
|
||
-@login_required
|
||
+# @app.route("/batch", methods=["GET", "POST"])
|
||
+# @login_required
|
||
def batch_licenses():
|
||
"""Batch-Generierung mehrerer Lizenzen für einen Kunden"""
|
||
if request.method == "POST":
|
||
@@ -1361,8 +1375,8 @@ def batch_licenses():
|
||
# GET Request
|
||
return render_template("batch_form.html")
|
||
|
||
-@app.route("/batch/export")
|
||
-@login_required
|
||
+# @app.route("/batch/export")
|
||
+# @login_required
|
||
def export_batch():
|
||
"""Exportiert die zuletzt generierten Batch-Lizenzen"""
|
||
batch_data = session.get('batch_export')
|
||
@@ -1400,14 +1414,14 @@ def export_batch():
|
||
download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv"
|
||
)
|
||
|
||
-@app.route("/licenses")
|
||
-@login_required
|
||
+# @app.route("/licenses")
|
||
+# @login_required
|
||
def licenses():
|
||
# Redirect zur kombinierten Ansicht
|
||
return redirect("/customers-licenses")
|
||
|
||
-@app.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
|
||
-@login_required
|
||
+# @app.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
|
||
+# @login_required
|
||
def edit_license(license_id):
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -1498,8 +1512,8 @@ def edit_license(license_id):
|
||
|
||
return render_template("edit_license.html", license=license, username=session.get('username'))
|
||
|
||
-@app.route("/license/delete/<int:license_id>", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/license/delete/<int:license_id>", methods=["POST"])
|
||
+# @login_required
|
||
def delete_license(license_id):
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -1531,14 +1545,14 @@ def delete_license(license_id):
|
||
|
||
return redirect("/licenses")
|
||
|
||
-@app.route("/customers")
|
||
-@login_required
|
||
+# @app.route("/customers")
|
||
+# @login_required
|
||
def customers():
|
||
# Redirect zur kombinierten Ansicht
|
||
return redirect("/customers-licenses")
|
||
|
||
-@app.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
|
||
-@login_required
|
||
+# @app.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
|
||
+# @login_required
|
||
def edit_customer(customer_id):
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -1621,8 +1635,8 @@ def edit_customer(customer_id):
|
||
|
||
return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username'))
|
||
|
||
-@app.route("/customer/create", methods=["GET", "POST"])
|
||
-@login_required
|
||
+# @app.route("/customer/create", methods=["GET", "POST"])
|
||
+# @login_required
|
||
def create_customer():
|
||
"""Erstellt einen neuen Kunden ohne Lizenz"""
|
||
if request.method == "POST":
|
||
@@ -1676,8 +1690,8 @@ def create_customer():
|
||
# GET Request - Formular anzeigen
|
||
return render_template("create_customer.html", username=session.get('username'))
|
||
|
||
-@app.route("/customer/delete/<int:customer_id>", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/customer/delete/<int:customer_id>", methods=["POST"])
|
||
+# @login_required
|
||
def delete_customer(customer_id):
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -1714,8 +1728,8 @@ def delete_customer(customer_id):
|
||
|
||
return redirect("/customers")
|
||
|
||
-@app.route("/customers-licenses")
|
||
-@login_required
|
||
+# @app.route("/customers-licenses")
|
||
+# @login_required
|
||
def customers_licenses():
|
||
"""Kombinierte Ansicht für Kunden und deren Lizenzen"""
|
||
conn = get_connection()
|
||
@@ -1807,8 +1821,8 @@ def customers_licenses():
|
||
licenses=licenses,
|
||
show_test=show_test)
|
||
|
||
-@app.route("/api/customer/<int:customer_id>/licenses")
|
||
-@login_required
|
||
+# @app.route("/api/customer/<int:customer_id>/licenses")
|
||
+# @login_required
|
||
def api_customer_licenses(customer_id):
|
||
"""API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden"""
|
||
conn = get_connection()
|
||
@@ -1910,8 +1924,8 @@ def api_customer_licenses(customer_id):
|
||
'count': len(licenses)
|
||
})
|
||
|
||
-@app.route("/api/customer/<int:customer_id>/quick-stats")
|
||
-@login_required
|
||
+# @app.route("/api/customer/<int:customer_id>/quick-stats")
|
||
+# @login_required
|
||
def api_customer_quick_stats(customer_id):
|
||
"""API-Endpoint für Schnellstatistiken eines Kunden"""
|
||
conn = get_connection()
|
||
@@ -1943,8 +1957,8 @@ def api_customer_quick_stats(customer_id):
|
||
}
|
||
})
|
||
|
||
-@app.route("/api/license/<int:license_id>/quick-edit", methods=['POST'])
|
||
-@login_required
|
||
+# @app.route("/api/license/<int:license_id>/quick-edit", methods=['POST'])
|
||
+# @login_required
|
||
def api_license_quick_edit(license_id):
|
||
"""API-Endpoint für schnelle Lizenz-Bearbeitung"""
|
||
conn = get_connection()
|
||
@@ -2013,8 +2027,8 @@ def api_license_quick_edit(license_id):
|
||
conn.close()
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
-@app.route("/api/license/<int:license_id>/resources")
|
||
-@login_required
|
||
+# @app.route("/api/license/<int:license_id>/resources")
|
||
+# @login_required
|
||
def api_license_resources(license_id):
|
||
"""API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz"""
|
||
conn = get_connection()
|
||
@@ -2063,8 +2077,8 @@ def api_license_resources(license_id):
|
||
conn.close()
|
||
return jsonify({'success': False, 'error': str(e)}), 500
|
||
|
||
-@app.route("/sessions")
|
||
-@login_required
|
||
+# @app.route("/sessions")
|
||
+# @login_required
|
||
def sessions():
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -2145,8 +2159,8 @@ def sessions():
|
||
ended_order=ended_order,
|
||
username=session.get('username'))
|
||
|
||
-@app.route("/session/end/<int:session_id>", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/session/end/<int:session_id>", methods=["POST"])
|
||
+# @login_required
|
||
def end_session(session_id):
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -2164,8 +2178,8 @@ def end_session(session_id):
|
||
|
||
return redirect("/sessions")
|
||
|
||
-@app.route("/export/licenses")
|
||
-@login_required
|
||
+# @app.route("/export/licenses")
|
||
+# @login_required
|
||
def export_licenses():
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -2274,8 +2288,8 @@ def export_licenses():
|
||
download_name=f'{filename}.xlsx'
|
||
)
|
||
|
||
-@app.route("/export/audit")
|
||
-@login_required
|
||
+# @app.route("/export/audit")
|
||
+# @login_required
|
||
def export_audit():
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -2398,8 +2412,8 @@ def export_audit():
|
||
download_name=f'{filename}.xlsx'
|
||
)
|
||
|
||
-@app.route("/export/customers")
|
||
-@login_required
|
||
+# @app.route("/export/customers")
|
||
+# @login_required
|
||
def export_customers():
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -2502,8 +2516,8 @@ def export_customers():
|
||
download_name=f'{filename}.xlsx'
|
||
)
|
||
|
||
-@app.route("/export/sessions")
|
||
-@login_required
|
||
+# @app.route("/export/sessions")
|
||
+# @login_required
|
||
def export_sessions():
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -2641,8 +2655,8 @@ def export_sessions():
|
||
download_name=f'{filename}.xlsx'
|
||
)
|
||
|
||
-@app.route("/export/resources")
|
||
-@login_required
|
||
+# @app.route("/export/resources")
|
||
+# @login_required
|
||
def export_resources():
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -2770,8 +2784,8 @@ def export_resources():
|
||
download_name=f'{filename}.xlsx'
|
||
)
|
||
|
||
-@app.route("/audit")
|
||
-@login_required
|
||
+# @app.route("/audit")
|
||
+# @login_required
|
||
def audit_log():
|
||
conn = get_connection()
|
||
cur = conn.cursor()
|
||
@@ -2864,8 +2878,8 @@ def audit_log():
|
||
order=order,
|
||
username=session.get('username'))
|
||
|
||
-@app.route("/backups")
|
||
-@login_required
|
||
+# @app.route("/backups")
|
||
+# @login_required
|
||
def backups():
|
||
"""Zeigt die Backup-Historie an"""
|
||
conn = get_connection()
|
||
@@ -2899,8 +2913,8 @@ def backups():
|
||
last_backup=last_backup,
|
||
username=session.get('username'))
|
||
|
||
-@app.route("/backup/create", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/backup/create", methods=["POST"])
|
||
+# @login_required
|
||
def create_backup_route():
|
||
"""Erstellt ein manuelles Backup"""
|
||
username = session.get('username')
|
||
@@ -2917,8 +2931,8 @@ def create_backup_route():
|
||
'message': f'Backup fehlgeschlagen: {result}'
|
||
}), 500
|
||
|
||
-@app.route("/backup/restore/<int:backup_id>", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/backup/restore/<int:backup_id>", methods=["POST"])
|
||
+# @login_required
|
||
def restore_backup_route(backup_id):
|
||
"""Stellt ein Backup wieder her"""
|
||
encryption_key = request.form.get('encryption_key')
|
||
@@ -2936,8 +2950,8 @@ def restore_backup_route(backup_id):
|
||
'message': message
|
||
}), 500
|
||
|
||
-@app.route("/backup/download/<int:backup_id>")
|
||
-@login_required
|
||
+# @app.route("/backup/download/<int:backup_id>")
|
||
+# @login_required
|
||
def download_backup(backup_id):
|
||
"""Lädt eine Backup-Datei herunter"""
|
||
conn = get_connection()
|
||
@@ -2968,8 +2982,8 @@ def download_backup(backup_id):
|
||
|
||
return send_file(filepath, as_attachment=True, download_name=filename)
|
||
|
||
-@app.route("/backup/delete/<int:backup_id>", methods=["DELETE"])
|
||
-@login_required
|
||
+# @app.route("/backup/delete/<int:backup_id>", methods=["DELETE"])
|
||
+# @login_required
|
||
def delete_backup(backup_id):
|
||
"""Löscht ein Backup"""
|
||
conn = get_connection()
|
||
@@ -3024,8 +3038,8 @@ def delete_backup(backup_id):
|
||
cur.close()
|
||
conn.close()
|
||
|
||
-@app.route("/security/blocked-ips")
|
||
-@login_required
|
||
+# @app.route("/security/blocked-ips")
|
||
+# @login_required
|
||
def blocked_ips():
|
||
"""Zeigt alle gesperrten IPs an"""
|
||
conn = get_connection()
|
||
@@ -3065,8 +3079,8 @@ def blocked_ips():
|
||
blocked_ips=blocked_ips_list,
|
||
username=session.get('username'))
|
||
|
||
-@app.route("/security/unblock-ip", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/security/unblock-ip", methods=["POST"])
|
||
+# @login_required
|
||
def unblock_ip():
|
||
"""Entsperrt eine IP-Adresse"""
|
||
ip_address = request.form.get('ip_address')
|
||
@@ -3091,8 +3105,8 @@ def unblock_ip():
|
||
|
||
return redirect(url_for('blocked_ips'))
|
||
|
||
-@app.route("/security/clear-attempts", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/security/clear-attempts", methods=["POST"])
|
||
+# @login_required
|
||
def clear_attempts():
|
||
"""Löscht alle Login-Versuche für eine IP"""
|
||
ip_address = request.form.get('ip_address')
|
||
@@ -3107,8 +3121,8 @@ def clear_attempts():
|
||
return redirect(url_for('blocked_ips'))
|
||
|
||
# API Endpoints for License Management
|
||
-@app.route("/api/license/<int:license_id>/toggle", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/api/license/<int:license_id>/toggle", methods=["POST"])
|
||
+# @login_required
|
||
def toggle_license_api(license_id):
|
||
"""Toggle license active status via API"""
|
||
try:
|
||
@@ -3139,8 +3153,8 @@ def toggle_license_api(license_id):
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'message': str(e)}), 500
|
||
|
||
-@app.route("/api/licenses/bulk-activate", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/api/licenses/bulk-activate", methods=["POST"])
|
||
+# @login_required
|
||
def bulk_activate_licenses():
|
||
"""Activate multiple licenses at once"""
|
||
try:
|
||
@@ -3175,8 +3189,8 @@ def bulk_activate_licenses():
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'message': str(e)}), 500
|
||
|
||
-@app.route("/api/licenses/bulk-deactivate", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/api/licenses/bulk-deactivate", methods=["POST"])
|
||
+# @login_required
|
||
def bulk_deactivate_licenses():
|
||
"""Deactivate multiple licenses at once"""
|
||
try:
|
||
@@ -3211,8 +3225,8 @@ def bulk_deactivate_licenses():
|
||
except Exception as e:
|
||
return jsonify({'success': False, 'message': str(e)}), 500
|
||
|
||
-@app.route("/api/license/<int:license_id>/devices")
|
||
-@login_required
|
||
+# @app.route("/api/license/<int:license_id>/devices")
|
||
+# @login_required
|
||
def get_license_devices(license_id):
|
||
"""Hole alle registrierten Geräte einer Lizenz"""
|
||
try:
|
||
@@ -3266,123 +3280,123 @@ def get_license_devices(license_id):
|
||
logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}")
|
||
return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500
|
||
|
||
-@app.route("/api/license/<int:license_id>/register-device", methods=["POST"])
|
||
-def register_device(license_id):
|
||
- """Registriere ein neues Gerät für eine Lizenz"""
|
||
- try:
|
||
- data = request.get_json()
|
||
- hardware_id = data.get('hardware_id')
|
||
- device_name = data.get('device_name', '')
|
||
- operating_system = data.get('operating_system', '')
|
||
+# @app.route("/api/license/<int:license_id>/register-device", methods=["POST"])
|
||
+# def register_device(license_id):
|
||
+ # """Registriere ein neues Gerät für eine Lizenz"""
|
||
+ # try:
|
||
+ # data = request.get_json()
|
||
+ # hardware_id = data.get('hardware_id')
|
||
+ # device_name = data.get('device_name', '')
|
||
+ # operating_system = data.get('operating_system', '')
|
||
|
||
- if not hardware_id:
|
||
- return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400
|
||
+ # if not hardware_id:
|
||
+ # return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400
|
||
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
+ # conn = get_connection()
|
||
+ # cur = conn.cursor()
|
||
|
||
# Prüfe ob Lizenz existiert und aktiv ist
|
||
- cur.execute("""
|
||
- SELECT device_limit, is_active, valid_until
|
||
- FROM licenses
|
||
- WHERE id = %s
|
||
- """, (license_id,))
|
||
- license_data = cur.fetchone()
|
||
+ # cur.execute("""
|
||
+ # SELECT device_limit, is_active, valid_until
|
||
+ # FROM licenses
|
||
+ # WHERE id = %s
|
||
+ # """, (license_id,))
|
||
+ # license_data = cur.fetchone()
|
||
|
||
- if not license_data:
|
||
- return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404
|
||
+ # if not license_data:
|
||
+ # return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404
|
||
|
||
- device_limit, is_active, valid_until = license_data
|
||
+ # device_limit, is_active, valid_until = license_data
|
||
|
||
# Prüfe ob Lizenz aktiv und gültig ist
|
||
- if not is_active:
|
||
- return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403
|
||
+ # if not is_active:
|
||
+ # return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403
|
||
|
||
- if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date():
|
||
- return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403
|
||
+ # if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date():
|
||
+ # return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403
|
||
|
||
# Prüfe ob Gerät bereits registriert ist
|
||
- cur.execute("""
|
||
- SELECT id, is_active FROM device_registrations
|
||
- WHERE license_id = %s AND hardware_id = %s
|
||
- """, (license_id, hardware_id))
|
||
- existing_device = cur.fetchone()
|
||
-
|
||
- if existing_device:
|
||
- device_id, is_device_active = existing_device
|
||
- if is_device_active:
|
||
+ # cur.execute("""
|
||
+ # SELECT id, is_active FROM device_registrations
|
||
+ # WHERE license_id = %s AND hardware_id = %s
|
||
+ # """, (license_id, hardware_id))
|
||
+ # existing_device = cur.fetchone()
|
||
+
|
||
+ # if existing_device:
|
||
+ # device_id, is_device_active = existing_device
|
||
+ # if is_device_active:
|
||
# Gerät ist bereits aktiv, update last_seen
|
||
- cur.execute("""
|
||
- UPDATE device_registrations
|
||
- SET last_seen = CURRENT_TIMESTAMP,
|
||
- ip_address = %s,
|
||
- user_agent = %s
|
||
- WHERE id = %s
|
||
- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
- conn.commit()
|
||
- return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id})
|
||
- else:
|
||
+ # cur.execute("""
|
||
+ # UPDATE device_registrations
|
||
+ # SET last_seen = CURRENT_TIMESTAMP,
|
||
+ # ip_address = %s,
|
||
+ # user_agent = %s
|
||
+ # WHERE id = %s
|
||
+ # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
+ # conn.commit()
|
||
+ # return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id})
|
||
+ # else:
|
||
# Gerät war deaktiviert, prüfe ob wir es reaktivieren können
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM device_registrations
|
||
- WHERE license_id = %s AND is_active = TRUE
|
||
- """, (license_id,))
|
||
- active_count = cur.fetchone()[0]
|
||
+ # cur.execute("""
|
||
+ # SELECT COUNT(*) FROM device_registrations
|
||
+ # WHERE license_id = %s AND is_active = TRUE
|
||
+ # """, (license_id,))
|
||
+ # active_count = cur.fetchone()[0]
|
||
|
||
- if active_count >= device_limit:
|
||
- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
+ # if active_count >= device_limit:
|
||
+ # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
|
||
# Reaktiviere das Gerät
|
||
- cur.execute("""
|
||
- UPDATE device_registrations
|
||
- SET is_active = TRUE,
|
||
- last_seen = CURRENT_TIMESTAMP,
|
||
- deactivated_at = NULL,
|
||
- deactivated_by = NULL,
|
||
- ip_address = %s,
|
||
- user_agent = %s
|
||
- WHERE id = %s
|
||
- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
- conn.commit()
|
||
- return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id})
|
||
+ # cur.execute("""
|
||
+ # UPDATE device_registrations
|
||
+ # SET is_active = TRUE,
|
||
+ # last_seen = CURRENT_TIMESTAMP,
|
||
+ # deactivated_at = NULL,
|
||
+ # deactivated_by = NULL,
|
||
+ # ip_address = %s,
|
||
+ # user_agent = %s
|
||
+ # WHERE id = %s
|
||
+ # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
+ # conn.commit()
|
||
+ # return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id})
|
||
|
||
# Neues Gerät - prüfe Gerätelimit
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM device_registrations
|
||
- WHERE license_id = %s AND is_active = TRUE
|
||
- """, (license_id,))
|
||
- active_count = cur.fetchone()[0]
|
||
+ # cur.execute("""
|
||
+ # SELECT COUNT(*) FROM device_registrations
|
||
+ # WHERE license_id = %s AND is_active = TRUE
|
||
+ # """, (license_id,))
|
||
+ # active_count = cur.fetchone()[0]
|
||
|
||
- if active_count >= device_limit:
|
||
- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
+ # if active_count >= device_limit:
|
||
+ # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
|
||
# Registriere neues Gerät
|
||
- cur.execute("""
|
||
- INSERT INTO device_registrations
|
||
- (license_id, hardware_id, device_name, operating_system, ip_address, user_agent)
|
||
- VALUES (%s, %s, %s, %s, %s, %s)
|
||
- RETURNING id
|
||
- """, (license_id, hardware_id, device_name, operating_system,
|
||
- get_client_ip(), request.headers.get('User-Agent', '')))
|
||
- device_id = cur.fetchone()[0]
|
||
+ # cur.execute("""
|
||
+ # INSERT INTO device_registrations
|
||
+ # (license_id, hardware_id, device_name, operating_system, ip_address, user_agent)
|
||
+ # VALUES (%s, %s, %s, %s, %s, %s)
|
||
+ # RETURNING id
|
||
+ # """, (license_id, hardware_id, device_name, operating_system,
|
||
+ # get_client_ip(), request.headers.get('User-Agent', '')))
|
||
+ # device_id = cur.fetchone()[0]
|
||
|
||
- conn.commit()
|
||
+ # conn.commit()
|
||
|
||
# Audit Log
|
||
- log_audit('DEVICE_REGISTER', 'device', device_id,
|
||
- new_values={'license_id': license_id, 'hardware_id': hardware_id})
|
||
+ # log_audit('DEVICE_REGISTER', 'device', device_id,
|
||
+ # new_values={'license_id': license_id, 'hardware_id': hardware_id})
|
||
|
||
- cur.close()
|
||
- conn.close()
|
||
+ # cur.close()
|
||
+ # conn.close()
|
||
|
||
- return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id})
|
||
+ # return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id})
|
||
|
||
- except Exception as e:
|
||
- logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}")
|
||
- return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500
|
||
+ # except Exception as e:
|
||
+ # logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}")
|
||
+ # return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500
|
||
|
||
-@app.route("/api/license/<int:license_id>/deactivate-device/<int:device_id>", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/api/license/<int:license_id>/deactivate-device/<int:device_id>", methods=["POST"])
|
||
+# @login_required
|
||
def deactivate_device(license_id, device_id):
|
||
"""Deaktiviere ein registriertes Gerät"""
|
||
try:
|
||
@@ -3423,8 +3437,8 @@ def deactivate_device(license_id, device_id):
|
||
logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}")
|
||
return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500
|
||
|
||
-@app.route("/api/licenses/bulk-delete", methods=["POST"])
|
||
-@login_required
|
||
+# @app.route("/api/licenses/bulk-delete", methods=["POST"])
|
||
+# @login_required
|
||
def bulk_delete_licenses():
|
||
"""Delete multiple licenses at once"""
|
||
try:
|
||
@@ -3468,8 +3482,8 @@ def bulk_delete_licenses():
|
||
|
||
# ===================== RESOURCE POOL MANAGEMENT =====================
|
||
|
||
-@app.route('/resources')
|
||
-@login_required
|
||
+# @app.route('/resources')
|
||
+# @login_required
|
||
def resources():
|
||
"""Resource Pool Hauptübersicht"""
|
||
conn = get_connection()
|
||
@@ -3608,8 +3622,8 @@ def resources():
|
||
datetime=datetime,
|
||
timedelta=timedelta)
|
||
|
||
-@app.route('/resources/add', methods=['GET', 'POST'])
|
||
-@login_required
|
||
+# @app.route('/resources/add', methods=['GET', 'POST'])
|
||
+# @login_required
|
||
def add_resources():
|
||
"""Ressourcen zum Pool hinzufügen"""
|
||
# Hole show_test Parameter für die Anzeige
|
||
@@ -3672,8 +3686,8 @@ def add_resources():
|
||
|
||
return render_template('add_resources.html', show_test=show_test)
|
||
|
||
-@app.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
|
||
-@login_required
|
||
+# @app.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
|
||
+# @login_required
|
||
def quarantine_resource(resource_id):
|
||
"""Ressource in Quarantäne setzen"""
|
||
reason = request.form.get('reason', 'review')
|
||
@@ -3730,8 +3744,8 @@ def quarantine_resource(resource_id):
|
||
status=request.args.get('status', request.form.get('status', '')),
|
||
search=request.args.get('search', request.form.get('search', ''))))
|
||
|
||
-@app.route('/resources/release', methods=['POST'])
|
||
-@login_required
|
||
+# @app.route('/resources/release', methods=['POST'])
|
||
+# @login_required
|
||
def release_resources():
|
||
"""Ressourcen aus Quarantäne freigeben"""
|
||
resource_ids = request.form.getlist('resource_ids')
|
||
@@ -3781,8 +3795,8 @@ def release_resources():
|
||
status=request.args.get('status', request.form.get('status', '')),
|
||
search=request.args.get('search', request.form.get('search', ''))))
|
||
|
||
-@app.route('/api/resources/allocate', methods=['POST'])
|
||
-@login_required
|
||
+# @app.route('/api/resources/allocate', methods=['POST'])
|
||
+# @login_required
|
||
def allocate_resources_api():
|
||
"""API für Ressourcen-Zuweisung bei Lizenzerstellung"""
|
||
data = request.json
|
||
@@ -3929,8 +3943,8 @@ def allocate_resources_api():
|
||
'error': str(e)
|
||
}), 400
|
||
|
||
-@app.route('/api/resources/check-availability', methods=['GET'])
|
||
-@login_required
|
||
+# @app.route('/api/resources/check-availability', methods=['GET'])
|
||
+# @login_required
|
||
def check_resource_availability():
|
||
"""Prüft verfügbare Ressourcen"""
|
||
resource_type = request.args.get('type', '')
|
||
@@ -3988,8 +4002,8 @@ def check_resource_availability():
|
||
|
||
return jsonify(availability)
|
||
|
||
-@app.route('/api/global-search', methods=['GET'])
|
||
-@login_required
|
||
+# @app.route('/api/global-search', methods=['GET'])
|
||
+# @login_required
|
||
def global_search():
|
||
"""Global search API endpoint for searching customers and licenses"""
|
||
query = request.args.get('q', '').strip()
|
||
@@ -4051,8 +4065,8 @@ def global_search():
|
||
'licenses': licenses
|
||
})
|
||
|
||
-@app.route('/resources/history/<int:resource_id>')
|
||
-@login_required
|
||
+# @app.route('/resources/history/<int:resource_id>')
|
||
+# @login_required
|
||
def resource_history(resource_id):
|
||
"""Zeigt die komplette Historie einer Ressource"""
|
||
conn = get_connection()
|
||
@@ -4138,8 +4152,8 @@ def resource_history(resource_id):
|
||
license_info=license_info,
|
||
history=history_objs)
|
||
|
||
-@app.route('/resources/metrics')
|
||
-@login_required
|
||
+# @app.route('/resources/metrics')
|
||
+# @login_required
|
||
def resources_metrics():
|
||
"""Dashboard für Resource Metrics und Reports"""
|
||
conn = get_connection()
|
||
@@ -4302,8 +4316,8 @@ def resources_metrics():
|
||
problem_resources=problem_resources,
|
||
daily_metrics=daily_metrics)
|
||
|
||
-@app.route('/resources/report', methods=['GET'])
|
||
-@login_required
|
||
+# @app.route('/resources/report', methods=['GET'])
|
||
+# @login_required
|
||
def resources_report():
|
||
"""Generiert Ressourcen-Reports oder zeigt Report-Formular"""
|
||
# Prüfe ob Download angefordert wurde
|
||
diff --git a/v2_adminpanel/app.py.backup b/v2_adminpanel/app.py.backup
|
||
index 96c85f1..398d007 100644
|
||
--- a/v2_adminpanel/app.py.backup
|
||
+++ b/v2_adminpanel/app.py.backup
|
||
@@ -1,5032 +1,5032 @@
|
||
-import os
|
||
-import psycopg2
|
||
-from psycopg2.extras import Json
|
||
-from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash
|
||
-from flask_session import Session
|
||
-from functools import wraps
|
||
-from dotenv import load_dotenv
|
||
-import pandas as pd
|
||
-from datetime import datetime, timedelta
|
||
-from zoneinfo import ZoneInfo
|
||
-import io
|
||
-import subprocess
|
||
-import gzip
|
||
-from cryptography.fernet import Fernet
|
||
-from pathlib import Path
|
||
-import time
|
||
-from apscheduler.schedulers.background import BackgroundScheduler
|
||
-import logging
|
||
-import random
|
||
-import hashlib
|
||
-import requests
|
||
-import secrets
|
||
-import string
|
||
-import re
|
||
-import bcrypt
|
||
-import pyotp
|
||
-import qrcode
|
||
-from io import BytesIO
|
||
-import base64
|
||
-import json
|
||
-from werkzeug.middleware.proxy_fix import ProxyFix
|
||
-from openpyxl.utils import get_column_letter
|
||
-
|
||
-load_dotenv()
|
||
-
|
||
-app = Flask(__name__)
|
||
-app.config['SECRET_KEY'] = os.urandom(24)
|
||
-app.config['SESSION_TYPE'] = 'filesystem'
|
||
-app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8
|
||
-app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8'
|
||
-app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=5) # 5 Minuten Session-Timeout
|
||
-app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||
-app.config['SESSION_COOKIE_SECURE'] = False # Wird auf True gesetzt wenn HTTPS (intern läuft HTTP)
|
||
-app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||
-app.config['SESSION_COOKIE_NAME'] = 'admin_session'
|
||
-# WICHTIG: Session-Cookie soll auch nach 5 Minuten ablaufen
|
||
-app.config['SESSION_REFRESH_EACH_REQUEST'] = False
|
||
-Session(app)
|
||
-
|
||
-# ProxyFix für korrekte IP-Adressen hinter Nginx
|
||
-app.wsgi_app = ProxyFix(
|
||
- app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
|
||
-)
|
||
-
|
||
-# Backup-Konfiguration
|
||
-BACKUP_DIR = Path("/app/backups")
|
||
-BACKUP_DIR.mkdir(exist_ok=True)
|
||
-
|
||
-# Rate-Limiting Konfiguration
|
||
-FAIL_MESSAGES = [
|
||
- "NOPE!",
|
||
- "ACCESS DENIED, TRY HARDER",
|
||
- "WRONG! 🚫",
|
||
- "COMPUTER SAYS NO",
|
||
- "YOU FAILED"
|
||
-]
|
||
-
|
||
-MAX_LOGIN_ATTEMPTS = 5
|
||
-BLOCK_DURATION_HOURS = 24
|
||
-CAPTCHA_AFTER_ATTEMPTS = 2
|
||
-
|
||
-# Scheduler für automatische Backups
|
||
-scheduler = BackgroundScheduler()
|
||
-scheduler.start()
|
||
-
|
||
-# Logging konfigurieren
|
||
-logging.basicConfig(level=logging.INFO)
|
||
-
|
||
-
|
||
-# Login decorator
|
||
-def login_required(f):
|
||
- @wraps(f)
|
||
- def decorated_function(*args, **kwargs):
|
||
- if 'logged_in' not in session:
|
||
- return redirect(url_for('login'))
|
||
-
|
||
- # Prüfe ob Session abgelaufen ist
|
||
- if 'last_activity' in session:
|
||
- last_activity = datetime.fromisoformat(session['last_activity'])
|
||
- time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity
|
||
-
|
||
- # Debug-Logging
|
||
- app.logger.info(f"Session check for {session.get('username', 'unknown')}: "
|
||
- f"Last activity: {last_activity}, "
|
||
- f"Time since: {time_since_activity.total_seconds()} seconds")
|
||
-
|
||
- if time_since_activity > timedelta(minutes=5):
|
||
- # Session abgelaufen - Logout
|
||
- username = session.get('username', 'unbekannt')
|
||
- app.logger.info(f"Session timeout for user {username} - auto logout")
|
||
- # Audit-Log für automatischen Logout (vor session.clear()!)
|
||
- try:
|
||
- log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username})
|
||
- except:
|
||
- pass
|
||
- session.clear()
|
||
- flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning')
|
||
- return redirect(url_for('login'))
|
||
-
|
||
- # Aktivität NICHT automatisch aktualisieren
|
||
- # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht)
|
||
- return f(*args, **kwargs)
|
||
- return decorated_function
|
||
-
|
||
-# DB-Verbindung mit UTF-8 Encoding
|
||
-def get_connection():
|
||
- conn = psycopg2.connect(
|
||
- host=os.getenv("POSTGRES_HOST", "postgres"),
|
||
- port=os.getenv("POSTGRES_PORT", "5432"),
|
||
- dbname=os.getenv("POSTGRES_DB"),
|
||
- user=os.getenv("POSTGRES_USER"),
|
||
- password=os.getenv("POSTGRES_PASSWORD"),
|
||
- options='-c client_encoding=UTF8'
|
||
- )
|
||
- conn.set_client_encoding('UTF8')
|
||
- return conn
|
||
-
|
||
-# User Authentication Helper Functions
|
||
-def hash_password(password):
|
||
- """Hash a password using bcrypt"""
|
||
- return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||
-
|
||
-def verify_password(password, hashed):
|
||
- """Verify a password against its hash"""
|
||
- return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
||
-
|
||
-def get_user_by_username(username):
|
||
- """Get user from database by username"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- try:
|
||
- cur.execute("""
|
||
- SELECT id, username, password_hash, email, totp_secret, totp_enabled,
|
||
- backup_codes, last_password_change, failed_2fa_attempts
|
||
- FROM users WHERE username = %s
|
||
- """, (username,))
|
||
- user = cur.fetchone()
|
||
- if user:
|
||
- return {
|
||
- 'id': user[0],
|
||
- 'username': user[1],
|
||
- 'password_hash': user[2],
|
||
- 'email': user[3],
|
||
- 'totp_secret': user[4],
|
||
- 'totp_enabled': user[5],
|
||
- 'backup_codes': user[6],
|
||
- 'last_password_change': user[7],
|
||
- 'failed_2fa_attempts': user[8]
|
||
- }
|
||
- return None
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
-def generate_totp_secret():
|
||
- """Generate a new TOTP secret"""
|
||
- return pyotp.random_base32()
|
||
-
|
||
-def generate_qr_code(username, totp_secret):
|
||
- """Generate QR code for TOTP setup"""
|
||
- totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
|
||
- name=username,
|
||
- issuer_name='V2 Admin Panel'
|
||
- )
|
||
-
|
||
- qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||
- qr.add_data(totp_uri)
|
||
- qr.make(fit=True)
|
||
-
|
||
- img = qr.make_image(fill_color="black", back_color="white")
|
||
- buf = BytesIO()
|
||
- img.save(buf, format='PNG')
|
||
- buf.seek(0)
|
||
-
|
||
- return base64.b64encode(buf.getvalue()).decode()
|
||
-
|
||
-def verify_totp(totp_secret, token):
|
||
- """Verify a TOTP token"""
|
||
- totp = pyotp.TOTP(totp_secret)
|
||
- return totp.verify(token, valid_window=1)
|
||
-
|
||
-def generate_backup_codes(count=8):
|
||
- """Generate backup codes for 2FA recovery"""
|
||
- codes = []
|
||
- for _ in range(count):
|
||
- code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||
- codes.append(code)
|
||
- return codes
|
||
-
|
||
-def hash_backup_code(code):
|
||
- """Hash a backup code for storage"""
|
||
- return hashlib.sha256(code.encode()).hexdigest()
|
||
-
|
||
-def verify_backup_code(code, hashed_codes):
|
||
- """Verify a backup code against stored hashes"""
|
||
- code_hash = hashlib.sha256(code.encode()).hexdigest()
|
||
- return code_hash in hashed_codes
|
||
-
|
||
-# Audit-Log-Funktion
|
||
-def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None):
|
||
- """Protokolliert Änderungen im Audit-Log"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- username = session.get('username', 'system')
|
||
- ip_address = get_client_ip() if request else None
|
||
- user_agent = request.headers.get('User-Agent') if request else None
|
||
-
|
||
- # Debug logging
|
||
- app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}")
|
||
-
|
||
- # Konvertiere Dictionaries zu JSONB
|
||
- old_json = Json(old_values) if old_values else None
|
||
- new_json = Json(new_values) if new_values else None
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO audit_log
|
||
- (username, action, entity_type, entity_id, old_values, new_values,
|
||
- ip_address, user_agent, additional_info)
|
||
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
- """, (username, action, entity_type, entity_id, old_json, new_json,
|
||
- ip_address, user_agent, additional_info))
|
||
-
|
||
- conn.commit()
|
||
- except Exception as e:
|
||
- print(f"Audit log error: {e}")
|
||
- conn.rollback()
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
-# Verschlüsselungs-Funktionen
|
||
-def get_or_create_encryption_key():
|
||
- """Holt oder erstellt einen Verschlüsselungsschlüssel"""
|
||
- key_file = BACKUP_DIR / ".backup_key"
|
||
-
|
||
- # Versuche Key aus Umgebungsvariable zu lesen
|
||
- env_key = os.getenv("BACKUP_ENCRYPTION_KEY")
|
||
- if env_key:
|
||
- try:
|
||
- # Validiere den Key
|
||
- Fernet(env_key.encode())
|
||
- return env_key.encode()
|
||
- except:
|
||
- pass
|
||
-
|
||
- # Wenn kein gültiger Key in ENV, prüfe Datei
|
||
- if key_file.exists():
|
||
- return key_file.read_bytes()
|
||
-
|
||
- # Erstelle neuen Key
|
||
- key = Fernet.generate_key()
|
||
- key_file.write_bytes(key)
|
||
- logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt")
|
||
- return key
|
||
-
|
||
-# Backup-Funktionen
|
||
-def create_backup(backup_type="manual", created_by=None):
|
||
- """Erstellt ein verschlüsseltes Backup der Datenbank"""
|
||
- start_time = time.time()
|
||
- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")
|
||
- filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc"
|
||
- filepath = BACKUP_DIR / filename
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Backup-Eintrag erstellen
|
||
- cur.execute("""
|
||
- INSERT INTO backup_history
|
||
- (filename, filepath, backup_type, status, created_by, is_encrypted)
|
||
- VALUES (%s, %s, %s, %s, %s, %s)
|
||
- RETURNING id
|
||
- """, (filename, str(filepath), backup_type, 'in_progress',
|
||
- created_by or 'system', True))
|
||
- backup_id = cur.fetchone()[0]
|
||
- conn.commit()
|
||
-
|
||
- try:
|
||
- # PostgreSQL Dump erstellen
|
||
- dump_command = [
|
||
- 'pg_dump',
|
||
- '-h', os.getenv("POSTGRES_HOST", "postgres"),
|
||
- '-p', os.getenv("POSTGRES_PORT", "5432"),
|
||
- '-U', os.getenv("POSTGRES_USER"),
|
||
- '-d', os.getenv("POSTGRES_DB"),
|
||
- '--no-password',
|
||
- '--verbose'
|
||
- ]
|
||
-
|
||
- # PGPASSWORD setzen
|
||
- env = os.environ.copy()
|
||
- env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
|
||
-
|
||
- # Dump ausführen
|
||
- result = subprocess.run(dump_command, capture_output=True, text=True, env=env)
|
||
-
|
||
- if result.returncode != 0:
|
||
- raise Exception(f"pg_dump failed: {result.stderr}")
|
||
-
|
||
- dump_data = result.stdout.encode('utf-8')
|
||
-
|
||
- # Komprimieren
|
||
- compressed_data = gzip.compress(dump_data)
|
||
-
|
||
- # Verschlüsseln
|
||
- key = get_or_create_encryption_key()
|
||
- f = Fernet(key)
|
||
- encrypted_data = f.encrypt(compressed_data)
|
||
-
|
||
- # Speichern
|
||
- filepath.write_bytes(encrypted_data)
|
||
-
|
||
- # Statistiken sammeln
|
||
- cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'")
|
||
- tables_count = cur.fetchone()[0]
|
||
-
|
||
- cur.execute("""
|
||
- SELECT SUM(n_live_tup)
|
||
- FROM pg_stat_user_tables
|
||
- """)
|
||
- records_count = cur.fetchone()[0] or 0
|
||
-
|
||
- duration = time.time() - start_time
|
||
- filesize = filepath.stat().st_size
|
||
-
|
||
- # Backup-Eintrag aktualisieren
|
||
- cur.execute("""
|
||
- UPDATE backup_history
|
||
- SET status = %s, filesize = %s, tables_count = %s,
|
||
- records_count = %s, duration_seconds = %s
|
||
- WHERE id = %s
|
||
- """, ('success', filesize, tables_count, records_count, duration, backup_id))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('BACKUP', 'database', backup_id,
|
||
- additional_info=f"Backup erstellt: {filename} ({filesize} bytes)")
|
||
-
|
||
- # E-Mail-Benachrichtigung (wenn konfiguriert)
|
||
- send_backup_notification(True, filename, filesize, duration)
|
||
-
|
||
- logging.info(f"Backup erfolgreich erstellt: {filename}")
|
||
- return True, filename
|
||
-
|
||
- except Exception as e:
|
||
- # Fehler protokollieren
|
||
- cur.execute("""
|
||
- UPDATE backup_history
|
||
- SET status = %s, error_message = %s, duration_seconds = %s
|
||
- WHERE id = %s
|
||
- """, ('failed', str(e), time.time() - start_time, backup_id))
|
||
- conn.commit()
|
||
-
|
||
- logging.error(f"Backup fehlgeschlagen: {e}")
|
||
- send_backup_notification(False, filename, error=str(e))
|
||
-
|
||
- return False, str(e)
|
||
-
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
-def restore_backup(backup_id, encryption_key=None):
|
||
- """Stellt ein Backup wieder her"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Backup-Info abrufen
|
||
- cur.execute("""
|
||
- SELECT filename, filepath, is_encrypted
|
||
- FROM backup_history
|
||
- WHERE id = %s
|
||
- """, (backup_id,))
|
||
- backup_info = cur.fetchone()
|
||
-
|
||
- if not backup_info:
|
||
- raise Exception("Backup nicht gefunden")
|
||
-
|
||
- filename, filepath, is_encrypted = backup_info
|
||
- filepath = Path(filepath)
|
||
-
|
||
- if not filepath.exists():
|
||
- raise Exception("Backup-Datei nicht gefunden")
|
||
-
|
||
- # Datei lesen
|
||
- encrypted_data = filepath.read_bytes()
|
||
-
|
||
- # Entschlüsseln
|
||
- if is_encrypted:
|
||
- key = encryption_key.encode() if encryption_key else get_or_create_encryption_key()
|
||
- try:
|
||
- f = Fernet(key)
|
||
- compressed_data = f.decrypt(encrypted_data)
|
||
- except:
|
||
- raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?")
|
||
- else:
|
||
- compressed_data = encrypted_data
|
||
-
|
||
- # Dekomprimieren
|
||
- dump_data = gzip.decompress(compressed_data)
|
||
- sql_commands = dump_data.decode('utf-8')
|
||
-
|
||
- # Bestehende Verbindungen schließen
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Datenbank wiederherstellen
|
||
- restore_command = [
|
||
- 'psql',
|
||
- '-h', os.getenv("POSTGRES_HOST", "postgres"),
|
||
- '-p', os.getenv("POSTGRES_PORT", "5432"),
|
||
- '-U', os.getenv("POSTGRES_USER"),
|
||
- '-d', os.getenv("POSTGRES_DB"),
|
||
- '--no-password'
|
||
- ]
|
||
-
|
||
- env = os.environ.copy()
|
||
- env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
|
||
-
|
||
- result = subprocess.run(restore_command, input=sql_commands,
|
||
- capture_output=True, text=True, env=env)
|
||
-
|
||
- if result.returncode != 0:
|
||
- raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}")
|
||
-
|
||
- # Audit-Log (neue Verbindung)
|
||
- log_audit('RESTORE', 'database', backup_id,
|
||
- additional_info=f"Backup wiederhergestellt: {filename}")
|
||
-
|
||
- return True, "Backup erfolgreich wiederhergestellt"
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Wiederherstellung fehlgeschlagen: {e}")
|
||
- return False, str(e)
|
||
-
|
||
-def send_backup_notification(success, filename, filesize=None, duration=None, error=None):
|
||
- """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)"""
|
||
- if not os.getenv("EMAIL_ENABLED", "false").lower() == "true":
|
||
- return
|
||
-
|
||
- # E-Mail-Funktion vorbereitet aber deaktiviert
|
||
- # TODO: Implementieren wenn E-Mail-Server konfiguriert ist
|
||
- logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}")
|
||
-
|
||
-# Scheduled Backup Job
|
||
-def scheduled_backup():
|
||
- """Führt ein geplantes Backup aus"""
|
||
- logging.info("Starte geplantes Backup...")
|
||
- create_backup(backup_type="scheduled", created_by="scheduler")
|
||
-
|
||
-# Scheduler konfigurieren - täglich um 3:00 Uhr
|
||
-scheduler.add_job(
|
||
- scheduled_backup,
|
||
- 'cron',
|
||
- hour=3,
|
||
- minute=0,
|
||
- id='daily_backup',
|
||
- replace_existing=True
|
||
-)
|
||
-
|
||
-# Rate-Limiting Funktionen
|
||
-def get_client_ip():
|
||
- """Ermittelt die echte IP-Adresse des Clients"""
|
||
- # Debug logging
|
||
- app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}")
|
||
-
|
||
- # Try X-Real-IP first (set by nginx)
|
||
- if request.headers.get('X-Real-IP'):
|
||
- return request.headers.get('X-Real-IP')
|
||
- # Then X-Forwarded-For
|
||
- elif request.headers.get('X-Forwarded-For'):
|
||
- # X-Forwarded-For can contain multiple IPs, take the first one
|
||
- return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||
- # Fallback to remote_addr
|
||
- else:
|
||
- return request.remote_addr
|
||
-
|
||
-def check_ip_blocked(ip_address):
|
||
- """Prüft ob eine IP-Adresse gesperrt ist"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- cur.execute("""
|
||
- SELECT blocked_until FROM login_attempts
|
||
- WHERE ip_address = %s AND blocked_until IS NOT NULL
|
||
- """, (ip_address,))
|
||
-
|
||
- result = cur.fetchone()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- if result and result[0]:
|
||
- if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None):
|
||
- return True, result[0]
|
||
- return False, None
|
||
-
|
||
-def record_failed_attempt(ip_address, username):
|
||
- """Zeichnet einen fehlgeschlagenen Login-Versuch auf"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Random Fehlermeldung
|
||
- error_message = random.choice(FAIL_MESSAGES)
|
||
-
|
||
- try:
|
||
- # Prüfen ob IP bereits existiert
|
||
- cur.execute("""
|
||
- SELECT attempt_count FROM login_attempts
|
||
- WHERE ip_address = %s
|
||
- """, (ip_address,))
|
||
-
|
||
- result = cur.fetchone()
|
||
-
|
||
- if result:
|
||
- # Update bestehenden Eintrag
|
||
- new_count = result[0] + 1
|
||
- blocked_until = None
|
||
-
|
||
- if new_count >= MAX_LOGIN_ATTEMPTS:
|
||
- blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS)
|
||
- # E-Mail-Benachrichtigung (wenn aktiviert)
|
||
- if os.getenv("EMAIL_ENABLED", "false").lower() == "true":
|
||
- send_security_alert_email(ip_address, username, new_count)
|
||
-
|
||
- cur.execute("""
|
||
- UPDATE login_attempts
|
||
- SET attempt_count = %s,
|
||
- last_attempt = CURRENT_TIMESTAMP,
|
||
- blocked_until = %s,
|
||
- last_username_tried = %s,
|
||
- last_error_message = %s
|
||
- WHERE ip_address = %s
|
||
- """, (new_count, blocked_until, username, error_message, ip_address))
|
||
- else:
|
||
- # Neuen Eintrag erstellen
|
||
- cur.execute("""
|
||
- INSERT INTO login_attempts
|
||
- (ip_address, attempt_count, last_username_tried, last_error_message)
|
||
- VALUES (%s, 1, %s, %s)
|
||
- """, (ip_address, username, error_message))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('LOGIN_FAILED', 'user',
|
||
- additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}")
|
||
-
|
||
- except Exception as e:
|
||
- print(f"Rate limiting error: {e}")
|
||
- conn.rollback()
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return error_message
|
||
-
|
||
-def reset_login_attempts(ip_address):
|
||
- """Setzt die Login-Versuche für eine IP zurück"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- cur.execute("""
|
||
- DELETE FROM login_attempts
|
||
- WHERE ip_address = %s
|
||
- """, (ip_address,))
|
||
- conn.commit()
|
||
- except Exception as e:
|
||
- print(f"Reset attempts error: {e}")
|
||
- conn.rollback()
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
-def get_login_attempts(ip_address):
|
||
- """Gibt die Anzahl der Login-Versuche für eine IP zurück"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- cur.execute("""
|
||
- SELECT attempt_count FROM login_attempts
|
||
- WHERE ip_address = %s
|
||
- """, (ip_address,))
|
||
-
|
||
- result = cur.fetchone()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return result[0] if result else 0
|
||
-
|
||
-def send_security_alert_email(ip_address, username, attempt_count):
|
||
- """Sendet eine Sicherheitswarnung per E-Mail"""
|
||
- subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche"
|
||
- body = f"""
|
||
- WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt!
|
||
-
|
||
- IP-Adresse: {ip_address}
|
||
- Versuchter Benutzername: {username}
|
||
- Anzahl Versuche: {attempt_count}
|
||
- Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')}
|
||
-
|
||
- Die IP-Adresse wurde für 24 Stunden gesperrt.
|
||
-
|
||
- Dies ist eine automatische Nachricht vom v2-Docker Admin Panel.
|
||
- """
|
||
-
|
||
- # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert
|
||
- logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}")
|
||
- print(f"E-Mail würde gesendet: {subject}")
|
||
-
|
||
-def verify_recaptcha(response):
|
||
- """Verifiziert die reCAPTCHA v2 Response mit Google"""
|
||
- secret_key = os.getenv('RECAPTCHA_SECRET_KEY')
|
||
-
|
||
- # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC)
|
||
- if not secret_key:
|
||
- logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen")
|
||
- return True
|
||
-
|
||
- # Verifizierung bei Google
|
||
- try:
|
||
- verify_url = 'https://www.google.com/recaptcha/api/siteverify'
|
||
- data = {
|
||
- 'secret': secret_key,
|
||
- 'response': response
|
||
- }
|
||
-
|
||
- # Timeout für Request setzen
|
||
- r = requests.post(verify_url, data=data, timeout=5)
|
||
- result = r.json()
|
||
-
|
||
- # Log für Debugging
|
||
- if not result.get('success'):
|
||
- logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}")
|
||
-
|
||
- return result.get('success', False)
|
||
-
|
||
- except requests.exceptions.RequestException as e:
|
||
- logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}")
|
||
- # Bei Netzwerkfehlern CAPTCHA als bestanden werten
|
||
- return True
|
||
- except Exception as e:
|
||
- logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}")
|
||
- return False
|
||
-
|
||
-def generate_license_key(license_type='full'):
|
||
- """
|
||
- Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ
|
||
-
|
||
- AF = Account Factory (Produktkennung)
|
||
- F/T = F für Fullversion, T für Testversion
|
||
- YYYY = Jahr
|
||
- MM = Monat
|
||
- XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen
|
||
- """
|
||
- # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l)
|
||
- chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||
-
|
||
- # Datum-Teil
|
||
- now = datetime.now(ZoneInfo("Europe/Berlin"))
|
||
- date_part = now.strftime('%Y%m')
|
||
- type_char = 'F' if license_type == 'full' else 'T'
|
||
-
|
||
- # Zufällige Teile generieren (3 Blöcke à 4 Zeichen)
|
||
- parts = []
|
||
- for _ in range(3):
|
||
- part = ''.join(secrets.choice(chars) for _ in range(4))
|
||
- parts.append(part)
|
||
-
|
||
- # Key zusammensetzen
|
||
- key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}"
|
||
-
|
||
- return key
|
||
-
|
||
-def validate_license_key(key):
|
||
- """
|
||
- Validiert das License Key Format
|
||
- Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ
|
||
- """
|
||
- if not key:
|
||
- return False
|
||
-
|
||
- # Pattern für das neue Format
|
||
- # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen
|
||
- pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||
-
|
||
- # Großbuchstaben für Vergleich
|
||
- return bool(re.match(pattern, key.upper()))
|
||
-
|
||
-@app.route("/login", methods=["GET", "POST"])
|
||
-def login():
|
||
- # Timing-Attack Schutz - Start Zeit merken
|
||
- start_time = time.time()
|
||
-
|
||
- # IP-Adresse ermitteln
|
||
- ip_address = get_client_ip()
|
||
-
|
||
- # Prüfen ob IP gesperrt ist
|
||
- is_blocked, blocked_until = check_ip_blocked(ip_address)
|
||
- if is_blocked:
|
||
- time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600
|
||
- error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten."
|
||
- return render_template("login.html", error=error_msg, error_type="blocked")
|
||
-
|
||
- # Anzahl bisheriger Versuche
|
||
- attempt_count = get_login_attempts(ip_address)
|
||
-
|
||
- if request.method == "POST":
|
||
- username = request.form.get("username")
|
||
- password = request.form.get("password")
|
||
- captcha_response = request.form.get("g-recaptcha-response")
|
||
-
|
||
- # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind
|
||
- recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY')
|
||
- if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key:
|
||
- if not captcha_response:
|
||
- # Timing-Attack Schutz
|
||
- elapsed = time.time() - start_time
|
||
- if elapsed < 1.0:
|
||
- time.sleep(1.0 - elapsed)
|
||
- return render_template("login.html",
|
||
- error="CAPTCHA ERFORDERLICH!",
|
||
- show_captcha=True,
|
||
- error_type="captcha",
|
||
- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
- recaptcha_site_key=recaptcha_site_key)
|
||
-
|
||
- # CAPTCHA validieren
|
||
- if not verify_recaptcha(captcha_response):
|
||
- # Timing-Attack Schutz
|
||
- elapsed = time.time() - start_time
|
||
- if elapsed < 1.0:
|
||
- time.sleep(1.0 - elapsed)
|
||
- return render_template("login.html",
|
||
- error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.",
|
||
- show_captcha=True,
|
||
- error_type="captcha",
|
||
- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
- recaptcha_site_key=recaptcha_site_key)
|
||
-
|
||
- # Check user in database first, fallback to env vars
|
||
- user = get_user_by_username(username)
|
||
- login_success = False
|
||
- needs_2fa = False
|
||
-
|
||
- if user:
|
||
- # Database user authentication
|
||
- if verify_password(password, user['password_hash']):
|
||
- login_success = True
|
||
- needs_2fa = user['totp_enabled']
|
||
- else:
|
||
- # Fallback to environment variables for backward compatibility
|
||
- admin1_user = os.getenv("ADMIN1_USERNAME")
|
||
- admin1_pass = os.getenv("ADMIN1_PASSWORD")
|
||
- admin2_user = os.getenv("ADMIN2_USERNAME")
|
||
- admin2_pass = os.getenv("ADMIN2_PASSWORD")
|
||
-
|
||
- if ((username == admin1_user and password == admin1_pass) or
|
||
- (username == admin2_user and password == admin2_pass)):
|
||
- login_success = True
|
||
-
|
||
- # Timing-Attack Schutz - Mindestens 1 Sekunde warten
|
||
- elapsed = time.time() - start_time
|
||
- if elapsed < 1.0:
|
||
- time.sleep(1.0 - elapsed)
|
||
-
|
||
- if login_success:
|
||
- # Erfolgreicher Login
|
||
- if needs_2fa:
|
||
- # Store temporary session for 2FA verification
|
||
- session['temp_username'] = username
|
||
- session['temp_user_id'] = user['id']
|
||
- session['awaiting_2fa'] = True
|
||
- return redirect(url_for('verify_2fa'))
|
||
- else:
|
||
- # Complete login without 2FA
|
||
- session.permanent = True # Aktiviert das Timeout
|
||
- session['logged_in'] = True
|
||
- session['username'] = username
|
||
- session['user_id'] = user['id'] if user else None
|
||
- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- reset_login_attempts(ip_address)
|
||
- log_audit('LOGIN_SUCCESS', 'user',
|
||
- additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}")
|
||
- return redirect(url_for('dashboard'))
|
||
- else:
|
||
- # Fehlgeschlagener Login
|
||
- error_message = record_failed_attempt(ip_address, username)
|
||
- new_attempt_count = get_login_attempts(ip_address)
|
||
-
|
||
- # Prüfen ob jetzt gesperrt
|
||
- is_now_blocked, _ = check_ip_blocked(ip_address)
|
||
- if is_now_blocked:
|
||
- log_audit('LOGIN_BLOCKED', 'security',
|
||
- additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt")
|
||
-
|
||
- return render_template("login.html",
|
||
- error=error_message,
|
||
- show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')),
|
||
- error_type="failed",
|
||
- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count),
|
||
- recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY'))
|
||
-
|
||
- # GET Request
|
||
- return render_template("login.html",
|
||
- show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')),
|
||
- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
- recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY'))
|
||
-
|
||
-@app.route("/logout")
|
||
-def logout():
|
||
- username = session.get('username', 'unknown')
|
||
- log_audit('LOGOUT', 'user', additional_info=f"Abmeldung")
|
||
- session.pop('logged_in', None)
|
||
- session.pop('username', None)
|
||
- session.pop('user_id', None)
|
||
- session.pop('temp_username', None)
|
||
- session.pop('temp_user_id', None)
|
||
- session.pop('awaiting_2fa', None)
|
||
- return redirect(url_for('login'))
|
||
-
|
||
-@app.route("/verify-2fa", methods=["GET", "POST"])
|
||
-def verify_2fa():
|
||
- if not session.get('awaiting_2fa'):
|
||
- return redirect(url_for('login'))
|
||
-
|
||
- if request.method == "POST":
|
||
- token = request.form.get('token', '').replace(' ', '')
|
||
- username = session.get('temp_username')
|
||
- user_id = session.get('temp_user_id')
|
||
-
|
||
- if not username or not user_id:
|
||
- flash('Session expired. Please login again.', 'error')
|
||
- return redirect(url_for('login'))
|
||
-
|
||
- user = get_user_by_username(username)
|
||
- if not user:
|
||
- flash('User not found.', 'error')
|
||
- return redirect(url_for('login'))
|
||
-
|
||
- # Check if it's a backup code
|
||
- if len(token) == 8 and token.isupper():
|
||
- # Try backup code
|
||
- backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else []
|
||
- if verify_backup_code(token, backup_codes):
|
||
- # Remove used backup code
|
||
- code_hash = hash_backup_code(token)
|
||
- backup_codes.remove(code_hash)
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s",
|
||
- (json.dumps(backup_codes), user_id))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Complete login
|
||
- session.permanent = True
|
||
- session['logged_in'] = True
|
||
- session['username'] = username
|
||
- session['user_id'] = user_id
|
||
- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- session.pop('temp_username', None)
|
||
- session.pop('temp_user_id', None)
|
||
- session.pop('awaiting_2fa', None)
|
||
-
|
||
- flash('Login successful using backup code. Please generate new backup codes.', 'warning')
|
||
- log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code")
|
||
- return redirect(url_for('dashboard'))
|
||
- else:
|
||
- # Try TOTP token
|
||
- if verify_totp(user['totp_secret'], token):
|
||
- # Complete login
|
||
- session.permanent = True
|
||
- session['logged_in'] = True
|
||
- session['username'] = username
|
||
- session['user_id'] = user_id
|
||
- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- session.pop('temp_username', None)
|
||
- session.pop('temp_user_id', None)
|
||
- session.pop('awaiting_2fa', None)
|
||
-
|
||
- log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful")
|
||
- return redirect(url_for('dashboard'))
|
||
-
|
||
- # Failed verification
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s",
|
||
- (datetime.now(), user_id))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- flash('Invalid authentication code. Please try again.', 'error')
|
||
- log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt")
|
||
-
|
||
- return render_template('verify_2fa.html')
|
||
-
|
||
-@app.route("/profile")
|
||
-@login_required
|
||
-def profile():
|
||
- user = get_user_by_username(session['username'])
|
||
- if not user:
|
||
- # For environment-based users, redirect with message
|
||
- flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info')
|
||
- return redirect(url_for('dashboard'))
|
||
- return render_template('profile.html', user=user)
|
||
-
|
||
-@app.route("/profile/change-password", methods=["POST"])
|
||
-@login_required
|
||
-def change_password():
|
||
- current_password = request.form.get('current_password')
|
||
- new_password = request.form.get('new_password')
|
||
- confirm_password = request.form.get('confirm_password')
|
||
-
|
||
- user = get_user_by_username(session['username'])
|
||
-
|
||
- # Verify current password
|
||
- if not verify_password(current_password, user['password_hash']):
|
||
- flash('Current password is incorrect.', 'error')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
- # Check new password
|
||
- if new_password != confirm_password:
|
||
- flash('New passwords do not match.', 'error')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
- if len(new_password) < 8:
|
||
- flash('Password must be at least 8 characters long.', 'error')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
- # Update password
|
||
- new_hash = hash_password(new_password)
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s",
|
||
- (new_hash, datetime.now(), user['id']))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'],
|
||
- additional_info="Password changed successfully")
|
||
- flash('Password changed successfully.', 'success')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
-@app.route("/profile/setup-2fa")
|
||
-@login_required
|
||
-def setup_2fa():
|
||
- user = get_user_by_username(session['username'])
|
||
-
|
||
- if user['totp_enabled']:
|
||
- flash('2FA is already enabled for your account.', 'info')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
- # Generate new TOTP secret
|
||
- totp_secret = generate_totp_secret()
|
||
- session['temp_totp_secret'] = totp_secret
|
||
-
|
||
- # Generate QR code
|
||
- qr_code = generate_qr_code(user['username'], totp_secret)
|
||
-
|
||
- return render_template('setup_2fa.html',
|
||
- totp_secret=totp_secret,
|
||
- qr_code=qr_code)
|
||
-
|
||
-@app.route("/profile/enable-2fa", methods=["POST"])
|
||
-@login_required
|
||
-def enable_2fa():
|
||
- token = request.form.get('token', '').replace(' ', '')
|
||
- totp_secret = session.get('temp_totp_secret')
|
||
-
|
||
- if not totp_secret:
|
||
- flash('2FA setup session expired. Please try again.', 'error')
|
||
- return redirect(url_for('setup_2fa'))
|
||
-
|
||
- # Verify the token
|
||
- if not verify_totp(totp_secret, token):
|
||
- flash('Invalid authentication code. Please try again.', 'error')
|
||
- return redirect(url_for('setup_2fa'))
|
||
-
|
||
- # Generate backup codes
|
||
- backup_codes = generate_backup_codes()
|
||
- hashed_codes = [hash_backup_code(code) for code in backup_codes]
|
||
-
|
||
- # Enable 2FA
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("""
|
||
- UPDATE users
|
||
- SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s
|
||
- WHERE username = %s
|
||
- """, (totp_secret, json.dumps(hashed_codes), session['username']))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- session.pop('temp_totp_secret', None)
|
||
-
|
||
- log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully")
|
||
-
|
||
- # Show backup codes
|
||
- return render_template('backup_codes.html', backup_codes=backup_codes)
|
||
-
|
||
-@app.route("/profile/disable-2fa", methods=["POST"])
|
||
-@login_required
|
||
-def disable_2fa():
|
||
- password = request.form.get('password')
|
||
- user = get_user_by_username(session['username'])
|
||
-
|
||
- # Verify password
|
||
- if not verify_password(password, user['password_hash']):
|
||
- flash('Incorrect password.', 'error')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
- # Disable 2FA
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("""
|
||
- UPDATE users
|
||
- SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL
|
||
- WHERE username = %s
|
||
- """, (session['username'],))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled")
|
||
- flash('2FA has been disabled for your account.', 'success')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
-@app.route("/heartbeat", methods=['POST'])
|
||
-@login_required
|
||
-def heartbeat():
|
||
- """Endpoint für Session Keep-Alive - aktualisiert last_activity"""
|
||
- # Aktualisiere last_activity nur wenn explizit angefordert
|
||
- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- # Force session save
|
||
- session.modified = True
|
||
-
|
||
- return jsonify({
|
||
- 'status': 'ok',
|
||
- 'last_activity': session['last_activity'],
|
||
- 'username': session.get('username')
|
||
- })
|
||
-
|
||
-@app.route("/api/generate-license-key", methods=['POST'])
|
||
-@login_required
|
||
-def api_generate_key():
|
||
- """API Endpoint zur Generierung eines neuen Lizenzschlüssels"""
|
||
- try:
|
||
- # Lizenztyp aus Request holen (default: full)
|
||
- data = request.get_json() or {}
|
||
- license_type = data.get('type', 'full')
|
||
-
|
||
- # Key generieren
|
||
- key = generate_license_key(license_type)
|
||
-
|
||
- # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher)
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Wiederhole bis eindeutiger Key gefunden
|
||
- attempts = 0
|
||
- while attempts < 10: # Max 10 Versuche
|
||
- cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,))
|
||
- if not cur.fetchone():
|
||
- break # Key ist eindeutig
|
||
- key = generate_license_key(license_type)
|
||
- attempts += 1
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Log für Audit
|
||
- log_audit('GENERATE_KEY', 'license',
|
||
- additional_info={'type': license_type, 'key': key})
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'key': key,
|
||
- 'type': license_type
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Fehler bei Key-Generierung: {str(e)}")
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'error': 'Fehler bei der Key-Generierung'
|
||
- }), 500
|
||
-
|
||
-@app.route("/api/customers", methods=['GET'])
|
||
-@login_required
|
||
-def api_customers():
|
||
- """API Endpoint für die Kundensuche mit Select2"""
|
||
- try:
|
||
- # Suchparameter
|
||
- search = request.args.get('q', '').strip()
|
||
- page = request.args.get('page', 1, type=int)
|
||
- per_page = 20
|
||
- customer_id = request.args.get('id', type=int)
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Einzelnen Kunden per ID abrufen
|
||
- if customer_id:
|
||
- cur.execute("""
|
||
- SELECT c.id, c.name, c.email,
|
||
- COUNT(l.id) as license_count
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- WHERE c.id = %s
|
||
- GROUP BY c.id, c.name, c.email
|
||
- """, (customer_id,))
|
||
-
|
||
- customer = cur.fetchone()
|
||
- results = []
|
||
- if customer:
|
||
- results.append({
|
||
- 'id': customer[0],
|
||
- 'text': f"{customer[1]} ({customer[2]})",
|
||
- 'name': customer[1],
|
||
- 'email': customer[2],
|
||
- 'license_count': customer[3]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'results': results,
|
||
- 'pagination': {'more': False}
|
||
- })
|
||
-
|
||
- # SQL Query mit optionaler Suche
|
||
- elif search:
|
||
- cur.execute("""
|
||
- SELECT c.id, c.name, c.email,
|
||
- COUNT(l.id) as license_count
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- WHERE LOWER(c.name) LIKE LOWER(%s)
|
||
- OR LOWER(c.email) LIKE LOWER(%s)
|
||
- GROUP BY c.id, c.name, c.email
|
||
- ORDER BY c.name
|
||
- LIMIT %s OFFSET %s
|
||
- """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page))
|
||
- else:
|
||
- cur.execute("""
|
||
- SELECT c.id, c.name, c.email,
|
||
- COUNT(l.id) as license_count
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- GROUP BY c.id, c.name, c.email
|
||
- ORDER BY c.name
|
||
- LIMIT %s OFFSET %s
|
||
- """, (per_page, (page - 1) * per_page))
|
||
-
|
||
- customers = cur.fetchall()
|
||
-
|
||
- # Format für Select2
|
||
- results = []
|
||
- for customer in customers:
|
||
- results.append({
|
||
- 'id': customer[0],
|
||
- 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)",
|
||
- 'name': customer[1],
|
||
- 'email': customer[2],
|
||
- 'license_count': customer[3]
|
||
- })
|
||
-
|
||
- # Gesamtanzahl für Pagination
|
||
- if search:
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM customers
|
||
- WHERE LOWER(name) LIKE LOWER(%s)
|
||
- OR LOWER(email) LIKE LOWER(%s)
|
||
- """, (f'%{search}%', f'%{search}%'))
|
||
- else:
|
||
- cur.execute("SELECT COUNT(*) FROM customers")
|
||
-
|
||
- total_count = cur.fetchone()[0]
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Select2 Response Format
|
||
- return jsonify({
|
||
- 'results': results,
|
||
- 'pagination': {
|
||
- 'more': (page * per_page) < total_count
|
||
- }
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Fehler bei Kundensuche: {str(e)}")
|
||
- return jsonify({
|
||
- 'results': [],
|
||
- 'error': 'Fehler bei der Kundensuche'
|
||
- }), 500
|
||
-
|
||
-@app.route("/")
|
||
-@login_required
|
||
-def dashboard():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Statistiken abrufen
|
||
- # Gesamtanzahl Kunden (ohne Testdaten)
|
||
- cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE")
|
||
- total_customers = cur.fetchone()[0]
|
||
-
|
||
- # Gesamtanzahl Lizenzen (ohne Testdaten)
|
||
- cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE")
|
||
- total_licenses = cur.fetchone()[0]
|
||
-
|
||
- # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM licenses
|
||
- WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE
|
||
- """)
|
||
- active_licenses = cur.fetchone()[0]
|
||
-
|
||
- # Aktive Sessions
|
||
- cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE")
|
||
- active_sessions_count = cur.fetchone()[0]
|
||
-
|
||
- # Abgelaufene Lizenzen (ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM licenses
|
||
- WHERE valid_until < CURRENT_DATE AND is_test = FALSE
|
||
- """)
|
||
- expired_licenses = cur.fetchone()[0]
|
||
-
|
||
- # Deaktivierte Lizenzen (ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM licenses
|
||
- WHERE is_active = FALSE AND is_test = FALSE
|
||
- """)
|
||
- inactive_licenses = cur.fetchone()[0]
|
||
-
|
||
- # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM licenses
|
||
- WHERE valid_until >= CURRENT_DATE
|
||
- AND valid_until < CURRENT_DATE + INTERVAL '30 days'
|
||
- AND is_active = TRUE
|
||
- AND is_test = FALSE
|
||
- """)
|
||
- expiring_soon = cur.fetchone()[0]
|
||
-
|
||
- # Testlizenzen vs Vollversionen (ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT license_type, COUNT(*)
|
||
- FROM licenses
|
||
- WHERE is_test = FALSE
|
||
- GROUP BY license_type
|
||
- """)
|
||
- license_types = dict(cur.fetchall())
|
||
-
|
||
- # Anzahl Testdaten
|
||
- cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE")
|
||
- test_data_count = cur.fetchone()[0]
|
||
-
|
||
- # Anzahl Test-Kunden
|
||
- cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE")
|
||
- test_customers_count = cur.fetchone()[0]
|
||
-
|
||
- # Anzahl Test-Ressourcen
|
||
- cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE")
|
||
- test_resources_count = cur.fetchone()[0]
|
||
-
|
||
- # Letzte 5 erstellten Lizenzen (ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT l.id, l.license_key, c.name, l.valid_until,
|
||
- CASE
|
||
- WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
- ELSE 'aktiv'
|
||
- END as status
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE l.is_test = FALSE
|
||
- ORDER BY l.id DESC
|
||
- LIMIT 5
|
||
- """)
|
||
- recent_licenses = cur.fetchall()
|
||
-
|
||
- # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT l.id, l.license_key, c.name, l.valid_until,
|
||
- l.valid_until - CURRENT_DATE as days_left
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE l.valid_until >= CURRENT_DATE
|
||
- AND l.valid_until < CURRENT_DATE + INTERVAL '30 days'
|
||
- AND l.is_active = TRUE
|
||
- AND l.is_test = FALSE
|
||
- ORDER BY l.valid_until
|
||
- LIMIT 10
|
||
- """)
|
||
- expiring_licenses = cur.fetchall()
|
||
-
|
||
- # Letztes Backup
|
||
- cur.execute("""
|
||
- SELECT created_at, filesize, duration_seconds, backup_type, status
|
||
- FROM backup_history
|
||
- ORDER BY created_at DESC
|
||
- LIMIT 1
|
||
- """)
|
||
- last_backup_info = cur.fetchone()
|
||
-
|
||
- # Sicherheitsstatistiken
|
||
- # Gesperrte IPs
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM login_attempts
|
||
- WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP
|
||
- """)
|
||
- blocked_ips_count = cur.fetchone()[0]
|
||
-
|
||
- # Fehlversuche heute
|
||
- cur.execute("""
|
||
- SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts
|
||
- WHERE last_attempt::date = CURRENT_DATE
|
||
- """)
|
||
- failed_attempts_today = cur.fetchone()[0]
|
||
-
|
||
- # Letzte 5 Sicherheitsereignisse
|
||
- cur.execute("""
|
||
- SELECT
|
||
- la.ip_address,
|
||
- la.attempt_count,
|
||
- la.last_attempt,
|
||
- la.blocked_until,
|
||
- la.last_username_tried,
|
||
- la.last_error_message
|
||
- FROM login_attempts la
|
||
- ORDER BY la.last_attempt DESC
|
||
- LIMIT 5
|
||
- """)
|
||
- recent_security_events = []
|
||
- for event in cur.fetchall():
|
||
- recent_security_events.append({
|
||
- 'ip_address': event[0],
|
||
- 'attempt_count': event[1],
|
||
- 'last_attempt': event[2].strftime('%d.%m %H:%M'),
|
||
- 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None,
|
||
- 'username_tried': event[4],
|
||
- 'error_message': event[5]
|
||
- })
|
||
-
|
||
- # Sicherheitslevel berechnen
|
||
- if blocked_ips_count > 5 or failed_attempts_today > 50:
|
||
- security_level = 'danger'
|
||
- security_level_text = 'KRITISCH'
|
||
- elif blocked_ips_count > 2 or failed_attempts_today > 20:
|
||
- security_level = 'warning'
|
||
- security_level_text = 'ERHÖHT'
|
||
- else:
|
||
- security_level = 'success'
|
||
- security_level_text = 'NORMAL'
|
||
-
|
||
- # Resource Pool Statistiken (nur Live-Daten, keine Testdaten)
|
||
- cur.execute("""
|
||
- SELECT
|
||
- resource_type,
|
||
- COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
- COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
- COUNT(*) as total
|
||
- FROM resource_pools
|
||
- WHERE is_test = FALSE
|
||
- GROUP BY resource_type
|
||
- """)
|
||
-
|
||
- resource_stats = {}
|
||
- resource_warning = None
|
||
-
|
||
- for row in cur.fetchall():
|
||
- available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1)
|
||
- resource_stats[row[0]] = {
|
||
- 'available': row[1],
|
||
- 'allocated': row[2],
|
||
- 'quarantine': row[3],
|
||
- 'total': row[4],
|
||
- 'available_percent': available_percent,
|
||
- 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success'
|
||
- }
|
||
-
|
||
- # Warnung bei niedrigem Bestand
|
||
- if row[1] < 50:
|
||
- if not resource_warning:
|
||
- resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!"
|
||
- else:
|
||
- resource_warning += f" | {row[0].upper()}: {row[1]}"
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- stats = {
|
||
- 'total_customers': total_customers,
|
||
- 'total_licenses': total_licenses,
|
||
- 'active_licenses': active_licenses,
|
||
- 'expired_licenses': expired_licenses,
|
||
- 'inactive_licenses': inactive_licenses,
|
||
- 'expiring_soon': expiring_soon,
|
||
- 'full_licenses': license_types.get('full', 0),
|
||
- 'test_licenses': license_types.get('test', 0),
|
||
- 'test_data_count': test_data_count,
|
||
- 'test_customers_count': test_customers_count,
|
||
- 'test_resources_count': test_resources_count,
|
||
- 'recent_licenses': recent_licenses,
|
||
- 'expiring_licenses': expiring_licenses,
|
||
- 'active_sessions': active_sessions_count,
|
||
- 'last_backup': last_backup_info,
|
||
- # Sicherheitsstatistiken
|
||
- 'blocked_ips_count': blocked_ips_count,
|
||
- 'failed_attempts_today': failed_attempts_today,
|
||
- 'recent_security_events': recent_security_events,
|
||
- 'security_level': security_level,
|
||
- 'security_level_text': security_level_text,
|
||
- 'resource_stats': resource_stats
|
||
- }
|
||
-
|
||
- return render_template("dashboard.html",
|
||
- stats=stats,
|
||
- resource_stats=resource_stats,
|
||
- resource_warning=resource_warning,
|
||
- username=session.get('username'))
|
||
-
|
||
-@app.route("/create", methods=["GET", "POST"])
|
||
-@login_required
|
||
-def create_license():
|
||
- if request.method == "POST":
|
||
- customer_id = request.form.get("customer_id")
|
||
- license_key = request.form["license_key"].upper() # Immer Großbuchstaben
|
||
- license_type = request.form["license_type"]
|
||
- valid_from = request.form["valid_from"]
|
||
- is_test = request.form.get("is_test") == "on" # Checkbox value
|
||
-
|
||
- # Berechne valid_until basierend auf Laufzeit
|
||
- duration = int(request.form.get("duration", 1))
|
||
- duration_type = request.form.get("duration_type", "years")
|
||
-
|
||
- from datetime import datetime, timedelta
|
||
- from dateutil.relativedelta import relativedelta
|
||
-
|
||
- start_date = datetime.strptime(valid_from, "%Y-%m-%d")
|
||
-
|
||
- if duration_type == "days":
|
||
- end_date = start_date + timedelta(days=duration)
|
||
- elif duration_type == "months":
|
||
- end_date = start_date + relativedelta(months=duration)
|
||
- else: # years
|
||
- end_date = start_date + relativedelta(years=duration)
|
||
-
|
||
- # Ein Tag abziehen, da der Starttag mitgezählt wird
|
||
- end_date = end_date - timedelta(days=1)
|
||
- valid_until = end_date.strftime("%Y-%m-%d")
|
||
-
|
||
- # Validiere License Key Format
|
||
- if not validate_license_key(license_key):
|
||
- flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error')
|
||
- return redirect(url_for('create_license'))
|
||
-
|
||
- # Resource counts
|
||
- domain_count = int(request.form.get("domain_count", 1))
|
||
- ipv4_count = int(request.form.get("ipv4_count", 1))
|
||
- phone_count = int(request.form.get("phone_count", 1))
|
||
- device_limit = int(request.form.get("device_limit", 3))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Prüfe ob neuer Kunde oder bestehender
|
||
- if customer_id == "new":
|
||
- # Neuer Kunde
|
||
- name = request.form.get("customer_name")
|
||
- email = request.form.get("email")
|
||
-
|
||
- if not name:
|
||
- flash('Kundenname ist erforderlich!', 'error')
|
||
- return redirect(url_for('create_license'))
|
||
-
|
||
- # Prüfe ob E-Mail bereits existiert
|
||
- if email:
|
||
- cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||
- existing = cur.fetchone()
|
||
- if existing:
|
||
- flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||
- return redirect(url_for('create_license'))
|
||
-
|
||
- # Kunde einfügen (erbt Test-Status von Lizenz)
|
||
- cur.execute("""
|
||
- INSERT INTO customers (name, email, is_test, created_at)
|
||
- VALUES (%s, %s, %s, NOW())
|
||
- RETURNING id
|
||
- """, (name, email, is_test))
|
||
- customer_id = cur.fetchone()[0]
|
||
- customer_info = {'name': name, 'email': email, 'is_test': is_test}
|
||
-
|
||
- # Audit-Log für neuen Kunden
|
||
- log_audit('CREATE', 'customer', customer_id,
|
||
- new_values={'name': name, 'email': email, 'is_test': is_test})
|
||
- else:
|
||
- # Bestehender Kunde - hole Infos für Audit-Log
|
||
- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
- customer_data = cur.fetchone()
|
||
- if not customer_data:
|
||
- flash('Kunde nicht gefunden!', 'error')
|
||
- return redirect(url_for('create_license'))
|
||
- customer_info = {'name': customer_data[0], 'email': customer_data[1]}
|
||
-
|
||
- # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren
|
||
- if customer_data[2]: # is_test des Kunden
|
||
- is_test = True
|
||
-
|
||
- # Lizenz hinzufügen
|
||
- cur.execute("""
|
||
- INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active,
|
||
- domain_count, ipv4_count, phone_count, device_limit, is_test)
|
||
- VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s)
|
||
- RETURNING id
|
||
- """, (license_key, customer_id, license_type, valid_from, valid_until,
|
||
- domain_count, ipv4_count, phone_count, device_limit, is_test))
|
||
- license_id = cur.fetchone()[0]
|
||
-
|
||
- # Ressourcen zuweisen
|
||
- try:
|
||
- # Prüfe Verfügbarkeit
|
||
- cur.execute("""
|
||
- SELECT
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains,
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s,
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones
|
||
- """, (is_test, is_test, is_test))
|
||
- available = cur.fetchone()
|
||
-
|
||
- if available[0] < domain_count:
|
||
- raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})")
|
||
- if available[1] < ipv4_count:
|
||
- raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})")
|
||
- if available[2] < phone_count:
|
||
- raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})")
|
||
-
|
||
- # Domains zuweisen
|
||
- if domain_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, domain_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- # IPv4s zuweisen
|
||
- if ipv4_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, ipv4_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- # Telefonnummern zuweisen
|
||
- if phone_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, phone_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- except ValueError as e:
|
||
- conn.rollback()
|
||
- flash(str(e), 'error')
|
||
- return redirect(url_for('create_license'))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('CREATE', 'license', license_id,
|
||
- new_values={
|
||
- 'license_key': license_key,
|
||
- 'customer_name': customer_info['name'],
|
||
- 'customer_email': customer_info['email'],
|
||
- 'license_type': license_type,
|
||
- 'valid_from': valid_from,
|
||
- 'valid_until': valid_until,
|
||
- 'device_limit': device_limit,
|
||
- 'is_test': is_test
|
||
- })
|
||
-
|
||
- flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success')
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}")
|
||
- flash('Fehler beim Erstellen der Lizenz!', 'error')
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Preserve show_test parameter if present
|
||
- redirect_url = "/create"
|
||
- if request.args.get('show_test') == 'true':
|
||
- redirect_url += "?show_test=true"
|
||
- return redirect(redirect_url)
|
||
-
|
||
- # Unterstützung für vorausgewählten Kunden
|
||
- preselected_customer_id = request.args.get('customer_id', type=int)
|
||
- return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id)
|
||
-
|
||
-@app.route("/batch", methods=["GET", "POST"])
|
||
-@login_required
|
||
-def batch_licenses():
|
||
- """Batch-Generierung mehrerer Lizenzen für einen Kunden"""
|
||
- if request.method == "POST":
|
||
- # Formulardaten
|
||
- customer_id = request.form.get("customer_id")
|
||
- license_type = request.form["license_type"]
|
||
- quantity = int(request.form["quantity"])
|
||
- valid_from = request.form["valid_from"]
|
||
- is_test = request.form.get("is_test") == "on" # Checkbox value
|
||
-
|
||
- # Berechne valid_until basierend auf Laufzeit
|
||
- duration = int(request.form.get("duration", 1))
|
||
- duration_type = request.form.get("duration_type", "years")
|
||
-
|
||
- from datetime import datetime, timedelta
|
||
- from dateutil.relativedelta import relativedelta
|
||
-
|
||
- start_date = datetime.strptime(valid_from, "%Y-%m-%d")
|
||
-
|
||
- if duration_type == "days":
|
||
- end_date = start_date + timedelta(days=duration)
|
||
- elif duration_type == "months":
|
||
- end_date = start_date + relativedelta(months=duration)
|
||
- else: # years
|
||
- end_date = start_date + relativedelta(years=duration)
|
||
-
|
||
- # Ein Tag abziehen, da der Starttag mitgezählt wird
|
||
- end_date = end_date - timedelta(days=1)
|
||
- valid_until = end_date.strftime("%Y-%m-%d")
|
||
-
|
||
- # Resource counts
|
||
- domain_count = int(request.form.get("domain_count", 1))
|
||
- ipv4_count = int(request.form.get("ipv4_count", 1))
|
||
- phone_count = int(request.form.get("phone_count", 1))
|
||
- device_limit = int(request.form.get("device_limit", 3))
|
||
-
|
||
- # Sicherheitslimit
|
||
- if quantity < 1 or quantity > 100:
|
||
- flash('Anzahl muss zwischen 1 und 100 liegen!', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Prüfe ob neuer Kunde oder bestehender
|
||
- if customer_id == "new":
|
||
- # Neuer Kunde
|
||
- name = request.form.get("customer_name")
|
||
- email = request.form.get("email")
|
||
-
|
||
- if not name:
|
||
- flash('Kundenname ist erforderlich!', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
-
|
||
- # Prüfe ob E-Mail bereits existiert
|
||
- if email:
|
||
- cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||
- existing = cur.fetchone()
|
||
- if existing:
|
||
- flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
-
|
||
- # Kunde einfügen (erbt Test-Status von Lizenz)
|
||
- cur.execute("""
|
||
- INSERT INTO customers (name, email, is_test, created_at)
|
||
- VALUES (%s, %s, %s, NOW())
|
||
- RETURNING id
|
||
- """, (name, email, is_test))
|
||
- customer_id = cur.fetchone()[0]
|
||
-
|
||
- # Audit-Log für neuen Kunden
|
||
- log_audit('CREATE', 'customer', customer_id,
|
||
- new_values={'name': name, 'email': email, 'is_test': is_test})
|
||
- else:
|
||
- # Bestehender Kunde - hole Infos
|
||
- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
- customer_data = cur.fetchone()
|
||
- if not customer_data:
|
||
- flash('Kunde nicht gefunden!', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
- name = customer_data[0]
|
||
- email = customer_data[1]
|
||
-
|
||
- # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren
|
||
- if customer_data[2]: # is_test des Kunden
|
||
- is_test = True
|
||
-
|
||
- # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch
|
||
- total_domains_needed = domain_count * quantity
|
||
- total_ipv4s_needed = ipv4_count * quantity
|
||
- total_phones_needed = phone_count * quantity
|
||
-
|
||
- cur.execute("""
|
||
- SELECT
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains,
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s,
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones
|
||
- """, (is_test, is_test, is_test))
|
||
- available = cur.fetchone()
|
||
-
|
||
- if available[0] < total_domains_needed:
|
||
- flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
- if available[1] < total_ipv4s_needed:
|
||
- flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
- if available[2] < total_phones_needed:
|
||
- flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
-
|
||
- # Lizenzen generieren und speichern
|
||
- generated_licenses = []
|
||
- for i in range(quantity):
|
||
- # Eindeutigen Key generieren
|
||
- attempts = 0
|
||
- while attempts < 10:
|
||
- license_key = generate_license_key(license_type)
|
||
- cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,))
|
||
- if not cur.fetchone():
|
||
- break
|
||
- attempts += 1
|
||
-
|
||
- # Lizenz einfügen
|
||
- cur.execute("""
|
||
- INSERT INTO licenses (license_key, customer_id, license_type, is_test,
|
||
- valid_from, valid_until, is_active,
|
||
- domain_count, ipv4_count, phone_count, device_limit)
|
||
- VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s)
|
||
- RETURNING id
|
||
- """, (license_key, customer_id, license_type, is_test, valid_from, valid_until,
|
||
- domain_count, ipv4_count, phone_count, device_limit))
|
||
- license_id = cur.fetchone()[0]
|
||
-
|
||
- # Ressourcen für diese Lizenz zuweisen
|
||
- # Domains
|
||
- if domain_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, domain_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- # IPv4s
|
||
- if ipv4_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, ipv4_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- # Telefonnummern
|
||
- if phone_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, phone_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- generated_licenses.append({
|
||
- 'id': license_id,
|
||
- 'key': license_key,
|
||
- 'type': license_type
|
||
- })
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('CREATE_BATCH', 'license',
|
||
- new_values={'customer': name, 'quantity': quantity, 'type': license_type},
|
||
- additional_info=f"Batch-Generierung von {quantity} Lizenzen")
|
||
-
|
||
- # Session für Export speichern
|
||
- session['batch_export'] = {
|
||
- 'customer': name,
|
||
- 'email': email,
|
||
- 'licenses': generated_licenses,
|
||
- 'valid_from': valid_from,
|
||
- 'valid_until': valid_until,
|
||
- 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- }
|
||
-
|
||
- flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success')
|
||
- return render_template("batch_result.html",
|
||
- customer=name,
|
||
- email=email,
|
||
- licenses=generated_licenses,
|
||
- valid_from=valid_from,
|
||
- valid_until=valid_until)
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- logging.error(f"Fehler bei Batch-Generierung: {str(e)}")
|
||
- flash('Fehler bei der Batch-Generierung!', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # GET Request
|
||
- return render_template("batch_form.html")
|
||
-
|
||
-@app.route("/batch/export")
|
||
-@login_required
|
||
-def export_batch():
|
||
- """Exportiert die zuletzt generierten Batch-Lizenzen"""
|
||
- batch_data = session.get('batch_export')
|
||
- if not batch_data:
|
||
- flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
-
|
||
- # CSV generieren
|
||
- output = io.StringIO()
|
||
- output.write('\ufeff') # UTF-8 BOM für Excel
|
||
-
|
||
- # Header
|
||
- output.write(f"Kunde: {batch_data['customer']}\n")
|
||
- output.write(f"E-Mail: {batch_data['email']}\n")
|
||
- output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n")
|
||
- output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n")
|
||
- output.write("\n")
|
||
- output.write("Nr;Lizenzschlüssel;Typ\n")
|
||
-
|
||
- # Lizenzen
|
||
- for i, license in enumerate(batch_data['licenses'], 1):
|
||
- typ_text = "Vollversion" if license['type'] == 'full' else "Testversion"
|
||
- output.write(f"{i};{license['key']};{typ_text}\n")
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('EXPORT', 'batch_licenses',
|
||
- additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen")
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
- mimetype='text/csv',
|
||
- as_attachment=True,
|
||
- download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv"
|
||
- )
|
||
-
|
||
-@app.route("/licenses")
|
||
-@login_required
|
||
-def licenses():
|
||
- # Redirect zur kombinierten Ansicht
|
||
- return redirect("/customers-licenses")
|
||
-
|
||
-@app.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
|
||
-@login_required
|
||
-def edit_license(license_id):
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- if request.method == "POST":
|
||
- # Alte Werte für Audit-Log abrufen
|
||
- cur.execute("""
|
||
- SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit
|
||
- FROM licenses WHERE id = %s
|
||
- """, (license_id,))
|
||
- old_license = cur.fetchone()
|
||
-
|
||
- # Update license
|
||
- license_key = request.form["license_key"]
|
||
- license_type = request.form["license_type"]
|
||
- valid_from = request.form["valid_from"]
|
||
- valid_until = request.form["valid_until"]
|
||
- is_active = request.form.get("is_active") == "on"
|
||
- is_test = request.form.get("is_test") == "on"
|
||
- device_limit = int(request.form.get("device_limit", 3))
|
||
-
|
||
- cur.execute("""
|
||
- UPDATE licenses
|
||
- SET license_key = %s, license_type = %s, valid_from = %s,
|
||
- valid_until = %s, is_active = %s, is_test = %s, device_limit = %s
|
||
- WHERE id = %s
|
||
- """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('UPDATE', 'license', license_id,
|
||
- old_values={
|
||
- 'license_key': old_license[0],
|
||
- 'license_type': old_license[1],
|
||
- 'valid_from': str(old_license[2]),
|
||
- 'valid_until': str(old_license[3]),
|
||
- 'is_active': old_license[4],
|
||
- 'is_test': old_license[5],
|
||
- 'device_limit': old_license[6]
|
||
- },
|
||
- new_values={
|
||
- 'license_key': license_key,
|
||
- 'license_type': license_type,
|
||
- 'valid_from': valid_from,
|
||
- 'valid_until': valid_until,
|
||
- 'is_active': is_active,
|
||
- 'is_test': is_test,
|
||
- 'device_limit': device_limit
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Redirect zurück zu customers-licenses mit beibehaltenen Parametern
|
||
- redirect_url = "/customers-licenses"
|
||
-
|
||
- # Behalte show_test Parameter bei (aus Form oder GET-Parameter)
|
||
- show_test = request.form.get('show_test') or request.args.get('show_test')
|
||
- if show_test == 'true':
|
||
- redirect_url += "?show_test=true"
|
||
-
|
||
- # Behalte customer_id bei wenn vorhanden
|
||
- if request.referrer and 'customer_id=' in request.referrer:
|
||
- import re
|
||
- match = re.search(r'customer_id=(\d+)', request.referrer)
|
||
- if match:
|
||
- connector = "&" if "?" in redirect_url else "?"
|
||
- redirect_url += f"{connector}customer_id={match.group(1)}"
|
||
-
|
||
- return redirect(redirect_url)
|
||
-
|
||
- # Get license data
|
||
- cur.execute("""
|
||
- SELECT l.id, l.license_key, c.name, c.email, l.license_type,
|
||
- l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE l.id = %s
|
||
- """, (license_id,))
|
||
-
|
||
- license = cur.fetchone()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- if not license:
|
||
- return redirect("/licenses")
|
||
-
|
||
- return render_template("edit_license.html", license=license, username=session.get('username'))
|
||
-
|
||
-@app.route("/license/delete/<int:license_id>", methods=["POST"])
|
||
-@login_required
|
||
-def delete_license(license_id):
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Lizenzdetails für Audit-Log abrufen
|
||
- cur.execute("""
|
||
- SELECT l.license_key, c.name, l.license_type
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE l.id = %s
|
||
- """, (license_id,))
|
||
- license_info = cur.fetchone()
|
||
-
|
||
- cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- if license_info:
|
||
- log_audit('DELETE', 'license', license_id,
|
||
- old_values={
|
||
- 'license_key': license_info[0],
|
||
- 'customer_name': license_info[1],
|
||
- 'license_type': license_info[2]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return redirect("/licenses")
|
||
-
|
||
-@app.route("/customers")
|
||
-@login_required
|
||
-def customers():
|
||
- # Redirect zur kombinierten Ansicht
|
||
- return redirect("/customers-licenses")
|
||
-
|
||
-@app.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
|
||
-@login_required
|
||
-def edit_customer(customer_id):
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- if request.method == "POST":
|
||
- # Alte Werte für Audit-Log abrufen
|
||
- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
- old_customer = cur.fetchone()
|
||
-
|
||
- # Update customer
|
||
- name = request.form["name"]
|
||
- email = request.form["email"]
|
||
- is_test = request.form.get("is_test") == "on"
|
||
-
|
||
- cur.execute("""
|
||
- UPDATE customers
|
||
- SET name = %s, email = %s, is_test = %s
|
||
- WHERE id = %s
|
||
- """, (name, email, is_test, customer_id))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('UPDATE', 'customer', customer_id,
|
||
- old_values={
|
||
- 'name': old_customer[0],
|
||
- 'email': old_customer[1],
|
||
- 'is_test': old_customer[2]
|
||
- },
|
||
- new_values={
|
||
- 'name': name,
|
||
- 'email': email,
|
||
- 'is_test': is_test
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Redirect zurück zu customers-licenses mit beibehaltenen Parametern
|
||
- redirect_url = "/customers-licenses"
|
||
-
|
||
- # Behalte show_test Parameter bei (aus Form oder GET-Parameter)
|
||
- show_test = request.form.get('show_test') or request.args.get('show_test')
|
||
- if show_test == 'true':
|
||
- redirect_url += "?show_test=true"
|
||
-
|
||
- # Behalte customer_id bei (immer der aktuelle Kunde)
|
||
- connector = "&" if "?" in redirect_url else "?"
|
||
- redirect_url += f"{connector}customer_id={customer_id}"
|
||
-
|
||
- return redirect(redirect_url)
|
||
-
|
||
- # Get customer data with licenses
|
||
- cur.execute("""
|
||
- SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s
|
||
- """, (customer_id,))
|
||
-
|
||
- customer = cur.fetchone()
|
||
- if not customer:
|
||
- cur.close()
|
||
- conn.close()
|
||
- return "Kunde nicht gefunden", 404
|
||
-
|
||
-
|
||
- # Get customer's licenses
|
||
- cur.execute("""
|
||
- SELECT id, license_key, license_type, valid_from, valid_until, is_active
|
||
- FROM licenses
|
||
- WHERE customer_id = %s
|
||
- ORDER BY valid_until DESC
|
||
- """, (customer_id,))
|
||
-
|
||
- licenses = cur.fetchall()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- if not customer:
|
||
- return redirect("/customers-licenses")
|
||
-
|
||
- return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username'))
|
||
-
|
||
-@app.route("/customer/create", methods=["GET", "POST"])
|
||
-@login_required
|
||
-def create_customer():
|
||
- """Erstellt einen neuen Kunden ohne Lizenz"""
|
||
- if request.method == "POST":
|
||
- name = request.form.get('name')
|
||
- email = request.form.get('email')
|
||
- is_test = request.form.get('is_test') == 'on'
|
||
-
|
||
- if not name or not email:
|
||
- flash("Name und E-Mail sind Pflichtfelder!", "error")
|
||
- return render_template("create_customer.html", username=session.get('username'))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Prüfen ob E-Mail bereits existiert
|
||
- cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,))
|
||
- existing = cur.fetchone()
|
||
- if existing:
|
||
- flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error")
|
||
- return render_template("create_customer.html", username=session.get('username'))
|
||
-
|
||
- # Kunde erstellen
|
||
- cur.execute("""
|
||
- INSERT INTO customers (name, email, created_at, is_test)
|
||
- VALUES (%s, %s, %s, %s) RETURNING id
|
||
- """, (name, email, datetime.now(), is_test))
|
||
-
|
||
- customer_id = cur.fetchone()[0]
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('CREATE', 'customer', customer_id,
|
||
- new_values={
|
||
- 'name': name,
|
||
- 'email': email,
|
||
- 'is_test': is_test
|
||
- })
|
||
-
|
||
- flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success")
|
||
- return redirect(f"/customer/edit/{customer_id}")
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error")
|
||
- return render_template("create_customer.html", username=session.get('username'))
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # GET Request - Formular anzeigen
|
||
- return render_template("create_customer.html", username=session.get('username'))
|
||
-
|
||
-@app.route("/customer/delete/<int:customer_id>", methods=["POST"])
|
||
-@login_required
|
||
-def delete_customer(customer_id):
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Prüfen ob Kunde Lizenzen hat
|
||
- cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,))
|
||
- license_count = cur.fetchone()[0]
|
||
-
|
||
- if license_count > 0:
|
||
- # Kunde hat Lizenzen - nicht löschen
|
||
- cur.close()
|
||
- conn.close()
|
||
- return redirect("/customers")
|
||
-
|
||
- # Kundendetails für Audit-Log abrufen
|
||
- cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,))
|
||
- customer_info = cur.fetchone()
|
||
-
|
||
- # Kunde löschen wenn keine Lizenzen vorhanden
|
||
- cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- if customer_info:
|
||
- log_audit('DELETE', 'customer', customer_id,
|
||
- old_values={
|
||
- 'name': customer_info[0],
|
||
- 'email': customer_info[1]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return redirect("/customers")
|
||
-
|
||
-@app.route("/customers-licenses")
|
||
-@login_required
|
||
-def customers_licenses():
|
||
- """Kombinierte Ansicht für Kunden und deren Lizenzen"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht)
|
||
- show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
-
|
||
- query = """
|
||
- SELECT
|
||
- c.id,
|
||
- c.name,
|
||
- c.email,
|
||
- c.created_at,
|
||
- COUNT(l.id) as total_licenses,
|
||
- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- """
|
||
-
|
||
- if not show_test:
|
||
- query += " WHERE c.is_test = FALSE"
|
||
-
|
||
- query += """
|
||
- GROUP BY c.id, c.name, c.email, c.created_at
|
||
- ORDER BY c.name
|
||
- """
|
||
-
|
||
- cur.execute(query)
|
||
- customers = cur.fetchall()
|
||
-
|
||
- # Hole ausgewählten Kunden nur wenn explizit in URL angegeben
|
||
- selected_customer_id = request.args.get('customer_id', type=int)
|
||
- licenses = []
|
||
- selected_customer = None
|
||
-
|
||
- if customers and selected_customer_id:
|
||
- # Hole Daten des ausgewählten Kunden
|
||
- for customer in customers:
|
||
- if customer[0] == selected_customer_id:
|
||
- selected_customer = customer
|
||
- break
|
||
-
|
||
- # Hole Lizenzen des ausgewählten Kunden
|
||
- if selected_customer:
|
||
- cur.execute("""
|
||
- SELECT
|
||
- l.id,
|
||
- l.license_key,
|
||
- l.license_type,
|
||
- l.valid_from,
|
||
- l.valid_until,
|
||
- l.is_active,
|
||
- CASE
|
||
- WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
- ELSE 'aktiv'
|
||
- END as status,
|
||
- l.domain_count,
|
||
- l.ipv4_count,
|
||
- l.phone_count,
|
||
- l.device_limit,
|
||
- (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices,
|
||
- -- Actual resource counts
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count,
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count,
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count
|
||
- FROM licenses l
|
||
- WHERE l.customer_id = %s
|
||
- ORDER BY l.created_at DESC, l.id DESC
|
||
- """, (selected_customer_id,))
|
||
- licenses = cur.fetchall()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template("customers_licenses.html",
|
||
- customers=customers,
|
||
- selected_customer=selected_customer,
|
||
- selected_customer_id=selected_customer_id,
|
||
- licenses=licenses,
|
||
- show_test=show_test)
|
||
-
|
||
-@app.route("/api/customer/<int:customer_id>/licenses")
|
||
-@login_required
|
||
-def api_customer_licenses(customer_id):
|
||
- """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Hole Lizenzen des Kunden
|
||
- cur.execute("""
|
||
- SELECT
|
||
- l.id,
|
||
- l.license_key,
|
||
- l.license_type,
|
||
- l.valid_from,
|
||
- l.valid_until,
|
||
- l.is_active,
|
||
- CASE
|
||
- WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
- ELSE 'aktiv'
|
||
- END as status,
|
||
- l.domain_count,
|
||
- l.ipv4_count,
|
||
- l.phone_count,
|
||
- l.device_limit,
|
||
- (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices,
|
||
- -- Actual resource counts
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count,
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count,
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count
|
||
- FROM licenses l
|
||
- WHERE l.customer_id = %s
|
||
- ORDER BY l.created_at DESC, l.id DESC
|
||
- """, (customer_id,))
|
||
-
|
||
- licenses = []
|
||
- for row in cur.fetchall():
|
||
- license_id = row[0]
|
||
-
|
||
- # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz
|
||
- cur.execute("""
|
||
- SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at
|
||
- FROM resource_pools rp
|
||
- JOIN license_resources lr ON rp.id = lr.resource_id
|
||
- WHERE lr.license_id = %s AND lr.is_active = true
|
||
- ORDER BY rp.resource_type, rp.resource_value
|
||
- """, (license_id,))
|
||
-
|
||
- resources = {
|
||
- 'domains': [],
|
||
- 'ipv4s': [],
|
||
- 'phones': []
|
||
- }
|
||
-
|
||
- for res_row in cur.fetchall():
|
||
- resource_info = {
|
||
- 'id': res_row[0],
|
||
- 'value': res_row[2],
|
||
- 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else ''
|
||
- }
|
||
-
|
||
- if res_row[1] == 'domain':
|
||
- resources['domains'].append(resource_info)
|
||
- elif res_row[1] == 'ipv4':
|
||
- resources['ipv4s'].append(resource_info)
|
||
- elif res_row[1] == 'phone':
|
||
- resources['phones'].append(resource_info)
|
||
-
|
||
- licenses.append({
|
||
- 'id': row[0],
|
||
- 'license_key': row[1],
|
||
- 'license_type': row[2],
|
||
- 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '',
|
||
- 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '',
|
||
- 'is_active': row[5],
|
||
- 'status': row[6],
|
||
- 'domain_count': row[7], # limit
|
||
- 'ipv4_count': row[8], # limit
|
||
- 'phone_count': row[9], # limit
|
||
- 'device_limit': row[10],
|
||
- 'active_devices': row[11],
|
||
- 'actual_domain_count': row[12], # actual count
|
||
- 'actual_ipv4_count': row[13], # actual count
|
||
- 'actual_phone_count': row[14], # actual count
|
||
- 'resources': resources
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'licenses': licenses,
|
||
- 'count': len(licenses)
|
||
- })
|
||
-
|
||
-@app.route("/api/customer/<int:customer_id>/quick-stats")
|
||
-@login_required
|
||
-def api_customer_quick_stats(customer_id):
|
||
- """API-Endpoint für Schnellstatistiken eines Kunden"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Hole Kundenstatistiken
|
||
- cur.execute("""
|
||
- SELECT
|
||
- COUNT(l.id) as total_licenses,
|
||
- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses,
|
||
- COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon
|
||
- FROM licenses l
|
||
- WHERE l.customer_id = %s
|
||
- """, (customer_id,))
|
||
-
|
||
- stats = cur.fetchone()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'stats': {
|
||
- 'total': stats[0],
|
||
- 'active': stats[1],
|
||
- 'expired': stats[2],
|
||
- 'expiring_soon': stats[3]
|
||
- }
|
||
- })
|
||
-
|
||
-@app.route("/api/license/<int:license_id>/quick-edit", methods=['POST'])
|
||
-@login_required
|
||
-def api_license_quick_edit(license_id):
|
||
- """API-Endpoint für schnelle Lizenz-Bearbeitung"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- data = request.get_json()
|
||
-
|
||
- # Hole alte Werte für Audit-Log
|
||
- cur.execute("""
|
||
- SELECT is_active, valid_until, license_type
|
||
- FROM licenses WHERE id = %s
|
||
- """, (license_id,))
|
||
- old_values = cur.fetchone()
|
||
-
|
||
- if not old_values:
|
||
- return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404
|
||
-
|
||
- # Update-Felder vorbereiten
|
||
- updates = []
|
||
- params = []
|
||
- new_values = {}
|
||
-
|
||
- if 'is_active' in data:
|
||
- updates.append("is_active = %s")
|
||
- params.append(data['is_active'])
|
||
- new_values['is_active'] = data['is_active']
|
||
-
|
||
- if 'valid_until' in data:
|
||
- updates.append("valid_until = %s")
|
||
- params.append(data['valid_until'])
|
||
- new_values['valid_until'] = data['valid_until']
|
||
-
|
||
- if 'license_type' in data:
|
||
- updates.append("license_type = %s")
|
||
- params.append(data['license_type'])
|
||
- new_values['license_type'] = data['license_type']
|
||
-
|
||
- if updates:
|
||
- params.append(license_id)
|
||
- cur.execute(f"""
|
||
- UPDATE licenses
|
||
- SET {', '.join(updates)}
|
||
- WHERE id = %s
|
||
- """, params)
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('UPDATE', 'license', license_id,
|
||
- old_values={
|
||
- 'is_active': old_values[0],
|
||
- 'valid_until': old_values[1].isoformat() if old_values[1] else None,
|
||
- 'license_type': old_values[2]
|
||
- },
|
||
- new_values=new_values)
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True})
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- cur.close()
|
||
- conn.close()
|
||
- return jsonify({'success': False, 'error': str(e)}), 500
|
||
-
|
||
-@app.route("/api/license/<int:license_id>/resources")
|
||
-@login_required
|
||
-def api_license_resources(license_id):
|
||
- """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz
|
||
- cur.execute("""
|
||
- SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at
|
||
- FROM resource_pools rp
|
||
- JOIN license_resources lr ON rp.id = lr.resource_id
|
||
- WHERE lr.license_id = %s AND lr.is_active = true
|
||
- ORDER BY rp.resource_type, rp.resource_value
|
||
- """, (license_id,))
|
||
-
|
||
- resources = {
|
||
- 'domains': [],
|
||
- 'ipv4s': [],
|
||
- 'phones': []
|
||
- }
|
||
-
|
||
- for row in cur.fetchall():
|
||
- resource_info = {
|
||
- 'id': row[0],
|
||
- 'value': row[2],
|
||
- 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else ''
|
||
- }
|
||
-
|
||
- if row[1] == 'domain':
|
||
- resources['domains'].append(resource_info)
|
||
- elif row[1] == 'ipv4':
|
||
- resources['ipv4s'].append(resource_info)
|
||
- elif row[1] == 'phone':
|
||
- resources['phones'].append(resource_info)
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'resources': resources
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- cur.close()
|
||
- conn.close()
|
||
- return jsonify({'success': False, 'error': str(e)}), 500
|
||
-
|
||
-@app.route("/sessions")
|
||
-@login_required
|
||
-def sessions():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Sortierparameter
|
||
- active_sort = request.args.get('active_sort', 'last_heartbeat')
|
||
- active_order = request.args.get('active_order', 'desc')
|
||
- ended_sort = request.args.get('ended_sort', 'ended_at')
|
||
- ended_order = request.args.get('ended_order', 'desc')
|
||
-
|
||
- # Whitelist für erlaubte Sortierfelder - Aktive Sessions
|
||
- active_sort_fields = {
|
||
- 'customer': 'c.name',
|
||
- 'license': 'l.license_key',
|
||
- 'ip': 's.ip_address',
|
||
- 'started': 's.started_at',
|
||
- 'last_heartbeat': 's.last_heartbeat',
|
||
- 'inactive': 'minutes_inactive'
|
||
- }
|
||
-
|
||
- # Whitelist für erlaubte Sortierfelder - Beendete Sessions
|
||
- ended_sort_fields = {
|
||
- 'customer': 'c.name',
|
||
- 'license': 'l.license_key',
|
||
- 'ip': 's.ip_address',
|
||
- 'started': 's.started_at',
|
||
- 'ended_at': 's.ended_at',
|
||
- 'duration': 'duration_minutes'
|
||
- }
|
||
-
|
||
- # Validierung
|
||
- if active_sort not in active_sort_fields:
|
||
- active_sort = 'last_heartbeat'
|
||
- if ended_sort not in ended_sort_fields:
|
||
- ended_sort = 'ended_at'
|
||
- if active_order not in ['asc', 'desc']:
|
||
- active_order = 'desc'
|
||
- if ended_order not in ['asc', 'desc']:
|
||
- ended_order = 'desc'
|
||
-
|
||
- # Aktive Sessions abrufen
|
||
- cur.execute(f"""
|
||
- SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address,
|
||
- s.user_agent, s.started_at, s.last_heartbeat,
|
||
- EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive
|
||
- FROM sessions s
|
||
- JOIN licenses l ON s.license_id = l.id
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE s.is_active = TRUE
|
||
- ORDER BY {active_sort_fields[active_sort]} {active_order.upper()}
|
||
- """)
|
||
- active_sessions = cur.fetchall()
|
||
-
|
||
- # Inaktive Sessions der letzten 24 Stunden
|
||
- cur.execute(f"""
|
||
- SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address,
|
||
- s.started_at, s.ended_at,
|
||
- EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes
|
||
- FROM sessions s
|
||
- JOIN licenses l ON s.license_id = l.id
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE s.is_active = FALSE
|
||
- AND s.ended_at > NOW() - INTERVAL '24 hours'
|
||
- ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()}
|
||
- LIMIT 50
|
||
- """)
|
||
- recent_sessions = cur.fetchall()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template("sessions.html",
|
||
- active_sessions=active_sessions,
|
||
- recent_sessions=recent_sessions,
|
||
- active_sort=active_sort,
|
||
- active_order=active_order,
|
||
- ended_sort=ended_sort,
|
||
- ended_order=ended_order,
|
||
- username=session.get('username'))
|
||
-
|
||
-@app.route("/session/end/<int:session_id>", methods=["POST"])
|
||
-@login_required
|
||
-def end_session(session_id):
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Session beenden
|
||
- cur.execute("""
|
||
- UPDATE sessions
|
||
- SET is_active = FALSE, ended_at = NOW()
|
||
- WHERE id = %s AND is_active = TRUE
|
||
- """, (session_id,))
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return redirect("/sessions")
|
||
-
|
||
-@app.route("/export/licenses")
|
||
-@login_required
|
||
-def export_licenses():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht)
|
||
- include_test = request.args.get('include_test', 'false').lower() == 'true'
|
||
- customer_id = request.args.get('customer_id', type=int)
|
||
-
|
||
- query = """
|
||
- SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email,
|
||
- l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test,
|
||
- CASE
|
||
- WHEN l.is_active = FALSE THEN 'Deaktiviert'
|
||
- WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen'
|
||
- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab'
|
||
- ELSE 'Aktiv'
|
||
- END as status
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- """
|
||
-
|
||
- # Build WHERE clause
|
||
- where_conditions = []
|
||
- params = []
|
||
-
|
||
- if not include_test:
|
||
- where_conditions.append("l.is_test = FALSE")
|
||
-
|
||
- if customer_id:
|
||
- where_conditions.append("l.customer_id = %s")
|
||
- params.append(customer_id)
|
||
-
|
||
- if where_conditions:
|
||
- query += " WHERE " + " AND ".join(where_conditions)
|
||
-
|
||
- query += " ORDER BY l.id"
|
||
-
|
||
- cur.execute(query, params)
|
||
-
|
||
- # Spaltennamen
|
||
- columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ',
|
||
- 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status']
|
||
-
|
||
- # Daten in DataFrame
|
||
- data = cur.fetchall()
|
||
- df = pd.DataFrame(data, columns=columns)
|
||
-
|
||
- # Datumsformatierung
|
||
- df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y')
|
||
- df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y')
|
||
-
|
||
- # Typ und Aktiv Status anpassen
|
||
- df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'})
|
||
- df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'})
|
||
- df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'})
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Export Format
|
||
- export_format = request.args.get('format', 'excel')
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('EXPORT', 'license',
|
||
- additional_info=f"Export aller Lizenzen als {export_format.upper()}")
|
||
- filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}'
|
||
-
|
||
- if export_format == 'csv':
|
||
- # CSV Export
|
||
- output = io.StringIO()
|
||
- df.to_csv(output, index=False, encoding='utf-8-sig', sep=';')
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
- mimetype='text/csv',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv'
|
||
- )
|
||
- else:
|
||
- # Excel Export
|
||
- output = io.BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, sheet_name='Lizenzen', index=False)
|
||
-
|
||
- # Formatierung
|
||
- worksheet = writer.sheets['Lizenzen']
|
||
- for column in worksheet.columns:
|
||
- max_length = 0
|
||
- column_letter = column[0].column_letter
|
||
- for cell in column:
|
||
- try:
|
||
- if len(str(cell.value)) > max_length:
|
||
- max_length = len(str(cell.value))
|
||
- except:
|
||
- pass
|
||
- adjusted_width = min(max_length + 2, 50)
|
||
- worksheet.column_dimensions[column_letter].width = adjusted_width
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx'
|
||
- )
|
||
-
|
||
-@app.route("/export/audit")
|
||
-@login_required
|
||
-def export_audit():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Holen der Filter-Parameter
|
||
- filter_user = request.args.get('user', '')
|
||
- filter_action = request.args.get('action', '')
|
||
- filter_entity = request.args.get('entity', '')
|
||
- export_format = request.args.get('format', 'excel')
|
||
-
|
||
- # SQL Query mit Filtern
|
||
- query = """
|
||
- SELECT id, timestamp, username, action, entity_type, entity_id,
|
||
- old_values, new_values, ip_address, user_agent, additional_info
|
||
- FROM audit_log
|
||
- WHERE 1=1
|
||
- """
|
||
- params = []
|
||
-
|
||
- if filter_user:
|
||
- query += " AND username ILIKE %s"
|
||
- params.append(f'%{filter_user}%')
|
||
-
|
||
- if filter_action:
|
||
- query += " AND action = %s"
|
||
- params.append(filter_action)
|
||
-
|
||
- if filter_entity:
|
||
- query += " AND entity_type = %s"
|
||
- params.append(filter_entity)
|
||
-
|
||
- query += " ORDER BY timestamp DESC"
|
||
-
|
||
- cur.execute(query, params)
|
||
- audit_logs = cur.fetchall()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Daten für Export vorbereiten
|
||
- data = []
|
||
- for log in audit_logs:
|
||
- action_text = {
|
||
- 'CREATE': 'Erstellt',
|
||
- 'UPDATE': 'Bearbeitet',
|
||
- 'DELETE': 'Gelöscht',
|
||
- 'LOGIN': 'Anmeldung',
|
||
- 'LOGOUT': 'Abmeldung',
|
||
- 'AUTO_LOGOUT': 'Auto-Logout',
|
||
- 'EXPORT': 'Export',
|
||
- 'GENERATE_KEY': 'Key generiert',
|
||
- 'CREATE_BATCH': 'Batch erstellt',
|
||
- 'BACKUP': 'Backup erstellt',
|
||
- 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung',
|
||
- 'LOGIN_2FA_BACKUP': '2FA-Backup-Code',
|
||
- 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen',
|
||
- 'LOGIN_BLOCKED': 'Login-Blockiert',
|
||
- 'RESTORE': 'Wiederhergestellt',
|
||
- 'PASSWORD_CHANGE': 'Passwort geändert',
|
||
- '2FA_ENABLED': '2FA aktiviert',
|
||
- '2FA_DISABLED': '2FA deaktiviert'
|
||
- }.get(log[3], log[3])
|
||
-
|
||
- data.append({
|
||
- 'ID': log[0],
|
||
- 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'),
|
||
- 'Benutzer': log[2],
|
||
- 'Aktion': action_text,
|
||
- 'Entität': log[4],
|
||
- 'Entität-ID': log[5] or '',
|
||
- 'IP-Adresse': log[8] or '',
|
||
- 'Zusatzinfo': log[10] or ''
|
||
- })
|
||
-
|
||
- # DataFrame erstellen
|
||
- df = pd.DataFrame(data)
|
||
-
|
||
- # Timestamp für Dateiname
|
||
- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
- filename = f'audit_log_export_{timestamp}'
|
||
-
|
||
- # Audit Log für Export
|
||
- log_audit('EXPORT', 'audit_log',
|
||
- additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen")
|
||
-
|
||
- if export_format == 'csv':
|
||
- # CSV Export
|
||
- output = io.StringIO()
|
||
- # UTF-8 BOM für Excel
|
||
- output.write('\ufeff')
|
||
- df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8')),
|
||
- mimetype='text/csv;charset=utf-8',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv'
|
||
- )
|
||
- else:
|
||
- # Excel Export
|
||
- output = BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, index=False, sheet_name='Audit Log')
|
||
-
|
||
- # Spaltenbreiten anpassen
|
||
- worksheet = writer.sheets['Audit Log']
|
||
- for idx, col in enumerate(df.columns):
|
||
- max_length = max(
|
||
- df[col].astype(str).map(len).max(),
|
||
- len(col)
|
||
- ) + 2
|
||
- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx'
|
||
- )
|
||
-
|
||
-@app.route("/export/customers")
|
||
-@login_required
|
||
-def export_customers():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Check if test data should be included
|
||
- include_test = request.args.get('include_test', 'false').lower() == 'true'
|
||
-
|
||
- # Build query based on test data filter
|
||
- if include_test:
|
||
- # Include all customers
|
||
- query = """
|
||
- SELECT c.id, c.name, c.email, c.created_at, c.is_test,
|
||
- COUNT(l.id) as total_licenses,
|
||
- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- GROUP BY c.id, c.name, c.email, c.created_at, c.is_test
|
||
- ORDER BY c.id
|
||
- """
|
||
- else:
|
||
- # Exclude test customers and test licenses
|
||
- query = """
|
||
- SELECT c.id, c.name, c.email, c.created_at, c.is_test,
|
||
- COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses,
|
||
- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses,
|
||
- COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- WHERE c.is_test = FALSE
|
||
- GROUP BY c.id, c.name, c.email, c.created_at, c.is_test
|
||
- ORDER BY c.id
|
||
- """
|
||
-
|
||
- cur.execute(query)
|
||
-
|
||
- # Spaltennamen
|
||
- columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten',
|
||
- 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen']
|
||
-
|
||
- # Daten in DataFrame
|
||
- data = cur.fetchall()
|
||
- df = pd.DataFrame(data, columns=columns)
|
||
-
|
||
- # Datumsformatierung
|
||
- df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M')
|
||
-
|
||
- # Testdaten formatting
|
||
- df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'})
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Export Format
|
||
- export_format = request.args.get('format', 'excel')
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('EXPORT', 'customer',
|
||
- additional_info=f"Export aller Kunden als {export_format.upper()}")
|
||
- filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}'
|
||
-
|
||
- if export_format == 'csv':
|
||
- # CSV Export
|
||
- output = io.StringIO()
|
||
- df.to_csv(output, index=False, encoding='utf-8-sig', sep=';')
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
- mimetype='text/csv',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv'
|
||
- )
|
||
- else:
|
||
- # Excel Export
|
||
- output = io.BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, sheet_name='Kunden', index=False)
|
||
-
|
||
- # Formatierung
|
||
- worksheet = writer.sheets['Kunden']
|
||
- for column in worksheet.columns:
|
||
- max_length = 0
|
||
- column_letter = column[0].column_letter
|
||
- for cell in column:
|
||
- try:
|
||
- if len(str(cell.value)) > max_length:
|
||
- max_length = len(str(cell.value))
|
||
- except:
|
||
- pass
|
||
- adjusted_width = min(max_length + 2, 50)
|
||
- worksheet.column_dimensions[column_letter].width = adjusted_width
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx'
|
||
- )
|
||
-
|
||
-@app.route("/export/sessions")
|
||
-@login_required
|
||
-def export_sessions():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Holen des Session-Typs (active oder ended)
|
||
- session_type = request.args.get('type', 'active')
|
||
- export_format = request.args.get('format', 'excel')
|
||
-
|
||
- # Daten je nach Typ abrufen
|
||
- if session_type == 'active':
|
||
- # Aktive Lizenz-Sessions
|
||
- cur.execute("""
|
||
- SELECT s.id, l.license_key, c.name as customer_name, s.session_id,
|
||
- s.started_at, s.last_heartbeat,
|
||
- EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds,
|
||
- s.ip_address, s.user_agent
|
||
- FROM sessions s
|
||
- JOIN licenses l ON s.license_id = l.id
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE s.is_active = true
|
||
- ORDER BY s.last_heartbeat DESC
|
||
- """)
|
||
- sessions = cur.fetchall()
|
||
-
|
||
- # Daten für Export vorbereiten
|
||
- data = []
|
||
- for sess in sessions:
|
||
- duration = sess[6]
|
||
- hours = duration // 3600
|
||
- minutes = (duration % 3600) // 60
|
||
- seconds = duration % 60
|
||
-
|
||
- data.append({
|
||
- 'Session-ID': sess[0],
|
||
- 'Lizenzschlüssel': sess[1],
|
||
- 'Kunde': sess[2],
|
||
- 'Session-ID (Tech)': sess[3],
|
||
- 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'),
|
||
- 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'),
|
||
- 'Dauer': f"{hours}h {minutes}m {seconds}s",
|
||
- 'IP-Adresse': sess[7],
|
||
- 'Browser': sess[8]
|
||
- })
|
||
-
|
||
- sheet_name = 'Aktive Sessions'
|
||
- filename_prefix = 'aktive_sessions'
|
||
- else:
|
||
- # Beendete Lizenz-Sessions
|
||
- cur.execute("""
|
||
- SELECT s.id, l.license_key, c.name as customer_name, s.session_id,
|
||
- s.started_at, s.ended_at,
|
||
- EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds,
|
||
- s.ip_address, s.user_agent
|
||
- FROM sessions s
|
||
- JOIN licenses l ON s.license_id = l.id
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE s.is_active = false AND s.ended_at IS NOT NULL
|
||
- ORDER BY s.ended_at DESC
|
||
- LIMIT 1000
|
||
- """)
|
||
- sessions = cur.fetchall()
|
||
-
|
||
- # Daten für Export vorbereiten
|
||
- data = []
|
||
- for sess in sessions:
|
||
- duration = sess[6] if sess[6] else 0
|
||
- hours = duration // 3600
|
||
- minutes = (duration % 3600) // 60
|
||
- seconds = duration % 60
|
||
-
|
||
- data.append({
|
||
- 'Session-ID': sess[0],
|
||
- 'Lizenzschlüssel': sess[1],
|
||
- 'Kunde': sess[2],
|
||
- 'Session-ID (Tech)': sess[3],
|
||
- 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'),
|
||
- 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '',
|
||
- 'Dauer': f"{hours}h {minutes}m {seconds}s",
|
||
- 'IP-Adresse': sess[7],
|
||
- 'Browser': sess[8]
|
||
- })
|
||
-
|
||
- sheet_name = 'Beendete Sessions'
|
||
- filename_prefix = 'beendete_sessions'
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # DataFrame erstellen
|
||
- df = pd.DataFrame(data)
|
||
-
|
||
- # Timestamp für Dateiname
|
||
- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
- filename = f'{filename_prefix}_export_{timestamp}'
|
||
-
|
||
- # Audit Log für Export
|
||
- log_audit('EXPORT', 'sessions',
|
||
- additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen")
|
||
-
|
||
- if export_format == 'csv':
|
||
- # CSV Export
|
||
- output = io.StringIO()
|
||
- # UTF-8 BOM für Excel
|
||
- output.write('\ufeff')
|
||
- df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8')),
|
||
- mimetype='text/csv;charset=utf-8',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv'
|
||
- )
|
||
- else:
|
||
- # Excel Export
|
||
- output = BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, index=False, sheet_name=sheet_name)
|
||
-
|
||
- # Spaltenbreiten anpassen
|
||
- worksheet = writer.sheets[sheet_name]
|
||
- for idx, col in enumerate(df.columns):
|
||
- max_length = max(
|
||
- df[col].astype(str).map(len).max(),
|
||
- len(col)
|
||
- ) + 2
|
||
- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx'
|
||
- )
|
||
-
|
||
-@app.route("/export/resources")
|
||
-@login_required
|
||
-def export_resources():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Holen der Filter-Parameter
|
||
- filter_type = request.args.get('type', '')
|
||
- filter_status = request.args.get('status', '')
|
||
- search_query = request.args.get('search', '')
|
||
- show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
- export_format = request.args.get('format', 'excel')
|
||
-
|
||
- # SQL Query mit Filtern
|
||
- query = """
|
||
- SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license,
|
||
- r.created_at, r.status_changed_at,
|
||
- l.license_key, c.name as customer_name, c.email as customer_email,
|
||
- l.license_type
|
||
- FROM resource_pools r
|
||
- LEFT JOIN licenses l ON r.allocated_to_license = l.id
|
||
- LEFT JOIN customers c ON l.customer_id = c.id
|
||
- WHERE 1=1
|
||
- """
|
||
- params = []
|
||
-
|
||
- # Filter für Testdaten
|
||
- if not show_test:
|
||
- query += " AND (r.is_test = false OR r.is_test IS NULL)"
|
||
-
|
||
- # Filter für Ressourcentyp
|
||
- if filter_type:
|
||
- query += " AND r.resource_type = %s"
|
||
- params.append(filter_type)
|
||
-
|
||
- # Filter für Status
|
||
- if filter_status:
|
||
- query += " AND r.status = %s"
|
||
- params.append(filter_status)
|
||
-
|
||
- # Suchfilter
|
||
- if search_query:
|
||
- query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)"
|
||
- params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%'])
|
||
-
|
||
- query += " ORDER BY r.id DESC"
|
||
-
|
||
- cur.execute(query, params)
|
||
- resources = cur.fetchall()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Daten für Export vorbereiten
|
||
- data = []
|
||
- for res in resources:
|
||
- status_text = {
|
||
- 'available': 'Verfügbar',
|
||
- 'allocated': 'Zugewiesen',
|
||
- 'quarantine': 'Quarantäne'
|
||
- }.get(res[3], res[3])
|
||
-
|
||
- type_text = {
|
||
- 'domain': 'Domain',
|
||
- 'ipv4': 'IPv4',
|
||
- 'phone': 'Telefon'
|
||
- }.get(res[1], res[1])
|
||
-
|
||
- data.append({
|
||
- 'ID': res[0],
|
||
- 'Typ': type_text,
|
||
- 'Ressource': res[2],
|
||
- 'Status': status_text,
|
||
- 'Lizenzschlüssel': res[7] or '',
|
||
- 'Kunde': res[8] or '',
|
||
- 'Kunden-Email': res[9] or '',
|
||
- 'Lizenztyp': res[10] or '',
|
||
- 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '',
|
||
- 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else ''
|
||
- })
|
||
-
|
||
- # DataFrame erstellen
|
||
- df = pd.DataFrame(data)
|
||
-
|
||
- # Timestamp für Dateiname
|
||
- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
- filename = f'resources_export_{timestamp}'
|
||
-
|
||
- # Audit Log für Export
|
||
- log_audit('EXPORT', 'resources',
|
||
- additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen")
|
||
-
|
||
- if export_format == 'csv':
|
||
- # CSV Export
|
||
- output = io.StringIO()
|
||
- # UTF-8 BOM für Excel
|
||
- output.write('\ufeff')
|
||
- df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8')),
|
||
- mimetype='text/csv;charset=utf-8',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv'
|
||
- )
|
||
- else:
|
||
- # Excel Export
|
||
- output = BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, index=False, sheet_name='Resources')
|
||
-
|
||
- # Spaltenbreiten anpassen
|
||
- worksheet = writer.sheets['Resources']
|
||
- for idx, col in enumerate(df.columns):
|
||
- max_length = max(
|
||
- df[col].astype(str).map(len).max(),
|
||
- len(col)
|
||
- ) + 2
|
||
- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx'
|
||
- )
|
||
-
|
||
-@app.route("/audit")
|
||
-@login_required
|
||
-def audit_log():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Parameter
|
||
- filter_user = request.args.get('user', '').strip()
|
||
- filter_action = request.args.get('action', '').strip()
|
||
- filter_entity = request.args.get('entity', '').strip()
|
||
- page = request.args.get('page', 1, type=int)
|
||
- sort = request.args.get('sort', 'timestamp')
|
||
- order = request.args.get('order', 'desc')
|
||
- per_page = 50
|
||
-
|
||
- # Whitelist für erlaubte Sortierfelder
|
||
- allowed_sort_fields = {
|
||
- 'timestamp': 'timestamp',
|
||
- 'username': 'username',
|
||
- 'action': 'action',
|
||
- 'entity': 'entity_type',
|
||
- 'ip': 'ip_address'
|
||
- }
|
||
-
|
||
- # Validierung
|
||
- if sort not in allowed_sort_fields:
|
||
- sort = 'timestamp'
|
||
- if order not in ['asc', 'desc']:
|
||
- order = 'desc'
|
||
-
|
||
- sort_field = allowed_sort_fields[sort]
|
||
-
|
||
- # SQL Query mit optionalen Filtern
|
||
- query = """
|
||
- SELECT id, timestamp, username, action, entity_type, entity_id,
|
||
- old_values, new_values, ip_address, user_agent, additional_info
|
||
- FROM audit_log
|
||
- WHERE 1=1
|
||
- """
|
||
-
|
||
- params = []
|
||
-
|
||
- # Filter
|
||
- if filter_user:
|
||
- query += " AND LOWER(username) LIKE LOWER(%s)"
|
||
- params.append(f'%{filter_user}%')
|
||
-
|
||
- if filter_action:
|
||
- query += " AND action = %s"
|
||
- params.append(filter_action)
|
||
-
|
||
- if filter_entity:
|
||
- query += " AND entity_type = %s"
|
||
- params.append(filter_entity)
|
||
-
|
||
- # Gesamtanzahl für Pagination
|
||
- count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table"
|
||
- cur.execute(count_query, params)
|
||
- total = cur.fetchone()[0]
|
||
-
|
||
- # Pagination
|
||
- offset = (page - 1) * per_page
|
||
- query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s"
|
||
- params.extend([per_page, offset])
|
||
-
|
||
- cur.execute(query, params)
|
||
- logs = cur.fetchall()
|
||
-
|
||
- # JSON-Werte parsen
|
||
- parsed_logs = []
|
||
- for log in logs:
|
||
- parsed_log = list(log)
|
||
- # old_values und new_values sind bereits Dictionaries (JSONB)
|
||
- # Keine Konvertierung nötig
|
||
- parsed_logs.append(parsed_log)
|
||
-
|
||
- # Pagination Info
|
||
- total_pages = (total + per_page - 1) // per_page
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template("audit_log.html",
|
||
- logs=parsed_logs,
|
||
- filter_user=filter_user,
|
||
- filter_action=filter_action,
|
||
- filter_entity=filter_entity,
|
||
- page=page,
|
||
- total_pages=total_pages,
|
||
- total=total,
|
||
- sort=sort,
|
||
- order=order,
|
||
- username=session.get('username'))
|
||
-
|
||
-@app.route("/backups")
|
||
-@login_required
|
||
-def backups():
|
||
- """Zeigt die Backup-Historie an"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Letztes erfolgreiches Backup für Dashboard
|
||
- cur.execute("""
|
||
- SELECT created_at, filesize, duration_seconds
|
||
- FROM backup_history
|
||
- WHERE status = 'success'
|
||
- ORDER BY created_at DESC
|
||
- LIMIT 1
|
||
- """)
|
||
- last_backup = cur.fetchone()
|
||
-
|
||
- # Alle Backups abrufen
|
||
- cur.execute("""
|
||
- SELECT id, filename, filesize, backup_type, status, error_message,
|
||
- created_at, created_by, tables_count, records_count,
|
||
- duration_seconds, is_encrypted
|
||
- FROM backup_history
|
||
- ORDER BY created_at DESC
|
||
- """)
|
||
- backups = cur.fetchall()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template("backups.html",
|
||
- backups=backups,
|
||
- last_backup=last_backup,
|
||
- username=session.get('username'))
|
||
-
|
||
-@app.route("/backup/create", methods=["POST"])
|
||
-@login_required
|
||
-def create_backup_route():
|
||
- """Erstellt ein manuelles Backup"""
|
||
- username = session.get('username')
|
||
- success, result = create_backup(backup_type="manual", created_by=username)
|
||
-
|
||
- if success:
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'message': f'Backup erfolgreich erstellt: {result}'
|
||
- })
|
||
- else:
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'message': f'Backup fehlgeschlagen: {result}'
|
||
- }), 500
|
||
-
|
||
-@app.route("/backup/restore/<int:backup_id>", methods=["POST"])
|
||
-@login_required
|
||
-def restore_backup_route(backup_id):
|
||
- """Stellt ein Backup wieder her"""
|
||
- encryption_key = request.form.get('encryption_key')
|
||
-
|
||
- success, message = restore_backup(backup_id, encryption_key)
|
||
-
|
||
- if success:
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'message': message
|
||
- })
|
||
- else:
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'message': message
|
||
- }), 500
|
||
-
|
||
-@app.route("/backup/download/<int:backup_id>")
|
||
-@login_required
|
||
-def download_backup(backup_id):
|
||
- """Lädt eine Backup-Datei herunter"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- cur.execute("""
|
||
- SELECT filename, filepath
|
||
- FROM backup_history
|
||
- WHERE id = %s
|
||
- """, (backup_id,))
|
||
- backup_info = cur.fetchone()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- if not backup_info:
|
||
- return "Backup nicht gefunden", 404
|
||
-
|
||
- filename, filepath = backup_info
|
||
- filepath = Path(filepath)
|
||
-
|
||
- if not filepath.exists():
|
||
- return "Backup-Datei nicht gefunden", 404
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('DOWNLOAD', 'backup', backup_id,
|
||
- additional_info=f"Backup heruntergeladen: {filename}")
|
||
-
|
||
- return send_file(filepath, as_attachment=True, download_name=filename)
|
||
-
|
||
-@app.route("/backup/delete/<int:backup_id>", methods=["DELETE"])
|
||
-@login_required
|
||
-def delete_backup(backup_id):
|
||
- """Löscht ein Backup"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Backup-Informationen abrufen
|
||
- cur.execute("""
|
||
- SELECT filename, filepath
|
||
- FROM backup_history
|
||
- WHERE id = %s
|
||
- """, (backup_id,))
|
||
- backup_info = cur.fetchone()
|
||
-
|
||
- if not backup_info:
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'message': 'Backup nicht gefunden'
|
||
- }), 404
|
||
-
|
||
- filename, filepath = backup_info
|
||
- filepath = Path(filepath)
|
||
-
|
||
- # Datei löschen, wenn sie existiert
|
||
- if filepath.exists():
|
||
- filepath.unlink()
|
||
-
|
||
- # Aus Datenbank löschen
|
||
- cur.execute("""
|
||
- DELETE FROM backup_history
|
||
- WHERE id = %s
|
||
- """, (backup_id,))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('DELETE', 'backup', backup_id,
|
||
- additional_info=f"Backup gelöscht: {filename}")
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'message': f'Backup "{filename}" wurde erfolgreich gelöscht'
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'message': f'Fehler beim Löschen des Backups: {str(e)}'
|
||
- }), 500
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
-@app.route("/security/blocked-ips")
|
||
-@login_required
|
||
-def blocked_ips():
|
||
- """Zeigt alle gesperrten IPs an"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- cur.execute("""
|
||
- SELECT
|
||
- ip_address,
|
||
- attempt_count,
|
||
- first_attempt,
|
||
- last_attempt,
|
||
- blocked_until,
|
||
- last_username_tried,
|
||
- last_error_message
|
||
- FROM login_attempts
|
||
- WHERE blocked_until IS NOT NULL
|
||
- ORDER BY blocked_until DESC
|
||
- """)
|
||
-
|
||
- blocked_ips_list = []
|
||
- for ip in cur.fetchall():
|
||
- blocked_ips_list.append({
|
||
- 'ip_address': ip[0],
|
||
- 'attempt_count': ip[1],
|
||
- 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'),
|
||
- 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'),
|
||
- 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'),
|
||
- 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None),
|
||
- 'last_username': ip[5],
|
||
- 'last_error': ip[6]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template("blocked_ips.html",
|
||
- blocked_ips=blocked_ips_list,
|
||
- username=session.get('username'))
|
||
-
|
||
-@app.route("/security/unblock-ip", methods=["POST"])
|
||
-@login_required
|
||
-def unblock_ip():
|
||
- """Entsperrt eine IP-Adresse"""
|
||
- ip_address = request.form.get('ip_address')
|
||
-
|
||
- if ip_address:
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- cur.execute("""
|
||
- UPDATE login_attempts
|
||
- SET blocked_until = NULL
|
||
- WHERE ip_address = %s
|
||
- """, (ip_address,))
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('UNBLOCK_IP', 'security',
|
||
- additional_info=f"IP {ip_address} manuell entsperrt")
|
||
-
|
||
- return redirect(url_for('blocked_ips'))
|
||
-
|
||
-@app.route("/security/clear-attempts", methods=["POST"])
|
||
-@login_required
|
||
-def clear_attempts():
|
||
- """Löscht alle Login-Versuche für eine IP"""
|
||
- ip_address = request.form.get('ip_address')
|
||
-
|
||
- if ip_address:
|
||
- reset_login_attempts(ip_address)
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('CLEAR_ATTEMPTS', 'security',
|
||
- additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt")
|
||
-
|
||
- return redirect(url_for('blocked_ips'))
|
||
-
|
||
-# API Endpoints for License Management
|
||
-@app.route("/api/license/<int:license_id>/toggle", methods=["POST"])
|
||
-@login_required
|
||
-def toggle_license_api(license_id):
|
||
- """Toggle license active status via API"""
|
||
- try:
|
||
- data = request.get_json()
|
||
- is_active = data.get('is_active', False)
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Update license status
|
||
- cur.execute("""
|
||
- UPDATE licenses
|
||
- SET is_active = %s
|
||
- WHERE id = %s
|
||
- """, (is_active, license_id))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Log the action
|
||
- log_audit('UPDATE', 'license', license_id,
|
||
- new_values={'is_active': is_active},
|
||
- additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle")
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': 'Status erfolgreich geändert'})
|
||
- except Exception as e:
|
||
- return jsonify({'success': False, 'message': str(e)}), 500
|
||
-
|
||
-@app.route("/api/licenses/bulk-activate", methods=["POST"])
|
||
-@login_required
|
||
-def bulk_activate_licenses():
|
||
- """Activate multiple licenses at once"""
|
||
- try:
|
||
- data = request.get_json()
|
||
- license_ids = data.get('ids', [])
|
||
-
|
||
- if not license_ids:
|
||
- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Update all selected licenses (nur Live-Daten)
|
||
- cur.execute("""
|
||
- UPDATE licenses
|
||
- SET is_active = TRUE
|
||
- WHERE id = ANY(%s) AND is_test = FALSE
|
||
- """, (license_ids,))
|
||
-
|
||
- affected_rows = cur.rowcount
|
||
- conn.commit()
|
||
-
|
||
- # Log the bulk action
|
||
- log_audit('BULK_UPDATE', 'licenses', None,
|
||
- new_values={'is_active': True, 'count': affected_rows},
|
||
- additional_info=f"{affected_rows} Lizenzen aktiviert")
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'})
|
||
- except Exception as e:
|
||
- return jsonify({'success': False, 'message': str(e)}), 500
|
||
-
|
||
-@app.route("/api/licenses/bulk-deactivate", methods=["POST"])
|
||
-@login_required
|
||
-def bulk_deactivate_licenses():
|
||
- """Deactivate multiple licenses at once"""
|
||
- try:
|
||
- data = request.get_json()
|
||
- license_ids = data.get('ids', [])
|
||
-
|
||
- if not license_ids:
|
||
- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Update all selected licenses (nur Live-Daten)
|
||
- cur.execute("""
|
||
- UPDATE licenses
|
||
- SET is_active = FALSE
|
||
- WHERE id = ANY(%s) AND is_test = FALSE
|
||
- """, (license_ids,))
|
||
-
|
||
- affected_rows = cur.rowcount
|
||
- conn.commit()
|
||
-
|
||
- # Log the bulk action
|
||
- log_audit('BULK_UPDATE', 'licenses', None,
|
||
- new_values={'is_active': False, 'count': affected_rows},
|
||
- additional_info=f"{affected_rows} Lizenzen deaktiviert")
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'})
|
||
- except Exception as e:
|
||
- return jsonify({'success': False, 'message': str(e)}), 500
|
||
-
|
||
-@app.route("/api/license/<int:license_id>/devices")
|
||
-@login_required
|
||
-def get_license_devices(license_id):
|
||
- """Hole alle registrierten Geräte einer Lizenz"""
|
||
- try:
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Prüfe ob Lizenz existiert und hole device_limit
|
||
- cur.execute("""
|
||
- SELECT device_limit FROM licenses WHERE id = %s
|
||
- """, (license_id,))
|
||
- license_data = cur.fetchone()
|
||
-
|
||
- if not license_data:
|
||
- return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404
|
||
-
|
||
- device_limit = license_data[0]
|
||
-
|
||
- # Hole alle Geräte für diese Lizenz
|
||
- cur.execute("""
|
||
- SELECT id, hardware_id, device_name, operating_system,
|
||
- first_seen, last_seen, is_active, ip_address
|
||
- FROM device_registrations
|
||
- WHERE license_id = %s
|
||
- ORDER BY is_active DESC, last_seen DESC
|
||
- """, (license_id,))
|
||
-
|
||
- devices = []
|
||
- for row in cur.fetchall():
|
||
- devices.append({
|
||
- 'id': row[0],
|
||
- 'hardware_id': row[1],
|
||
- 'device_name': row[2] or 'Unbekanntes Gerät',
|
||
- 'operating_system': row[3] or 'Unbekannt',
|
||
- 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '',
|
||
- 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '',
|
||
- 'is_active': row[6],
|
||
- 'ip_address': row[7] or '-'
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'devices': devices,
|
||
- 'device_limit': device_limit,
|
||
- 'active_count': sum(1 for d in devices if d['is_active'])
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}")
|
||
- return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500
|
||
-
|
||
-@app.route("/api/license/<int:license_id>/register-device", methods=["POST"])
|
||
-def register_device(license_id):
|
||
- """Registriere ein neues Gerät für eine Lizenz"""
|
||
- try:
|
||
- data = request.get_json()
|
||
- hardware_id = data.get('hardware_id')
|
||
- device_name = data.get('device_name', '')
|
||
- operating_system = data.get('operating_system', '')
|
||
-
|
||
- if not hardware_id:
|
||
- return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Prüfe ob Lizenz existiert und aktiv ist
|
||
- cur.execute("""
|
||
- SELECT device_limit, is_active, valid_until
|
||
- FROM licenses
|
||
- WHERE id = %s
|
||
- """, (license_id,))
|
||
- license_data = cur.fetchone()
|
||
-
|
||
- if not license_data:
|
||
- return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404
|
||
-
|
||
- device_limit, is_active, valid_until = license_data
|
||
-
|
||
- # Prüfe ob Lizenz aktiv und gültig ist
|
||
- if not is_active:
|
||
- return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403
|
||
-
|
||
- if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date():
|
||
- return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403
|
||
-
|
||
- # Prüfe ob Gerät bereits registriert ist
|
||
- cur.execute("""
|
||
- SELECT id, is_active FROM device_registrations
|
||
- WHERE license_id = %s AND hardware_id = %s
|
||
- """, (license_id, hardware_id))
|
||
- existing_device = cur.fetchone()
|
||
-
|
||
- if existing_device:
|
||
- device_id, is_device_active = existing_device
|
||
- if is_device_active:
|
||
- # Gerät ist bereits aktiv, update last_seen
|
||
- cur.execute("""
|
||
- UPDATE device_registrations
|
||
- SET last_seen = CURRENT_TIMESTAMP,
|
||
- ip_address = %s,
|
||
- user_agent = %s
|
||
- WHERE id = %s
|
||
- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
- conn.commit()
|
||
- return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id})
|
||
- else:
|
||
- # Gerät war deaktiviert, prüfe ob wir es reaktivieren können
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM device_registrations
|
||
- WHERE license_id = %s AND is_active = TRUE
|
||
- """, (license_id,))
|
||
- active_count = cur.fetchone()[0]
|
||
-
|
||
- if active_count >= device_limit:
|
||
- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
-
|
||
- # Reaktiviere das Gerät
|
||
- cur.execute("""
|
||
- UPDATE device_registrations
|
||
- SET is_active = TRUE,
|
||
- last_seen = CURRENT_TIMESTAMP,
|
||
- deactivated_at = NULL,
|
||
- deactivated_by = NULL,
|
||
- ip_address = %s,
|
||
- user_agent = %s
|
||
- WHERE id = %s
|
||
- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
- conn.commit()
|
||
- return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id})
|
||
-
|
||
- # Neues Gerät - prüfe Gerätelimit
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM device_registrations
|
||
- WHERE license_id = %s AND is_active = TRUE
|
||
- """, (license_id,))
|
||
- active_count = cur.fetchone()[0]
|
||
-
|
||
- if active_count >= device_limit:
|
||
- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
-
|
||
- # Registriere neues Gerät
|
||
- cur.execute("""
|
||
- INSERT INTO device_registrations
|
||
- (license_id, hardware_id, device_name, operating_system, ip_address, user_agent)
|
||
- VALUES (%s, %s, %s, %s, %s, %s)
|
||
- RETURNING id
|
||
- """, (license_id, hardware_id, device_name, operating_system,
|
||
- get_client_ip(), request.headers.get('User-Agent', '')))
|
||
- device_id = cur.fetchone()[0]
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit Log
|
||
- log_audit('DEVICE_REGISTER', 'device', device_id,
|
||
- new_values={'license_id': license_id, 'hardware_id': hardware_id})
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id})
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}")
|
||
- return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500
|
||
-
|
||
-@app.route("/api/license/<int:license_id>/deactivate-device/<int:device_id>", methods=["POST"])
|
||
-@login_required
|
||
-def deactivate_device(license_id, device_id):
|
||
- """Deaktiviere ein registriertes Gerät"""
|
||
- try:
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Prüfe ob das Gerät zu dieser Lizenz gehört
|
||
- cur.execute("""
|
||
- SELECT id FROM device_registrations
|
||
- WHERE id = %s AND license_id = %s AND is_active = TRUE
|
||
- """, (device_id, license_id))
|
||
-
|
||
- if not cur.fetchone():
|
||
- return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404
|
||
-
|
||
- # Deaktiviere das Gerät
|
||
- cur.execute("""
|
||
- UPDATE device_registrations
|
||
- SET is_active = FALSE,
|
||
- deactivated_at = CURRENT_TIMESTAMP,
|
||
- deactivated_by = %s
|
||
- WHERE id = %s
|
||
- """, (session['username'], device_id))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit Log
|
||
- log_audit('DEVICE_DEACTIVATE', 'device', device_id,
|
||
- old_values={'is_active': True},
|
||
- new_values={'is_active': False})
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'})
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}")
|
||
- return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500
|
||
-
|
||
-@app.route("/api/licenses/bulk-delete", methods=["POST"])
|
||
-@login_required
|
||
-def bulk_delete_licenses():
|
||
- """Delete multiple licenses at once"""
|
||
- try:
|
||
- data = request.get_json()
|
||
- license_ids = data.get('ids', [])
|
||
-
|
||
- if not license_ids:
|
||
- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Get license info for audit log (nur Live-Daten)
|
||
- cur.execute("""
|
||
- SELECT license_key
|
||
- FROM licenses
|
||
- WHERE id = ANY(%s) AND is_test = FALSE
|
||
- """, (license_ids,))
|
||
- license_keys = [row[0] for row in cur.fetchall()]
|
||
-
|
||
- # Delete all selected licenses (nur Live-Daten)
|
||
- cur.execute("""
|
||
- DELETE FROM licenses
|
||
- WHERE id = ANY(%s) AND is_test = FALSE
|
||
- """, (license_ids,))
|
||
-
|
||
- affected_rows = cur.rowcount
|
||
- conn.commit()
|
||
-
|
||
- # Log the bulk action
|
||
- log_audit('BULK_DELETE', 'licenses', None,
|
||
- old_values={'license_keys': license_keys, 'count': affected_rows},
|
||
- additional_info=f"{affected_rows} Lizenzen gelöscht")
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'})
|
||
- except Exception as e:
|
||
- return jsonify({'success': False, 'message': str(e)}), 500
|
||
-
|
||
-# ===================== RESOURCE POOL MANAGEMENT =====================
|
||
-
|
||
-@app.route('/resources')
|
||
-@login_required
|
||
-def resources():
|
||
- """Resource Pool Hauptübersicht"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden)
|
||
- show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
-
|
||
- # Statistiken abrufen
|
||
- cur.execute("""
|
||
- SELECT
|
||
- resource_type,
|
||
- COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
- COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
- COUNT(*) as total
|
||
- FROM resource_pools
|
||
- WHERE is_test = %s
|
||
- GROUP BY resource_type
|
||
- """, (show_test,))
|
||
-
|
||
- stats = {}
|
||
- for row in cur.fetchall():
|
||
- stats[row[0]] = {
|
||
- 'available': row[1],
|
||
- 'allocated': row[2],
|
||
- 'quarantine': row[3],
|
||
- 'total': row[4],
|
||
- 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1)
|
||
- }
|
||
-
|
||
- # Letzte Aktivitäten (gefiltert nach Test/Live)
|
||
- cur.execute("""
|
||
- SELECT
|
||
- rh.action,
|
||
- rh.action_by,
|
||
- rh.action_at,
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- rh.details
|
||
- FROM resource_history rh
|
||
- JOIN resource_pools rp ON rh.resource_id = rp.id
|
||
- WHERE rp.is_test = %s
|
||
- ORDER BY rh.action_at DESC
|
||
- LIMIT 10
|
||
- """, (show_test,))
|
||
- recent_activities = cur.fetchall()
|
||
-
|
||
- # Ressourcen-Liste mit Pagination
|
||
- page = request.args.get('page', 1, type=int)
|
||
- per_page = 50
|
||
- offset = (page - 1) * per_page
|
||
-
|
||
- resource_type = request.args.get('type', '')
|
||
- status_filter = request.args.get('status', '')
|
||
- search = request.args.get('search', '')
|
||
-
|
||
- # Sortierung
|
||
- sort_by = request.args.get('sort', 'id')
|
||
- sort_order = request.args.get('order', 'desc')
|
||
-
|
||
- # Base Query
|
||
- query = """
|
||
- SELECT
|
||
- rp.id,
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- rp.status,
|
||
- rp.allocated_to_license,
|
||
- l.license_key,
|
||
- c.name as customer_name,
|
||
- rp.status_changed_at,
|
||
- rp.quarantine_reason,
|
||
- rp.quarantine_until,
|
||
- c.id as customer_id
|
||
- FROM resource_pools rp
|
||
- LEFT JOIN licenses l ON rp.allocated_to_license = l.id
|
||
- LEFT JOIN customers c ON l.customer_id = c.id
|
||
- WHERE rp.is_test = %s
|
||
- """
|
||
- params = [show_test]
|
||
-
|
||
- if resource_type:
|
||
- query += " AND rp.resource_type = %s"
|
||
- params.append(resource_type)
|
||
-
|
||
- if status_filter:
|
||
- query += " AND rp.status = %s"
|
||
- params.append(status_filter)
|
||
-
|
||
- if search:
|
||
- query += " AND rp.resource_value ILIKE %s"
|
||
- params.append(f'%{search}%')
|
||
-
|
||
- # Count total
|
||
- count_query = f"SELECT COUNT(*) FROM ({query}) as cnt"
|
||
- cur.execute(count_query, params)
|
||
- total = cur.fetchone()[0]
|
||
- total_pages = (total + per_page - 1) // per_page
|
||
-
|
||
- # Get paginated results with dynamic sorting
|
||
- sort_column_map = {
|
||
- 'id': 'rp.id',
|
||
- 'type': 'rp.resource_type',
|
||
- 'resource': 'rp.resource_value',
|
||
- 'status': 'rp.status',
|
||
- 'assigned': 'c.name',
|
||
- 'changed': 'rp.status_changed_at'
|
||
- }
|
||
-
|
||
- sort_column = sort_column_map.get(sort_by, 'rp.id')
|
||
- sort_direction = 'ASC' if sort_order == 'asc' else 'DESC'
|
||
-
|
||
- query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s"
|
||
- params.extend([per_page, offset])
|
||
-
|
||
- cur.execute(query, params)
|
||
- resources = cur.fetchall()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template('resources.html',
|
||
- stats=stats,
|
||
- resources=resources,
|
||
- recent_activities=recent_activities,
|
||
- page=page,
|
||
- total_pages=total_pages,
|
||
- total=total,
|
||
- resource_type=resource_type,
|
||
- status_filter=status_filter,
|
||
- search=search,
|
||
- show_test=show_test,
|
||
- sort_by=sort_by,
|
||
- sort_order=sort_order,
|
||
- datetime=datetime,
|
||
- timedelta=timedelta)
|
||
-
|
||
-@app.route('/resources/add', methods=['GET', 'POST'])
|
||
-@login_required
|
||
-def add_resources():
|
||
- """Ressourcen zum Pool hinzufügen"""
|
||
- # Hole show_test Parameter für die Anzeige
|
||
- show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
-
|
||
- if request.method == 'POST':
|
||
- resource_type = request.form.get('resource_type')
|
||
- resources_text = request.form.get('resources_text', '')
|
||
- is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten
|
||
-
|
||
- # Parse resources (one per line)
|
||
- resources = [r.strip() for r in resources_text.split('\n') if r.strip()]
|
||
-
|
||
- if not resources:
|
||
- flash('Keine Ressourcen angegeben', 'error')
|
||
- return redirect(url_for('add_resources', show_test=show_test))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- added = 0
|
||
- duplicates = 0
|
||
-
|
||
- for resource_value in resources:
|
||
- try:
|
||
- cur.execute("""
|
||
- INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test)
|
||
- VALUES (%s, %s, %s, %s)
|
||
- ON CONFLICT (resource_type, resource_value) DO NOTHING
|
||
- """, (resource_type, resource_value, session['username'], is_test))
|
||
-
|
||
- if cur.rowcount > 0:
|
||
- added += 1
|
||
- # Get the inserted ID
|
||
- cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s",
|
||
- (resource_type, resource_value))
|
||
- resource_id = cur.fetchone()[0]
|
||
-
|
||
- # Log in history
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, action, action_by, ip_address)
|
||
- VALUES (%s, 'created', %s, %s)
|
||
- """, (resource_id, session['username'], get_client_ip()))
|
||
- else:
|
||
- duplicates += 1
|
||
-
|
||
- except Exception as e:
|
||
- app.logger.error(f"Error adding resource {resource_value}: {e}")
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- log_audit('CREATE', 'resource_pool', None,
|
||
- new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test},
|
||
- additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen")
|
||
-
|
||
- flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success')
|
||
- return redirect(url_for('resources', show_test=show_test))
|
||
-
|
||
- return render_template('add_resources.html', show_test=show_test)
|
||
-
|
||
-@app.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
|
||
-@login_required
|
||
-def quarantine_resource(resource_id):
|
||
- """Ressource in Quarantäne setzen"""
|
||
- reason = request.form.get('reason', 'review')
|
||
- until_date = request.form.get('until_date')
|
||
- notes = request.form.get('notes', '')
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Get current resource info
|
||
- cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,))
|
||
- resource = cur.fetchone()
|
||
-
|
||
- if not resource:
|
||
- flash('Ressource nicht gefunden', 'error')
|
||
- return redirect(url_for('resources'))
|
||
-
|
||
- old_status = resource[2]
|
||
-
|
||
- # Update resource
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'quarantine',
|
||
- quarantine_reason = %s,
|
||
- quarantine_until = %s,
|
||
- notes = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP,
|
||
- status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (reason, until_date if until_date else None, notes, session['username'], resource_id))
|
||
-
|
||
- # Log in history
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, action, action_by, ip_address, details)
|
||
- VALUES (%s, 'quarantined', %s, %s, %s)
|
||
- """, (resource_id, session['username'], get_client_ip(),
|
||
- Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status})))
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- log_audit('UPDATE', 'resource', resource_id,
|
||
- old_values={'status': old_status},
|
||
- new_values={'status': 'quarantine', 'reason': reason},
|
||
- additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne")
|
||
-
|
||
- flash('Ressource in Quarantäne gesetzt', 'success')
|
||
-
|
||
- # Redirect mit allen aktuellen Filtern
|
||
- return redirect(url_for('resources',
|
||
- show_test=request.args.get('show_test', request.form.get('show_test', 'false')),
|
||
- type=request.args.get('type', request.form.get('type', '')),
|
||
- status=request.args.get('status', request.form.get('status', '')),
|
||
- search=request.args.get('search', request.form.get('search', ''))))
|
||
-
|
||
-@app.route('/resources/release', methods=['POST'])
|
||
-@login_required
|
||
-def release_resources():
|
||
- """Ressourcen aus Quarantäne freigeben"""
|
||
- resource_ids = request.form.getlist('resource_ids')
|
||
-
|
||
- if not resource_ids:
|
||
- flash('Keine Ressourcen ausgewählt', 'error')
|
||
- return redirect(url_for('resources'))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- released = 0
|
||
- for resource_id in resource_ids:
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'available',
|
||
- quarantine_reason = NULL,
|
||
- quarantine_until = NULL,
|
||
- allocated_to_license = NULL,
|
||
- status_changed_at = CURRENT_TIMESTAMP,
|
||
- status_changed_by = %s
|
||
- WHERE id = %s AND status = 'quarantine'
|
||
- """, (session['username'], resource_id))
|
||
-
|
||
- if cur.rowcount > 0:
|
||
- released += 1
|
||
- # Log in history
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, action, action_by, ip_address)
|
||
- VALUES (%s, 'released', %s, %s)
|
||
- """, (resource_id, session['username'], get_client_ip()))
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- log_audit('UPDATE', 'resource_pool', None,
|
||
- new_values={'released': released},
|
||
- additional_info=f"{released} Ressourcen aus Quarantäne freigegeben")
|
||
-
|
||
- flash(f'{released} Ressourcen freigegeben', 'success')
|
||
-
|
||
- # Redirect mit allen aktuellen Filtern
|
||
- return redirect(url_for('resources',
|
||
- show_test=request.args.get('show_test', request.form.get('show_test', 'false')),
|
||
- type=request.args.get('type', request.form.get('type', '')),
|
||
- status=request.args.get('status', request.form.get('status', '')),
|
||
- search=request.args.get('search', request.form.get('search', ''))))
|
||
-
|
||
-@app.route('/api/resources/allocate', methods=['POST'])
|
||
-@login_required
|
||
-def allocate_resources_api():
|
||
- """API für Ressourcen-Zuweisung bei Lizenzerstellung"""
|
||
- data = request.json
|
||
- license_id = data.get('license_id')
|
||
- domain_count = data.get('domain_count', 1)
|
||
- ipv4_count = data.get('ipv4_count', 1)
|
||
- phone_count = data.get('phone_count', 1)
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- allocated = {'domains': [], 'ipv4s': [], 'phones': []}
|
||
-
|
||
- # Allocate domains
|
||
- if domain_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id, resource_value FROM resource_pools
|
||
- WHERE resource_type = 'domain' AND status = 'available'
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (domain_count,))
|
||
- domains = cur.fetchall()
|
||
-
|
||
- if len(domains) < domain_count:
|
||
- raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})")
|
||
-
|
||
- for domain_id, domain_value in domains:
|
||
- # Update resource status
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated',
|
||
- allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP,
|
||
- status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], domain_id))
|
||
-
|
||
- # Create assignment
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, domain_id, session['username']))
|
||
-
|
||
- # Log history
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (domain_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- allocated['domains'].append(domain_value)
|
||
-
|
||
- # Allocate IPv4s (similar logic)
|
||
- if ipv4_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id, resource_value FROM resource_pools
|
||
- WHERE resource_type = 'ipv4' AND status = 'available'
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (ipv4_count,))
|
||
- ipv4s = cur.fetchall()
|
||
-
|
||
- if len(ipv4s) < ipv4_count:
|
||
- raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar")
|
||
-
|
||
- for ipv4_id, ipv4_value in ipv4s:
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated',
|
||
- allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP,
|
||
- status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], ipv4_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, ipv4_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (ipv4_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- allocated['ipv4s'].append(ipv4_value)
|
||
-
|
||
- # Allocate phones (similar logic)
|
||
- if phone_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id, resource_value FROM resource_pools
|
||
- WHERE resource_type = 'phone' AND status = 'available'
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (phone_count,))
|
||
- phones = cur.fetchall()
|
||
-
|
||
- if len(phones) < phone_count:
|
||
- raise ValueError(f"Nicht genügend Telefonnummern verfügbar")
|
||
-
|
||
- for phone_id, phone_value in phones:
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated',
|
||
- allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP,
|
||
- status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], phone_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, phone_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (phone_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- allocated['phones'].append(phone_value)
|
||
-
|
||
- # Update license resource counts
|
||
- cur.execute("""
|
||
- UPDATE licenses
|
||
- SET domain_count = %s,
|
||
- ipv4_count = %s,
|
||
- phone_count = %s
|
||
- WHERE id = %s
|
||
- """, (domain_count, ipv4_count, phone_count, license_id))
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'allocated': allocated
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- cur.close()
|
||
- conn.close()
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'error': str(e)
|
||
- }), 400
|
||
-
|
||
-@app.route('/api/resources/check-availability', methods=['GET'])
|
||
-@login_required
|
||
-def check_resource_availability():
|
||
- """Prüft verfügbare Ressourcen"""
|
||
- resource_type = request.args.get('type', '')
|
||
- count = request.args.get('count', 10, type=int)
|
||
- show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- if resource_type:
|
||
- # Spezifische Ressourcen für einen Typ
|
||
- cur.execute("""
|
||
- SELECT id, resource_value
|
||
- FROM resource_pools
|
||
- WHERE status = 'available'
|
||
- AND resource_type = %s
|
||
- AND is_test = %s
|
||
- ORDER BY resource_value
|
||
- LIMIT %s
|
||
- """, (resource_type, show_test, count))
|
||
-
|
||
- resources = []
|
||
- for row in cur.fetchall():
|
||
- resources.append({
|
||
- 'id': row[0],
|
||
- 'value': row[1]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'available': resources,
|
||
- 'type': resource_type,
|
||
- 'count': len(resources)
|
||
- })
|
||
- else:
|
||
- # Zusammenfassung aller Typen
|
||
- cur.execute("""
|
||
- SELECT
|
||
- resource_type,
|
||
- COUNT(*) as available
|
||
- FROM resource_pools
|
||
- WHERE status = 'available'
|
||
- AND is_test = %s
|
||
- GROUP BY resource_type
|
||
- """, (show_test,))
|
||
-
|
||
- availability = {}
|
||
- for row in cur.fetchall():
|
||
- availability[row[0]] = row[1]
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify(availability)
|
||
-
|
||
-@app.route('/api/global-search', methods=['GET'])
|
||
-@login_required
|
||
-def global_search():
|
||
- """Global search API endpoint for searching customers and licenses"""
|
||
- query = request.args.get('q', '').strip()
|
||
-
|
||
- if not query or len(query) < 2:
|
||
- return jsonify({'customers': [], 'licenses': []})
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Search pattern with wildcards
|
||
- search_pattern = f'%{query}%'
|
||
-
|
||
- # Search customers
|
||
- cur.execute("""
|
||
- SELECT id, name, email, company_name
|
||
- FROM customers
|
||
- WHERE (LOWER(name) LIKE LOWER(%s)
|
||
- OR LOWER(email) LIKE LOWER(%s)
|
||
- OR LOWER(company_name) LIKE LOWER(%s))
|
||
- AND is_test = FALSE
|
||
- ORDER BY name
|
||
- LIMIT 5
|
||
- """, (search_pattern, search_pattern, search_pattern))
|
||
-
|
||
- customers = []
|
||
- for row in cur.fetchall():
|
||
- customers.append({
|
||
- 'id': row[0],
|
||
- 'name': row[1],
|
||
- 'email': row[2],
|
||
- 'company_name': row[3]
|
||
- })
|
||
-
|
||
- # Search licenses
|
||
- cur.execute("""
|
||
- SELECT l.id, l.license_key, c.name as customer_name
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE LOWER(l.license_key) LIKE LOWER(%s)
|
||
- AND l.is_test = FALSE
|
||
- ORDER BY l.created_at DESC
|
||
- LIMIT 5
|
||
- """, (search_pattern,))
|
||
-
|
||
- licenses = []
|
||
- for row in cur.fetchall():
|
||
- licenses.append({
|
||
- 'id': row[0],
|
||
- 'license_key': row[1],
|
||
- 'customer_name': row[2]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'customers': customers,
|
||
- 'licenses': licenses
|
||
- })
|
||
-
|
||
-@app.route('/resources/history/<int:resource_id>')
|
||
-@login_required
|
||
-def resource_history(resource_id):
|
||
- """Zeigt die komplette Historie einer Ressource"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Get complete resource info using named columns
|
||
- cur.execute("""
|
||
- SELECT id, resource_type, resource_value, status, allocated_to_license,
|
||
- status_changed_at, status_changed_by, quarantine_reason,
|
||
- quarantine_until, created_at, notes
|
||
- FROM resource_pools
|
||
- WHERE id = %s
|
||
- """, (resource_id,))
|
||
- row = cur.fetchone()
|
||
-
|
||
- if not row:
|
||
- flash('Ressource nicht gefunden', 'error')
|
||
- return redirect(url_for('resources'))
|
||
-
|
||
- # Create resource object with named attributes
|
||
- resource = {
|
||
- 'id': row[0],
|
||
- 'resource_type': row[1],
|
||
- 'resource_value': row[2],
|
||
- 'status': row[3],
|
||
- 'allocated_to_license': row[4],
|
||
- 'status_changed_at': row[5],
|
||
- 'status_changed_by': row[6],
|
||
- 'quarantine_reason': row[7],
|
||
- 'quarantine_until': row[8],
|
||
- 'created_at': row[9],
|
||
- 'notes': row[10]
|
||
- }
|
||
-
|
||
- # Get license info if allocated
|
||
- license_info = None
|
||
- if resource['allocated_to_license']:
|
||
- cur.execute("SELECT license_key FROM licenses WHERE id = %s",
|
||
- (resource['allocated_to_license'],))
|
||
- lic = cur.fetchone()
|
||
- if lic:
|
||
- license_info = {'license_key': lic[0]}
|
||
-
|
||
- # Get history with named columns
|
||
- cur.execute("""
|
||
- SELECT
|
||
- rh.action,
|
||
- rh.action_by,
|
||
- rh.action_at,
|
||
- rh.details,
|
||
- rh.license_id,
|
||
- rh.ip_address
|
||
- FROM resource_history rh
|
||
- WHERE rh.resource_id = %s
|
||
- ORDER BY rh.action_at DESC
|
||
- """, (resource_id,))
|
||
-
|
||
- history = []
|
||
- for row in cur.fetchall():
|
||
- history.append({
|
||
- 'action': row[0],
|
||
- 'action_by': row[1],
|
||
- 'action_at': row[2],
|
||
- 'details': row[3],
|
||
- 'license_id': row[4],
|
||
- 'ip_address': row[5]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Convert to object-like for template
|
||
- class ResourceObj:
|
||
- def __init__(self, data):
|
||
- for key, value in data.items():
|
||
- setattr(self, key, value)
|
||
-
|
||
- resource_obj = ResourceObj(resource)
|
||
- history_objs = [ResourceObj(h) for h in history]
|
||
-
|
||
- return render_template('resource_history.html',
|
||
- resource=resource_obj,
|
||
- license_info=license_info,
|
||
- history=history_objs)
|
||
-
|
||
-@app.route('/resources/metrics')
|
||
-@login_required
|
||
-def resources_metrics():
|
||
- """Dashboard für Resource Metrics und Reports"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Overall stats with fallback values
|
||
- cur.execute("""
|
||
- SELECT
|
||
- COUNT(DISTINCT resource_id) as total_resources,
|
||
- COALESCE(AVG(performance_score), 0) as avg_performance,
|
||
- COALESCE(SUM(cost), 0) as total_cost,
|
||
- COALESCE(SUM(revenue), 0) as total_revenue,
|
||
- COALESCE(SUM(issues_count), 0) as total_issues
|
||
- FROM resource_metrics
|
||
- WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
- """)
|
||
- row = cur.fetchone()
|
||
-
|
||
- # Calculate ROI
|
||
- roi = 0
|
||
- if row[2] > 0: # if total_cost > 0
|
||
- roi = row[3] / row[2] # revenue / cost
|
||
-
|
||
- stats = {
|
||
- 'total_resources': row[0] or 0,
|
||
- 'avg_performance': row[1] or 0,
|
||
- 'total_cost': row[2] or 0,
|
||
- 'total_revenue': row[3] or 0,
|
||
- 'total_issues': row[4] or 0,
|
||
- 'roi': roi
|
||
- }
|
||
-
|
||
- # Performance by type
|
||
- cur.execute("""
|
||
- SELECT
|
||
- rp.resource_type,
|
||
- COALESCE(AVG(rm.performance_score), 0) as avg_score,
|
||
- COUNT(DISTINCT rp.id) as resource_count
|
||
- FROM resource_pools rp
|
||
- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
- GROUP BY rp.resource_type
|
||
- ORDER BY rp.resource_type
|
||
- """)
|
||
- performance_by_type = cur.fetchall()
|
||
-
|
||
- # Utilization data
|
||
- cur.execute("""
|
||
- SELECT
|
||
- resource_type,
|
||
- COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
- COUNT(*) as total,
|
||
- ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent
|
||
- FROM resource_pools
|
||
- GROUP BY resource_type
|
||
- """)
|
||
- utilization_rows = cur.fetchall()
|
||
- utilization_data = [
|
||
- {
|
||
- 'type': row[0].upper(),
|
||
- 'allocated': row[1],
|
||
- 'total': row[2],
|
||
- 'allocated_percent': row[3]
|
||
- }
|
||
- for row in utilization_rows
|
||
- ]
|
||
-
|
||
- # Top performing resources
|
||
- cur.execute("""
|
||
- SELECT
|
||
- rp.id,
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- COALESCE(AVG(rm.performance_score), 0) as avg_score,
|
||
- COALESCE(SUM(rm.revenue), 0) as total_revenue,
|
||
- COALESCE(SUM(rm.cost), 1) as total_cost,
|
||
- CASE
|
||
- WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0
|
||
- ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1)
|
||
- END as roi
|
||
- FROM resource_pools rp
|
||
- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
- WHERE rp.status != 'quarantine'
|
||
- GROUP BY rp.id, rp.resource_type, rp.resource_value
|
||
- HAVING AVG(rm.performance_score) IS NOT NULL
|
||
- ORDER BY avg_score DESC
|
||
- LIMIT 10
|
||
- """)
|
||
- top_rows = cur.fetchall()
|
||
- top_performers = [
|
||
- {
|
||
- 'id': row[0],
|
||
- 'resource_type': row[1],
|
||
- 'resource_value': row[2],
|
||
- 'avg_score': row[3],
|
||
- 'roi': row[6]
|
||
- }
|
||
- for row in top_rows
|
||
- ]
|
||
-
|
||
- # Resources with issues
|
||
- cur.execute("""
|
||
- SELECT
|
||
- rp.id,
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- rp.status,
|
||
- COALESCE(SUM(rm.issues_count), 0) as total_issues
|
||
- FROM resource_pools rp
|
||
- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
- WHERE rm.issues_count > 0 OR rp.status = 'quarantine'
|
||
- GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status
|
||
- HAVING SUM(rm.issues_count) > 0
|
||
- ORDER BY total_issues DESC
|
||
- LIMIT 10
|
||
- """)
|
||
- problem_rows = cur.fetchall()
|
||
- problem_resources = [
|
||
- {
|
||
- 'id': row[0],
|
||
- 'resource_type': row[1],
|
||
- 'resource_value': row[2],
|
||
- 'status': row[3],
|
||
- 'total_issues': row[4]
|
||
- }
|
||
- for row in problem_rows
|
||
- ]
|
||
-
|
||
- # Daily metrics for trend chart (last 30 days)
|
||
- cur.execute("""
|
||
- SELECT
|
||
- metric_date,
|
||
- COALESCE(AVG(performance_score), 0) as avg_performance,
|
||
- COALESCE(SUM(issues_count), 0) as total_issues
|
||
- FROM resource_metrics
|
||
- WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
- GROUP BY metric_date
|
||
- ORDER BY metric_date
|
||
- """)
|
||
- daily_rows = cur.fetchall()
|
||
- daily_metrics = [
|
||
- {
|
||
- 'date': row[0].strftime('%d.%m'),
|
||
- 'performance': float(row[1]),
|
||
- 'issues': int(row[2])
|
||
- }
|
||
- for row in daily_rows
|
||
- ]
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template('resource_metrics.html',
|
||
- stats=stats,
|
||
- performance_by_type=performance_by_type,
|
||
- utilization_data=utilization_data,
|
||
- top_performers=top_performers,
|
||
- problem_resources=problem_resources,
|
||
- daily_metrics=daily_metrics)
|
||
-
|
||
-@app.route('/resources/report', methods=['GET'])
|
||
-@login_required
|
||
-def resources_report():
|
||
- """Generiert Ressourcen-Reports oder zeigt Report-Formular"""
|
||
- # Prüfe ob Download angefordert wurde
|
||
- if request.args.get('download') == 'true':
|
||
- report_type = request.args.get('type', 'usage')
|
||
- format_type = request.args.get('format', 'excel')
|
||
- date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d'))
|
||
- date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d'))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- if report_type == 'usage':
|
||
- # Auslastungsreport
|
||
- query = """
|
||
- SELECT
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- rp.status,
|
||
- COUNT(DISTINCT rh.license_id) as unique_licenses,
|
||
- COUNT(rh.id) as total_allocations,
|
||
- MIN(rh.action_at) as first_used,
|
||
- MAX(rh.action_at) as last_used
|
||
- FROM resource_pools rp
|
||
- LEFT JOIN resource_history rh ON rp.id = rh.resource_id
|
||
- AND rh.action = 'allocated'
|
||
- AND rh.action_at BETWEEN %s AND %s
|
||
- GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status
|
||
- ORDER BY rp.resource_type, total_allocations DESC
|
||
- """
|
||
- cur.execute(query, (date_from, date_to))
|
||
- columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung']
|
||
-
|
||
- elif report_type == 'performance':
|
||
- # Performance-Report
|
||
- query = """
|
||
- SELECT
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- AVG(rm.performance_score) as avg_performance,
|
||
- SUM(rm.usage_count) as total_usage,
|
||
- SUM(rm.revenue) as total_revenue,
|
||
- SUM(rm.cost) as total_cost,
|
||
- SUM(rm.revenue - rm.cost) as profit,
|
||
- SUM(rm.issues_count) as total_issues
|
||
- FROM resource_pools rp
|
||
- JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
- WHERE rm.metric_date BETWEEN %s AND %s
|
||
- GROUP BY rp.id, rp.resource_type, rp.resource_value
|
||
- ORDER BY profit DESC
|
||
- """
|
||
- cur.execute(query, (date_from, date_to))
|
||
- columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues']
|
||
-
|
||
- elif report_type == 'compliance':
|
||
- # Compliance-Report
|
||
- query = """
|
||
- SELECT
|
||
- rh.action_at,
|
||
- rh.action,
|
||
- rh.action_by,
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- l.license_key,
|
||
- c.name as customer_name,
|
||
- rh.ip_address
|
||
- FROM resource_history rh
|
||
- JOIN resource_pools rp ON rh.resource_id = rp.id
|
||
- LEFT JOIN licenses l ON rh.license_id = l.id
|
||
- LEFT JOIN customers c ON l.customer_id = c.id
|
||
- WHERE rh.action_at BETWEEN %s AND %s
|
||
- ORDER BY rh.action_at DESC
|
||
- """
|
||
- cur.execute(query, (date_from, date_to))
|
||
- columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse']
|
||
-
|
||
- else: # inventory report
|
||
- # Inventar-Report
|
||
- query = """
|
||
- SELECT
|
||
- resource_type,
|
||
- COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
- COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
- COUNT(*) as total
|
||
- FROM resource_pools
|
||
- GROUP BY resource_type
|
||
- ORDER BY resource_type
|
||
- """
|
||
- cur.execute(query)
|
||
- columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt']
|
||
-
|
||
- # Convert to DataFrame
|
||
- data = cur.fetchall()
|
||
- df = pd.DataFrame(data, columns=columns)
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Generate file
|
||
- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
- filename = f"resource_report_{report_type}_{timestamp}"
|
||
-
|
||
- if format_type == 'excel':
|
||
- output = io.BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, sheet_name='Report', index=False)
|
||
-
|
||
- # Auto-adjust columns width
|
||
- worksheet = writer.sheets['Report']
|
||
- for column in worksheet.columns:
|
||
- max_length = 0
|
||
- column = [cell for cell in column]
|
||
- for cell in column:
|
||
- try:
|
||
- if len(str(cell.value)) > max_length:
|
||
- max_length = len(str(cell.value))
|
||
- except:
|
||
- pass
|
||
- adjusted_width = (max_length + 2)
|
||
- worksheet.column_dimensions[column[0].column_letter].width = adjusted_width
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- log_audit('EXPORT', 'resource_report', None,
|
||
- new_values={'type': report_type, 'format': 'excel', 'rows': len(df)},
|
||
- additional_info=f"Resource Report {report_type} exportiert")
|
||
-
|
||
- return send_file(output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx')
|
||
-
|
||
- else: # CSV
|
||
- output = io.StringIO()
|
||
- df.to_csv(output, index=False, sep=';', encoding='utf-8-sig')
|
||
- output.seek(0)
|
||
-
|
||
- log_audit('EXPORT', 'resource_report', None,
|
||
- new_values={'type': report_type, 'format': 'csv', 'rows': len(df)},
|
||
- additional_info=f"Resource Report {report_type} exportiert")
|
||
-
|
||
- return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
- mimetype='text/csv',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv')
|
||
-
|
||
- # Wenn kein Download, zeige Report-Formular
|
||
- return render_template('resource_report.html',
|
||
- datetime=datetime,
|
||
- timedelta=timedelta,
|
||
- username=session.get('username'))
|
||
-
|
||
-if __name__ == "__main__":
|
||
- app.run(host="0.0.0.0", port=5000)
|
||
+import os
|
||
+import psycopg2
|
||
+from psycopg2.extras import Json
|
||
+from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash
|
||
+from flask_session import Session
|
||
+from functools import wraps
|
||
+from dotenv import load_dotenv
|
||
+import pandas as pd
|
||
+from datetime import datetime, timedelta
|
||
+from zoneinfo import ZoneInfo
|
||
+import io
|
||
+import subprocess
|
||
+import gzip
|
||
+from cryptography.fernet import Fernet
|
||
+from pathlib import Path
|
||
+import time
|
||
+from apscheduler.schedulers.background import BackgroundScheduler
|
||
+import logging
|
||
+import random
|
||
+import hashlib
|
||
+import requests
|
||
+import secrets
|
||
+import string
|
||
+import re
|
||
+import bcrypt
|
||
+import pyotp
|
||
+import qrcode
|
||
+from io import BytesIO
|
||
+import base64
|
||
+import json
|
||
+from werkzeug.middleware.proxy_fix import ProxyFix
|
||
+from openpyxl.utils import get_column_letter
|
||
+
|
||
+load_dotenv()
|
||
+
|
||
+app = Flask(__name__)
|
||
+app.config['SECRET_KEY'] = os.urandom(24)
|
||
+app.config['SESSION_TYPE'] = 'filesystem'
|
||
+app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8
|
||
+app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8'
|
||
+app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=5) # 5 Minuten Session-Timeout
|
||
+app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||
+app.config['SESSION_COOKIE_SECURE'] = False # Wird auf True gesetzt wenn HTTPS (intern läuft HTTP)
|
||
+app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||
+app.config['SESSION_COOKIE_NAME'] = 'admin_session'
|
||
+# WICHTIG: Session-Cookie soll auch nach 5 Minuten ablaufen
|
||
+app.config['SESSION_REFRESH_EACH_REQUEST'] = False
|
||
+Session(app)
|
||
+
|
||
+# ProxyFix für korrekte IP-Adressen hinter Nginx
|
||
+app.wsgi_app = ProxyFix(
|
||
+ app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
|
||
+)
|
||
+
|
||
+# Backup-Konfiguration
|
||
+BACKUP_DIR = Path("/app/backups")
|
||
+BACKUP_DIR.mkdir(exist_ok=True)
|
||
+
|
||
+# Rate-Limiting Konfiguration
|
||
+FAIL_MESSAGES = [
|
||
+ "NOPE!",
|
||
+ "ACCESS DENIED, TRY HARDER",
|
||
+ "WRONG! 🚫",
|
||
+ "COMPUTER SAYS NO",
|
||
+ "YOU FAILED"
|
||
+]
|
||
+
|
||
+MAX_LOGIN_ATTEMPTS = 5
|
||
+BLOCK_DURATION_HOURS = 24
|
||
+CAPTCHA_AFTER_ATTEMPTS = 2
|
||
+
|
||
+# Scheduler für automatische Backups
|
||
+scheduler = BackgroundScheduler()
|
||
+scheduler.start()
|
||
+
|
||
+# Logging konfigurieren
|
||
+logging.basicConfig(level=logging.INFO)
|
||
+
|
||
+
|
||
+# Login decorator
|
||
+def login_required(f):
|
||
+ @wraps(f)
|
||
+ def decorated_function(*args, **kwargs):
|
||
+ if 'logged_in' not in session:
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+ # Prüfe ob Session abgelaufen ist
|
||
+ if 'last_activity' in session:
|
||
+ last_activity = datetime.fromisoformat(session['last_activity'])
|
||
+ time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity
|
||
+
|
||
+ # Debug-Logging
|
||
+ app.logger.info(f"Session check for {session.get('username', 'unknown')}: "
|
||
+ f"Last activity: {last_activity}, "
|
||
+ f"Time since: {time_since_activity.total_seconds()} seconds")
|
||
+
|
||
+ if time_since_activity > timedelta(minutes=5):
|
||
+ # Session abgelaufen - Logout
|
||
+ username = session.get('username', 'unbekannt')
|
||
+ app.logger.info(f"Session timeout for user {username} - auto logout")
|
||
+ # Audit-Log für automatischen Logout (vor session.clear()!)
|
||
+ try:
|
||
+ log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username})
|
||
+ except:
|
||
+ pass
|
||
+ session.clear()
|
||
+ flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning')
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+ # Aktivität NICHT automatisch aktualisieren
|
||
+ # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht)
|
||
+ return f(*args, **kwargs)
|
||
+ return decorated_function
|
||
+
|
||
+# DB-Verbindung mit UTF-8 Encoding
|
||
+def get_connection():
|
||
+ conn = psycopg2.connect(
|
||
+ host=os.getenv("POSTGRES_HOST", "postgres"),
|
||
+ port=os.getenv("POSTGRES_PORT", "5432"),
|
||
+ dbname=os.getenv("POSTGRES_DB"),
|
||
+ user=os.getenv("POSTGRES_USER"),
|
||
+ password=os.getenv("POSTGRES_PASSWORD"),
|
||
+ options='-c client_encoding=UTF8'
|
||
+ )
|
||
+ conn.set_client_encoding('UTF8')
|
||
+ return conn
|
||
+
|
||
+# User Authentication Helper Functions
|
||
+def hash_password(password):
|
||
+ """Hash a password using bcrypt"""
|
||
+ return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||
+
|
||
+def verify_password(password, hashed):
|
||
+ """Verify a password against its hash"""
|
||
+ return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
||
+
|
||
+def get_user_by_username(username):
|
||
+ """Get user from database by username"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ try:
|
||
+ cur.execute("""
|
||
+ SELECT id, username, password_hash, email, totp_secret, totp_enabled,
|
||
+ backup_codes, last_password_change, failed_2fa_attempts
|
||
+ FROM users WHERE username = %s
|
||
+ """, (username,))
|
||
+ user = cur.fetchone()
|
||
+ if user:
|
||
+ return {
|
||
+ 'id': user[0],
|
||
+ 'username': user[1],
|
||
+ 'password_hash': user[2],
|
||
+ 'email': user[3],
|
||
+ 'totp_secret': user[4],
|
||
+ 'totp_enabled': user[5],
|
||
+ 'backup_codes': user[6],
|
||
+ 'last_password_change': user[7],
|
||
+ 'failed_2fa_attempts': user[8]
|
||
+ }
|
||
+ return None
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+def generate_totp_secret():
|
||
+ """Generate a new TOTP secret"""
|
||
+ return pyotp.random_base32()
|
||
+
|
||
+def generate_qr_code(username, totp_secret):
|
||
+ """Generate QR code for TOTP setup"""
|
||
+ totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
|
||
+ name=username,
|
||
+ issuer_name='V2 Admin Panel'
|
||
+ )
|
||
+
|
||
+ qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||
+ qr.add_data(totp_uri)
|
||
+ qr.make(fit=True)
|
||
+
|
||
+ img = qr.make_image(fill_color="black", back_color="white")
|
||
+ buf = BytesIO()
|
||
+ img.save(buf, format='PNG')
|
||
+ buf.seek(0)
|
||
+
|
||
+ return base64.b64encode(buf.getvalue()).decode()
|
||
+
|
||
+def verify_totp(totp_secret, token):
|
||
+ """Verify a TOTP token"""
|
||
+ totp = pyotp.TOTP(totp_secret)
|
||
+ return totp.verify(token, valid_window=1)
|
||
+
|
||
+def generate_backup_codes(count=8):
|
||
+ """Generate backup codes for 2FA recovery"""
|
||
+ codes = []
|
||
+ for _ in range(count):
|
||
+ code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||
+ codes.append(code)
|
||
+ return codes
|
||
+
|
||
+def hash_backup_code(code):
|
||
+ """Hash a backup code for storage"""
|
||
+ return hashlib.sha256(code.encode()).hexdigest()
|
||
+
|
||
+def verify_backup_code(code, hashed_codes):
|
||
+ """Verify a backup code against stored hashes"""
|
||
+ code_hash = hashlib.sha256(code.encode()).hexdigest()
|
||
+ return code_hash in hashed_codes
|
||
+
|
||
+# Audit-Log-Funktion
|
||
+def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None):
|
||
+ """Protokolliert Änderungen im Audit-Log"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ username = session.get('username', 'system')
|
||
+ ip_address = get_client_ip() if request else None
|
||
+ user_agent = request.headers.get('User-Agent') if request else None
|
||
+
|
||
+ # Debug logging
|
||
+ app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}")
|
||
+
|
||
+ # Konvertiere Dictionaries zu JSONB
|
||
+ old_json = Json(old_values) if old_values else None
|
||
+ new_json = Json(new_values) if new_values else None
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO audit_log
|
||
+ (username, action, entity_type, entity_id, old_values, new_values,
|
||
+ ip_address, user_agent, additional_info)
|
||
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
+ """, (username, action, entity_type, entity_id, old_json, new_json,
|
||
+ ip_address, user_agent, additional_info))
|
||
+
|
||
+ conn.commit()
|
||
+ except Exception as e:
|
||
+ print(f"Audit log error: {e}")
|
||
+ conn.rollback()
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+# Verschlüsselungs-Funktionen
|
||
+def get_or_create_encryption_key():
|
||
+ """Holt oder erstellt einen Verschlüsselungsschlüssel"""
|
||
+ key_file = BACKUP_DIR / ".backup_key"
|
||
+
|
||
+ # Versuche Key aus Umgebungsvariable zu lesen
|
||
+ env_key = os.getenv("BACKUP_ENCRYPTION_KEY")
|
||
+ if env_key:
|
||
+ try:
|
||
+ # Validiere den Key
|
||
+ Fernet(env_key.encode())
|
||
+ return env_key.encode()
|
||
+ except:
|
||
+ pass
|
||
+
|
||
+ # Wenn kein gültiger Key in ENV, prüfe Datei
|
||
+ if key_file.exists():
|
||
+ return key_file.read_bytes()
|
||
+
|
||
+ # Erstelle neuen Key
|
||
+ key = Fernet.generate_key()
|
||
+ key_file.write_bytes(key)
|
||
+ logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt")
|
||
+ return key
|
||
+
|
||
+# Backup-Funktionen
|
||
+def create_backup(backup_type="manual", created_by=None):
|
||
+ """Erstellt ein verschlüsseltes Backup der Datenbank"""
|
||
+ start_time = time.time()
|
||
+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")
|
||
+ filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc"
|
||
+ filepath = BACKUP_DIR / filename
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Backup-Eintrag erstellen
|
||
+ cur.execute("""
|
||
+ INSERT INTO backup_history
|
||
+ (filename, filepath, backup_type, status, created_by, is_encrypted)
|
||
+ VALUES (%s, %s, %s, %s, %s, %s)
|
||
+ RETURNING id
|
||
+ """, (filename, str(filepath), backup_type, 'in_progress',
|
||
+ created_by or 'system', True))
|
||
+ backup_id = cur.fetchone()[0]
|
||
+ conn.commit()
|
||
+
|
||
+ try:
|
||
+ # PostgreSQL Dump erstellen
|
||
+ dump_command = [
|
||
+ 'pg_dump',
|
||
+ '-h', os.getenv("POSTGRES_HOST", "postgres"),
|
||
+ '-p', os.getenv("POSTGRES_PORT", "5432"),
|
||
+ '-U', os.getenv("POSTGRES_USER"),
|
||
+ '-d', os.getenv("POSTGRES_DB"),
|
||
+ '--no-password',
|
||
+ '--verbose'
|
||
+ ]
|
||
+
|
||
+ # PGPASSWORD setzen
|
||
+ env = os.environ.copy()
|
||
+ env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
|
||
+
|
||
+ # Dump ausführen
|
||
+ result = subprocess.run(dump_command, capture_output=True, text=True, env=env)
|
||
+
|
||
+ if result.returncode != 0:
|
||
+ raise Exception(f"pg_dump failed: {result.stderr}")
|
||
+
|
||
+ dump_data = result.stdout.encode('utf-8')
|
||
+
|
||
+ # Komprimieren
|
||
+ compressed_data = gzip.compress(dump_data)
|
||
+
|
||
+ # Verschlüsseln
|
||
+ key = get_or_create_encryption_key()
|
||
+ f = Fernet(key)
|
||
+ encrypted_data = f.encrypt(compressed_data)
|
||
+
|
||
+ # Speichern
|
||
+ filepath.write_bytes(encrypted_data)
|
||
+
|
||
+ # Statistiken sammeln
|
||
+ cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'")
|
||
+ tables_count = cur.fetchone()[0]
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT SUM(n_live_tup)
|
||
+ FROM pg_stat_user_tables
|
||
+ """)
|
||
+ records_count = cur.fetchone()[0] or 0
|
||
+
|
||
+ duration = time.time() - start_time
|
||
+ filesize = filepath.stat().st_size
|
||
+
|
||
+ # Backup-Eintrag aktualisieren
|
||
+ cur.execute("""
|
||
+ UPDATE backup_history
|
||
+ SET status = %s, filesize = %s, tables_count = %s,
|
||
+ records_count = %s, duration_seconds = %s
|
||
+ WHERE id = %s
|
||
+ """, ('success', filesize, tables_count, records_count, duration, backup_id))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('BACKUP', 'database', backup_id,
|
||
+ additional_info=f"Backup erstellt: {filename} ({filesize} bytes)")
|
||
+
|
||
+ # E-Mail-Benachrichtigung (wenn konfiguriert)
|
||
+ send_backup_notification(True, filename, filesize, duration)
|
||
+
|
||
+ logging.info(f"Backup erfolgreich erstellt: {filename}")
|
||
+ return True, filename
|
||
+
|
||
+ except Exception as e:
|
||
+ # Fehler protokollieren
|
||
+ cur.execute("""
|
||
+ UPDATE backup_history
|
||
+ SET status = %s, error_message = %s, duration_seconds = %s
|
||
+ WHERE id = %s
|
||
+ """, ('failed', str(e), time.time() - start_time, backup_id))
|
||
+ conn.commit()
|
||
+
|
||
+ logging.error(f"Backup fehlgeschlagen: {e}")
|
||
+ send_backup_notification(False, filename, error=str(e))
|
||
+
|
||
+ return False, str(e)
|
||
+
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+def restore_backup(backup_id, encryption_key=None):
|
||
+ """Stellt ein Backup wieder her"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Backup-Info abrufen
|
||
+ cur.execute("""
|
||
+ SELECT filename, filepath, is_encrypted
|
||
+ FROM backup_history
|
||
+ WHERE id = %s
|
||
+ """, (backup_id,))
|
||
+ backup_info = cur.fetchone()
|
||
+
|
||
+ if not backup_info:
|
||
+ raise Exception("Backup nicht gefunden")
|
||
+
|
||
+ filename, filepath, is_encrypted = backup_info
|
||
+ filepath = Path(filepath)
|
||
+
|
||
+ if not filepath.exists():
|
||
+ raise Exception("Backup-Datei nicht gefunden")
|
||
+
|
||
+ # Datei lesen
|
||
+ encrypted_data = filepath.read_bytes()
|
||
+
|
||
+ # Entschlüsseln
|
||
+ if is_encrypted:
|
||
+ key = encryption_key.encode() if encryption_key else get_or_create_encryption_key()
|
||
+ try:
|
||
+ f = Fernet(key)
|
||
+ compressed_data = f.decrypt(encrypted_data)
|
||
+ except:
|
||
+ raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?")
|
||
+ else:
|
||
+ compressed_data = encrypted_data
|
||
+
|
||
+ # Dekomprimieren
|
||
+ dump_data = gzip.decompress(compressed_data)
|
||
+ sql_commands = dump_data.decode('utf-8')
|
||
+
|
||
+ # Bestehende Verbindungen schließen
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Datenbank wiederherstellen
|
||
+ restore_command = [
|
||
+ 'psql',
|
||
+ '-h', os.getenv("POSTGRES_HOST", "postgres"),
|
||
+ '-p', os.getenv("POSTGRES_PORT", "5432"),
|
||
+ '-U', os.getenv("POSTGRES_USER"),
|
||
+ '-d', os.getenv("POSTGRES_DB"),
|
||
+ '--no-password'
|
||
+ ]
|
||
+
|
||
+ env = os.environ.copy()
|
||
+ env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
|
||
+
|
||
+ result = subprocess.run(restore_command, input=sql_commands,
|
||
+ capture_output=True, text=True, env=env)
|
||
+
|
||
+ if result.returncode != 0:
|
||
+ raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}")
|
||
+
|
||
+ # Audit-Log (neue Verbindung)
|
||
+ log_audit('RESTORE', 'database', backup_id,
|
||
+ additional_info=f"Backup wiederhergestellt: {filename}")
|
||
+
|
||
+ return True, "Backup erfolgreich wiederhergestellt"
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Wiederherstellung fehlgeschlagen: {e}")
|
||
+ return False, str(e)
|
||
+
|
||
+def send_backup_notification(success, filename, filesize=None, duration=None, error=None):
|
||
+ """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)"""
|
||
+ if not os.getenv("EMAIL_ENABLED", "false").lower() == "true":
|
||
+ return
|
||
+
|
||
+ # E-Mail-Funktion vorbereitet aber deaktiviert
|
||
+ # TODO: Implementieren wenn E-Mail-Server konfiguriert ist
|
||
+ logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}")
|
||
+
|
||
+# Scheduled Backup Job
|
||
+def scheduled_backup():
|
||
+ """Führt ein geplantes Backup aus"""
|
||
+ logging.info("Starte geplantes Backup...")
|
||
+ create_backup(backup_type="scheduled", created_by="scheduler")
|
||
+
|
||
+# Scheduler konfigurieren - täglich um 3:00 Uhr
|
||
+scheduler.add_job(
|
||
+ scheduled_backup,
|
||
+ 'cron',
|
||
+ hour=3,
|
||
+ minute=0,
|
||
+ id='daily_backup',
|
||
+ replace_existing=True
|
||
+)
|
||
+
|
||
+# Rate-Limiting Funktionen
|
||
+def get_client_ip():
|
||
+ """Ermittelt die echte IP-Adresse des Clients"""
|
||
+ # Debug logging
|
||
+ app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}")
|
||
+
|
||
+ # Try X-Real-IP first (set by nginx)
|
||
+ if request.headers.get('X-Real-IP'):
|
||
+ return request.headers.get('X-Real-IP')
|
||
+ # Then X-Forwarded-For
|
||
+ elif request.headers.get('X-Forwarded-For'):
|
||
+ # X-Forwarded-For can contain multiple IPs, take the first one
|
||
+ return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||
+ # Fallback to remote_addr
|
||
+ else:
|
||
+ return request.remote_addr
|
||
+
|
||
+def check_ip_blocked(ip_address):
|
||
+ """Prüft ob eine IP-Adresse gesperrt ist"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT blocked_until FROM login_attempts
|
||
+ WHERE ip_address = %s AND blocked_until IS NOT NULL
|
||
+ """, (ip_address,))
|
||
+
|
||
+ result = cur.fetchone()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ if result and result[0]:
|
||
+ if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None):
|
||
+ return True, result[0]
|
||
+ return False, None
|
||
+
|
||
+def record_failed_attempt(ip_address, username):
|
||
+ """Zeichnet einen fehlgeschlagenen Login-Versuch auf"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Random Fehlermeldung
|
||
+ error_message = random.choice(FAIL_MESSAGES)
|
||
+
|
||
+ try:
|
||
+ # Prüfen ob IP bereits existiert
|
||
+ cur.execute("""
|
||
+ SELECT attempt_count FROM login_attempts
|
||
+ WHERE ip_address = %s
|
||
+ """, (ip_address,))
|
||
+
|
||
+ result = cur.fetchone()
|
||
+
|
||
+ if result:
|
||
+ # Update bestehenden Eintrag
|
||
+ new_count = result[0] + 1
|
||
+ blocked_until = None
|
||
+
|
||
+ if new_count >= MAX_LOGIN_ATTEMPTS:
|
||
+ blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS)
|
||
+ # E-Mail-Benachrichtigung (wenn aktiviert)
|
||
+ if os.getenv("EMAIL_ENABLED", "false").lower() == "true":
|
||
+ send_security_alert_email(ip_address, username, new_count)
|
||
+
|
||
+ cur.execute("""
|
||
+ UPDATE login_attempts
|
||
+ SET attempt_count = %s,
|
||
+ last_attempt = CURRENT_TIMESTAMP,
|
||
+ blocked_until = %s,
|
||
+ last_username_tried = %s,
|
||
+ last_error_message = %s
|
||
+ WHERE ip_address = %s
|
||
+ """, (new_count, blocked_until, username, error_message, ip_address))
|
||
+ else:
|
||
+ # Neuen Eintrag erstellen
|
||
+ cur.execute("""
|
||
+ INSERT INTO login_attempts
|
||
+ (ip_address, attempt_count, last_username_tried, last_error_message)
|
||
+ VALUES (%s, 1, %s, %s)
|
||
+ """, (ip_address, username, error_message))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('LOGIN_FAILED', 'user',
|
||
+ additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}")
|
||
+
|
||
+ except Exception as e:
|
||
+ print(f"Rate limiting error: {e}")
|
||
+ conn.rollback()
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return error_message
|
||
+
|
||
+def reset_login_attempts(ip_address):
|
||
+ """Setzt die Login-Versuche für eine IP zurück"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ cur.execute("""
|
||
+ DELETE FROM login_attempts
|
||
+ WHERE ip_address = %s
|
||
+ """, (ip_address,))
|
||
+ conn.commit()
|
||
+ except Exception as e:
|
||
+ print(f"Reset attempts error: {e}")
|
||
+ conn.rollback()
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+def get_login_attempts(ip_address):
|
||
+ """Gibt die Anzahl der Login-Versuche für eine IP zurück"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT attempt_count FROM login_attempts
|
||
+ WHERE ip_address = %s
|
||
+ """, (ip_address,))
|
||
+
|
||
+ result = cur.fetchone()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return result[0] if result else 0
|
||
+
|
||
+def send_security_alert_email(ip_address, username, attempt_count):
|
||
+ """Sendet eine Sicherheitswarnung per E-Mail"""
|
||
+ subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche"
|
||
+ body = f"""
|
||
+ WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt!
|
||
+
|
||
+ IP-Adresse: {ip_address}
|
||
+ Versuchter Benutzername: {username}
|
||
+ Anzahl Versuche: {attempt_count}
|
||
+ Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')}
|
||
+
|
||
+ Die IP-Adresse wurde für 24 Stunden gesperrt.
|
||
+
|
||
+ Dies ist eine automatische Nachricht vom v2-Docker Admin Panel.
|
||
+ """
|
||
+
|
||
+ # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert
|
||
+ logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}")
|
||
+ print(f"E-Mail würde gesendet: {subject}")
|
||
+
|
||
+def verify_recaptcha(response):
|
||
+ """Verifiziert die reCAPTCHA v2 Response mit Google"""
|
||
+ secret_key = os.getenv('RECAPTCHA_SECRET_KEY')
|
||
+
|
||
+ # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC)
|
||
+ if not secret_key:
|
||
+ logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen")
|
||
+ return True
|
||
+
|
||
+ # Verifizierung bei Google
|
||
+ try:
|
||
+ verify_url = 'https://www.google.com/recaptcha/api/siteverify'
|
||
+ data = {
|
||
+ 'secret': secret_key,
|
||
+ 'response': response
|
||
+ }
|
||
+
|
||
+ # Timeout für Request setzen
|
||
+ r = requests.post(verify_url, data=data, timeout=5)
|
||
+ result = r.json()
|
||
+
|
||
+ # Log für Debugging
|
||
+ if not result.get('success'):
|
||
+ logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}")
|
||
+
|
||
+ return result.get('success', False)
|
||
+
|
||
+ except requests.exceptions.RequestException as e:
|
||
+ logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}")
|
||
+ # Bei Netzwerkfehlern CAPTCHA als bestanden werten
|
||
+ return True
|
||
+ except Exception as e:
|
||
+ logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}")
|
||
+ return False
|
||
+
|
||
+def generate_license_key(license_type='full'):
|
||
+ """
|
||
+ Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ
|
||
+
|
||
+ AF = Account Factory (Produktkennung)
|
||
+ F/T = F für Fullversion, T für Testversion
|
||
+ YYYY = Jahr
|
||
+ MM = Monat
|
||
+ XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen
|
||
+ """
|
||
+ # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l)
|
||
+ chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||
+
|
||
+ # Datum-Teil
|
||
+ now = datetime.now(ZoneInfo("Europe/Berlin"))
|
||
+ date_part = now.strftime('%Y%m')
|
||
+ type_char = 'F' if license_type == 'full' else 'T'
|
||
+
|
||
+ # Zufällige Teile generieren (3 Blöcke à 4 Zeichen)
|
||
+ parts = []
|
||
+ for _ in range(3):
|
||
+ part = ''.join(secrets.choice(chars) for _ in range(4))
|
||
+ parts.append(part)
|
||
+
|
||
+ # Key zusammensetzen
|
||
+ key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}"
|
||
+
|
||
+ return key
|
||
+
|
||
+def validate_license_key(key):
|
||
+ """
|
||
+ Validiert das License Key Format
|
||
+ Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ
|
||
+ """
|
||
+ if not key:
|
||
+ return False
|
||
+
|
||
+ # Pattern für das neue Format
|
||
+ # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen
|
||
+ pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||
+
|
||
+ # Großbuchstaben für Vergleich
|
||
+ return bool(re.match(pattern, key.upper()))
|
||
+
|
||
+@app.route("/login", methods=["GET", "POST"])
|
||
+def login():
|
||
+ # Timing-Attack Schutz - Start Zeit merken
|
||
+ start_time = time.time()
|
||
+
|
||
+ # IP-Adresse ermitteln
|
||
+ ip_address = get_client_ip()
|
||
+
|
||
+ # Prüfen ob IP gesperrt ist
|
||
+ is_blocked, blocked_until = check_ip_blocked(ip_address)
|
||
+ if is_blocked:
|
||
+ time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600
|
||
+ error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten."
|
||
+ return render_template("login.html", error=error_msg, error_type="blocked")
|
||
+
|
||
+ # Anzahl bisheriger Versuche
|
||
+ attempt_count = get_login_attempts(ip_address)
|
||
+
|
||
+ if request.method == "POST":
|
||
+ username = request.form.get("username")
|
||
+ password = request.form.get("password")
|
||
+ captcha_response = request.form.get("g-recaptcha-response")
|
||
+
|
||
+ # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind
|
||
+ recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY')
|
||
+ if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key:
|
||
+ if not captcha_response:
|
||
+ # Timing-Attack Schutz
|
||
+ elapsed = time.time() - start_time
|
||
+ if elapsed < 1.0:
|
||
+ time.sleep(1.0 - elapsed)
|
||
+ return render_template("login.html",
|
||
+ error="CAPTCHA ERFORDERLICH!",
|
||
+ show_captcha=True,
|
||
+ error_type="captcha",
|
||
+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
+ recaptcha_site_key=recaptcha_site_key)
|
||
+
|
||
+ # CAPTCHA validieren
|
||
+ if not verify_recaptcha(captcha_response):
|
||
+ # Timing-Attack Schutz
|
||
+ elapsed = time.time() - start_time
|
||
+ if elapsed < 1.0:
|
||
+ time.sleep(1.0 - elapsed)
|
||
+ return render_template("login.html",
|
||
+ error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.",
|
||
+ show_captcha=True,
|
||
+ error_type="captcha",
|
||
+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
+ recaptcha_site_key=recaptcha_site_key)
|
||
+
|
||
+ # Check user in database first, fallback to env vars
|
||
+ user = get_user_by_username(username)
|
||
+ login_success = False
|
||
+ needs_2fa = False
|
||
+
|
||
+ if user:
|
||
+ # Database user authentication
|
||
+ if verify_password(password, user['password_hash']):
|
||
+ login_success = True
|
||
+ needs_2fa = user['totp_enabled']
|
||
+ else:
|
||
+ # Fallback to environment variables for backward compatibility
|
||
+ admin1_user = os.getenv("ADMIN1_USERNAME")
|
||
+ admin1_pass = os.getenv("ADMIN1_PASSWORD")
|
||
+ admin2_user = os.getenv("ADMIN2_USERNAME")
|
||
+ admin2_pass = os.getenv("ADMIN2_PASSWORD")
|
||
+
|
||
+ if ((username == admin1_user and password == admin1_pass) or
|
||
+ (username == admin2_user and password == admin2_pass)):
|
||
+ login_success = True
|
||
+
|
||
+ # Timing-Attack Schutz - Mindestens 1 Sekunde warten
|
||
+ elapsed = time.time() - start_time
|
||
+ if elapsed < 1.0:
|
||
+ time.sleep(1.0 - elapsed)
|
||
+
|
||
+ if login_success:
|
||
+ # Erfolgreicher Login
|
||
+ if needs_2fa:
|
||
+ # Store temporary session for 2FA verification
|
||
+ session['temp_username'] = username
|
||
+ session['temp_user_id'] = user['id']
|
||
+ session['awaiting_2fa'] = True
|
||
+ return redirect(url_for('verify_2fa'))
|
||
+ else:
|
||
+ # Complete login without 2FA
|
||
+ session.permanent = True # Aktiviert das Timeout
|
||
+ session['logged_in'] = True
|
||
+ session['username'] = username
|
||
+ session['user_id'] = user['id'] if user else None
|
||
+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ reset_login_attempts(ip_address)
|
||
+ log_audit('LOGIN_SUCCESS', 'user',
|
||
+ additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}")
|
||
+ return redirect(url_for('dashboard'))
|
||
+ else:
|
||
+ # Fehlgeschlagener Login
|
||
+ error_message = record_failed_attempt(ip_address, username)
|
||
+ new_attempt_count = get_login_attempts(ip_address)
|
||
+
|
||
+ # Prüfen ob jetzt gesperrt
|
||
+ is_now_blocked, _ = check_ip_blocked(ip_address)
|
||
+ if is_now_blocked:
|
||
+ log_audit('LOGIN_BLOCKED', 'security',
|
||
+ additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt")
|
||
+
|
||
+ return render_template("login.html",
|
||
+ error=error_message,
|
||
+ show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')),
|
||
+ error_type="failed",
|
||
+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count),
|
||
+ recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY'))
|
||
+
|
||
+ # GET Request
|
||
+ return render_template("login.html",
|
||
+ show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')),
|
||
+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
+ recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY'))
|
||
+
|
||
+@app.route("/logout")
|
||
+def logout():
|
||
+ username = session.get('username', 'unknown')
|
||
+ log_audit('LOGOUT', 'user', additional_info=f"Abmeldung")
|
||
+ session.pop('logged_in', None)
|
||
+ session.pop('username', None)
|
||
+ session.pop('user_id', None)
|
||
+ session.pop('temp_username', None)
|
||
+ session.pop('temp_user_id', None)
|
||
+ session.pop('awaiting_2fa', None)
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+@app.route("/verify-2fa", methods=["GET", "POST"])
|
||
+def verify_2fa():
|
||
+ if not session.get('awaiting_2fa'):
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+ if request.method == "POST":
|
||
+ token = request.form.get('token', '').replace(' ', '')
|
||
+ username = session.get('temp_username')
|
||
+ user_id = session.get('temp_user_id')
|
||
+
|
||
+ if not username or not user_id:
|
||
+ flash('Session expired. Please login again.', 'error')
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+ user = get_user_by_username(username)
|
||
+ if not user:
|
||
+ flash('User not found.', 'error')
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+ # Check if it's a backup code
|
||
+ if len(token) == 8 and token.isupper():
|
||
+ # Try backup code
|
||
+ backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else []
|
||
+ if verify_backup_code(token, backup_codes):
|
||
+ # Remove used backup code
|
||
+ code_hash = hash_backup_code(token)
|
||
+ backup_codes.remove(code_hash)
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s",
|
||
+ (json.dumps(backup_codes), user_id))
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Complete login
|
||
+ session.permanent = True
|
||
+ session['logged_in'] = True
|
||
+ session['username'] = username
|
||
+ session['user_id'] = user_id
|
||
+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ session.pop('temp_username', None)
|
||
+ session.pop('temp_user_id', None)
|
||
+ session.pop('awaiting_2fa', None)
|
||
+
|
||
+ flash('Login successful using backup code. Please generate new backup codes.', 'warning')
|
||
+ log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code")
|
||
+ return redirect(url_for('dashboard'))
|
||
+ else:
|
||
+ # Try TOTP token
|
||
+ if verify_totp(user['totp_secret'], token):
|
||
+ # Complete login
|
||
+ session.permanent = True
|
||
+ session['logged_in'] = True
|
||
+ session['username'] = username
|
||
+ session['user_id'] = user_id
|
||
+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ session.pop('temp_username', None)
|
||
+ session.pop('temp_user_id', None)
|
||
+ session.pop('awaiting_2fa', None)
|
||
+
|
||
+ log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful")
|
||
+ return redirect(url_for('dashboard'))
|
||
+
|
||
+ # Failed verification
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s",
|
||
+ (datetime.now(), user_id))
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ flash('Invalid authentication code. Please try again.', 'error')
|
||
+ log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt")
|
||
+
|
||
+ return render_template('verify_2fa.html')
|
||
+
|
||
+@app.route("/profile")
|
||
+@login_required
|
||
+def profile():
|
||
+ user = get_user_by_username(session['username'])
|
||
+ if not user:
|
||
+ # For environment-based users, redirect with message
|
||
+ flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info')
|
||
+ return redirect(url_for('dashboard'))
|
||
+ return render_template('profile.html', user=user)
|
||
+
|
||
+@app.route("/profile/change-password", methods=["POST"])
|
||
+@login_required
|
||
+def change_password():
|
||
+ current_password = request.form.get('current_password')
|
||
+ new_password = request.form.get('new_password')
|
||
+ confirm_password = request.form.get('confirm_password')
|
||
+
|
||
+ user = get_user_by_username(session['username'])
|
||
+
|
||
+ # Verify current password
|
||
+ if not verify_password(current_password, user['password_hash']):
|
||
+ flash('Current password is incorrect.', 'error')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+ # Check new password
|
||
+ if new_password != confirm_password:
|
||
+ flash('New passwords do not match.', 'error')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+ if len(new_password) < 8:
|
||
+ flash('Password must be at least 8 characters long.', 'error')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+ # Update password
|
||
+ new_hash = hash_password(new_password)
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s",
|
||
+ (new_hash, datetime.now(), user['id']))
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'],
|
||
+ additional_info="Password changed successfully")
|
||
+ flash('Password changed successfully.', 'success')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+@app.route("/profile/setup-2fa")
|
||
+@login_required
|
||
+def setup_2fa():
|
||
+ user = get_user_by_username(session['username'])
|
||
+
|
||
+ if user['totp_enabled']:
|
||
+ flash('2FA is already enabled for your account.', 'info')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+ # Generate new TOTP secret
|
||
+ totp_secret = generate_totp_secret()
|
||
+ session['temp_totp_secret'] = totp_secret
|
||
+
|
||
+ # Generate QR code
|
||
+ qr_code = generate_qr_code(user['username'], totp_secret)
|
||
+
|
||
+ return render_template('setup_2fa.html',
|
||
+ totp_secret=totp_secret,
|
||
+ qr_code=qr_code)
|
||
+
|
||
+@app.route("/profile/enable-2fa", methods=["POST"])
|
||
+@login_required
|
||
+def enable_2fa():
|
||
+ token = request.form.get('token', '').replace(' ', '')
|
||
+ totp_secret = session.get('temp_totp_secret')
|
||
+
|
||
+ if not totp_secret:
|
||
+ flash('2FA setup session expired. Please try again.', 'error')
|
||
+ return redirect(url_for('setup_2fa'))
|
||
+
|
||
+ # Verify the token
|
||
+ if not verify_totp(totp_secret, token):
|
||
+ flash('Invalid authentication code. Please try again.', 'error')
|
||
+ return redirect(url_for('setup_2fa'))
|
||
+
|
||
+ # Generate backup codes
|
||
+ backup_codes = generate_backup_codes()
|
||
+ hashed_codes = [hash_backup_code(code) for code in backup_codes]
|
||
+
|
||
+ # Enable 2FA
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ cur.execute("""
|
||
+ UPDATE users
|
||
+ SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s
|
||
+ WHERE username = %s
|
||
+ """, (totp_secret, json.dumps(hashed_codes), session['username']))
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ session.pop('temp_totp_secret', None)
|
||
+
|
||
+ log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully")
|
||
+
|
||
+ # Show backup codes
|
||
+ return render_template('backup_codes.html', backup_codes=backup_codes)
|
||
+
|
||
+@app.route("/profile/disable-2fa", methods=["POST"])
|
||
+@login_required
|
||
+def disable_2fa():
|
||
+ password = request.form.get('password')
|
||
+ user = get_user_by_username(session['username'])
|
||
+
|
||
+ # Verify password
|
||
+ if not verify_password(password, user['password_hash']):
|
||
+ flash('Incorrect password.', 'error')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+ # Disable 2FA
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ cur.execute("""
|
||
+ UPDATE users
|
||
+ SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL
|
||
+ WHERE username = %s
|
||
+ """, (session['username'],))
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled")
|
||
+ flash('2FA has been disabled for your account.', 'success')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+@app.route("/heartbeat", methods=['POST'])
|
||
+@login_required
|
||
+def heartbeat():
|
||
+ """Endpoint für Session Keep-Alive - aktualisiert last_activity"""
|
||
+ # Aktualisiere last_activity nur wenn explizit angefordert
|
||
+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ # Force session save
|
||
+ session.modified = True
|
||
+
|
||
+ return jsonify({
|
||
+ 'status': 'ok',
|
||
+ 'last_activity': session['last_activity'],
|
||
+ 'username': session.get('username')
|
||
+ })
|
||
+
|
||
+@app.route("/api/generate-license-key", methods=['POST'])
|
||
+@login_required
|
||
+def api_generate_key():
|
||
+ """API Endpoint zur Generierung eines neuen Lizenzschlüssels"""
|
||
+ try:
|
||
+ # Lizenztyp aus Request holen (default: full)
|
||
+ data = request.get_json() or {}
|
||
+ license_type = data.get('type', 'full')
|
||
+
|
||
+ # Key generieren
|
||
+ key = generate_license_key(license_type)
|
||
+
|
||
+ # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher)
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Wiederhole bis eindeutiger Key gefunden
|
||
+ attempts = 0
|
||
+ while attempts < 10: # Max 10 Versuche
|
||
+ cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,))
|
||
+ if not cur.fetchone():
|
||
+ break # Key ist eindeutig
|
||
+ key = generate_license_key(license_type)
|
||
+ attempts += 1
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Log für Audit
|
||
+ log_audit('GENERATE_KEY', 'license',
|
||
+ additional_info={'type': license_type, 'key': key})
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'key': key,
|
||
+ 'type': license_type
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Fehler bei Key-Generierung: {str(e)}")
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'error': 'Fehler bei der Key-Generierung'
|
||
+ }), 500
|
||
+
|
||
+@app.route("/api/customers", methods=['GET'])
|
||
+@login_required
|
||
+def api_customers():
|
||
+ """API Endpoint für die Kundensuche mit Select2"""
|
||
+ try:
|
||
+ # Suchparameter
|
||
+ search = request.args.get('q', '').strip()
|
||
+ page = request.args.get('page', 1, type=int)
|
||
+ per_page = 20
|
||
+ customer_id = request.args.get('id', type=int)
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Einzelnen Kunden per ID abrufen
|
||
+ if customer_id:
|
||
+ cur.execute("""
|
||
+ SELECT c.id, c.name, c.email,
|
||
+ COUNT(l.id) as license_count
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ WHERE c.id = %s
|
||
+ GROUP BY c.id, c.name, c.email
|
||
+ """, (customer_id,))
|
||
+
|
||
+ customer = cur.fetchone()
|
||
+ results = []
|
||
+ if customer:
|
||
+ results.append({
|
||
+ 'id': customer[0],
|
||
+ 'text': f"{customer[1]} ({customer[2]})",
|
||
+ 'name': customer[1],
|
||
+ 'email': customer[2],
|
||
+ 'license_count': customer[3]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'results': results,
|
||
+ 'pagination': {'more': False}
|
||
+ })
|
||
+
|
||
+ # SQL Query mit optionaler Suche
|
||
+ elif search:
|
||
+ cur.execute("""
|
||
+ SELECT c.id, c.name, c.email,
|
||
+ COUNT(l.id) as license_count
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ WHERE LOWER(c.name) LIKE LOWER(%s)
|
||
+ OR LOWER(c.email) LIKE LOWER(%s)
|
||
+ GROUP BY c.id, c.name, c.email
|
||
+ ORDER BY c.name
|
||
+ LIMIT %s OFFSET %s
|
||
+ """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page))
|
||
+ else:
|
||
+ cur.execute("""
|
||
+ SELECT c.id, c.name, c.email,
|
||
+ COUNT(l.id) as license_count
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ GROUP BY c.id, c.name, c.email
|
||
+ ORDER BY c.name
|
||
+ LIMIT %s OFFSET %s
|
||
+ """, (per_page, (page - 1) * per_page))
|
||
+
|
||
+ customers = cur.fetchall()
|
||
+
|
||
+ # Format für Select2
|
||
+ results = []
|
||
+ for customer in customers:
|
||
+ results.append({
|
||
+ 'id': customer[0],
|
||
+ 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)",
|
||
+ 'name': customer[1],
|
||
+ 'email': customer[2],
|
||
+ 'license_count': customer[3]
|
||
+ })
|
||
+
|
||
+ # Gesamtanzahl für Pagination
|
||
+ if search:
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM customers
|
||
+ WHERE LOWER(name) LIKE LOWER(%s)
|
||
+ OR LOWER(email) LIKE LOWER(%s)
|
||
+ """, (f'%{search}%', f'%{search}%'))
|
||
+ else:
|
||
+ cur.execute("SELECT COUNT(*) FROM customers")
|
||
+
|
||
+ total_count = cur.fetchone()[0]
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Select2 Response Format
|
||
+ return jsonify({
|
||
+ 'results': results,
|
||
+ 'pagination': {
|
||
+ 'more': (page * per_page) < total_count
|
||
+ }
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Fehler bei Kundensuche: {str(e)}")
|
||
+ return jsonify({
|
||
+ 'results': [],
|
||
+ 'error': 'Fehler bei der Kundensuche'
|
||
+ }), 500
|
||
+
|
||
+@app.route("/")
|
||
+@login_required
|
||
+def dashboard():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Statistiken abrufen
|
||
+ # Gesamtanzahl Kunden (ohne Testdaten)
|
||
+ cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE")
|
||
+ total_customers = cur.fetchone()[0]
|
||
+
|
||
+ # Gesamtanzahl Lizenzen (ohne Testdaten)
|
||
+ cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE")
|
||
+ total_licenses = cur.fetchone()[0]
|
||
+
|
||
+ # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM licenses
|
||
+ WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE
|
||
+ """)
|
||
+ active_licenses = cur.fetchone()[0]
|
||
+
|
||
+ # Aktive Sessions
|
||
+ cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE")
|
||
+ active_sessions_count = cur.fetchone()[0]
|
||
+
|
||
+ # Abgelaufene Lizenzen (ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM licenses
|
||
+ WHERE valid_until < CURRENT_DATE AND is_test = FALSE
|
||
+ """)
|
||
+ expired_licenses = cur.fetchone()[0]
|
||
+
|
||
+ # Deaktivierte Lizenzen (ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM licenses
|
||
+ WHERE is_active = FALSE AND is_test = FALSE
|
||
+ """)
|
||
+ inactive_licenses = cur.fetchone()[0]
|
||
+
|
||
+ # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM licenses
|
||
+ WHERE valid_until >= CURRENT_DATE
|
||
+ AND valid_until < CURRENT_DATE + INTERVAL '30 days'
|
||
+ AND is_active = TRUE
|
||
+ AND is_test = FALSE
|
||
+ """)
|
||
+ expiring_soon = cur.fetchone()[0]
|
||
+
|
||
+ # Testlizenzen vs Vollversionen (ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT license_type, COUNT(*)
|
||
+ FROM licenses
|
||
+ WHERE is_test = FALSE
|
||
+ GROUP BY license_type
|
||
+ """)
|
||
+ license_types = dict(cur.fetchall())
|
||
+
|
||
+ # Anzahl Testdaten
|
||
+ cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE")
|
||
+ test_data_count = cur.fetchone()[0]
|
||
+
|
||
+ # Anzahl Test-Kunden
|
||
+ cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE")
|
||
+ test_customers_count = cur.fetchone()[0]
|
||
+
|
||
+ # Anzahl Test-Ressourcen
|
||
+ cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE")
|
||
+ test_resources_count = cur.fetchone()[0]
|
||
+
|
||
+ # Letzte 5 erstellten Lizenzen (ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT l.id, l.license_key, c.name, l.valid_until,
|
||
+ CASE
|
||
+ WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
+ ELSE 'aktiv'
|
||
+ END as status
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE l.is_test = FALSE
|
||
+ ORDER BY l.id DESC
|
||
+ LIMIT 5
|
||
+ """)
|
||
+ recent_licenses = cur.fetchall()
|
||
+
|
||
+ # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT l.id, l.license_key, c.name, l.valid_until,
|
||
+ l.valid_until - CURRENT_DATE as days_left
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE l.valid_until >= CURRENT_DATE
|
||
+ AND l.valid_until < CURRENT_DATE + INTERVAL '30 days'
|
||
+ AND l.is_active = TRUE
|
||
+ AND l.is_test = FALSE
|
||
+ ORDER BY l.valid_until
|
||
+ LIMIT 10
|
||
+ """)
|
||
+ expiring_licenses = cur.fetchall()
|
||
+
|
||
+ # Letztes Backup
|
||
+ cur.execute("""
|
||
+ SELECT created_at, filesize, duration_seconds, backup_type, status
|
||
+ FROM backup_history
|
||
+ ORDER BY created_at DESC
|
||
+ LIMIT 1
|
||
+ """)
|
||
+ last_backup_info = cur.fetchone()
|
||
+
|
||
+ # Sicherheitsstatistiken
|
||
+ # Gesperrte IPs
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM login_attempts
|
||
+ WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP
|
||
+ """)
|
||
+ blocked_ips_count = cur.fetchone()[0]
|
||
+
|
||
+ # Fehlversuche heute
|
||
+ cur.execute("""
|
||
+ SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts
|
||
+ WHERE last_attempt::date = CURRENT_DATE
|
||
+ """)
|
||
+ failed_attempts_today = cur.fetchone()[0]
|
||
+
|
||
+ # Letzte 5 Sicherheitsereignisse
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ la.ip_address,
|
||
+ la.attempt_count,
|
||
+ la.last_attempt,
|
||
+ la.blocked_until,
|
||
+ la.last_username_tried,
|
||
+ la.last_error_message
|
||
+ FROM login_attempts la
|
||
+ ORDER BY la.last_attempt DESC
|
||
+ LIMIT 5
|
||
+ """)
|
||
+ recent_security_events = []
|
||
+ for event in cur.fetchall():
|
||
+ recent_security_events.append({
|
||
+ 'ip_address': event[0],
|
||
+ 'attempt_count': event[1],
|
||
+ 'last_attempt': event[2].strftime('%d.%m %H:%M'),
|
||
+ 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None,
|
||
+ 'username_tried': event[4],
|
||
+ 'error_message': event[5]
|
||
+ })
|
||
+
|
||
+ # Sicherheitslevel berechnen
|
||
+ if blocked_ips_count > 5 or failed_attempts_today > 50:
|
||
+ security_level = 'danger'
|
||
+ security_level_text = 'KRITISCH'
|
||
+ elif blocked_ips_count > 2 or failed_attempts_today > 20:
|
||
+ security_level = 'warning'
|
||
+ security_level_text = 'ERHÖHT'
|
||
+ else:
|
||
+ security_level = 'success'
|
||
+ security_level_text = 'NORMAL'
|
||
+
|
||
+ # Resource Pool Statistiken (nur Live-Daten, keine Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ resource_type,
|
||
+ COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
+ COUNT(*) as total
|
||
+ FROM resource_pools
|
||
+ WHERE is_test = FALSE
|
||
+ GROUP BY resource_type
|
||
+ """)
|
||
+
|
||
+ resource_stats = {}
|
||
+ resource_warning = None
|
||
+
|
||
+ for row in cur.fetchall():
|
||
+ available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1)
|
||
+ resource_stats[row[0]] = {
|
||
+ 'available': row[1],
|
||
+ 'allocated': row[2],
|
||
+ 'quarantine': row[3],
|
||
+ 'total': row[4],
|
||
+ 'available_percent': available_percent,
|
||
+ 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success'
|
||
+ }
|
||
+
|
||
+ # Warnung bei niedrigem Bestand
|
||
+ if row[1] < 50:
|
||
+ if not resource_warning:
|
||
+ resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!"
|
||
+ else:
|
||
+ resource_warning += f" | {row[0].upper()}: {row[1]}"
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ stats = {
|
||
+ 'total_customers': total_customers,
|
||
+ 'total_licenses': total_licenses,
|
||
+ 'active_licenses': active_licenses,
|
||
+ 'expired_licenses': expired_licenses,
|
||
+ 'inactive_licenses': inactive_licenses,
|
||
+ 'expiring_soon': expiring_soon,
|
||
+ 'full_licenses': license_types.get('full', 0),
|
||
+ 'test_licenses': license_types.get('test', 0),
|
||
+ 'test_data_count': test_data_count,
|
||
+ 'test_customers_count': test_customers_count,
|
||
+ 'test_resources_count': test_resources_count,
|
||
+ 'recent_licenses': recent_licenses,
|
||
+ 'expiring_licenses': expiring_licenses,
|
||
+ 'active_sessions': active_sessions_count,
|
||
+ 'last_backup': last_backup_info,
|
||
+ # Sicherheitsstatistiken
|
||
+ 'blocked_ips_count': blocked_ips_count,
|
||
+ 'failed_attempts_today': failed_attempts_today,
|
||
+ 'recent_security_events': recent_security_events,
|
||
+ 'security_level': security_level,
|
||
+ 'security_level_text': security_level_text,
|
||
+ 'resource_stats': resource_stats
|
||
+ }
|
||
+
|
||
+ return render_template("dashboard.html",
|
||
+ stats=stats,
|
||
+ resource_stats=resource_stats,
|
||
+ resource_warning=resource_warning,
|
||
+ username=session.get('username'))
|
||
+
|
||
+@app.route("/create", methods=["GET", "POST"])
|
||
+@login_required
|
||
+def create_license():
|
||
+ if request.method == "POST":
|
||
+ customer_id = request.form.get("customer_id")
|
||
+ license_key = request.form["license_key"].upper() # Immer Großbuchstaben
|
||
+ license_type = request.form["license_type"]
|
||
+ valid_from = request.form["valid_from"]
|
||
+ is_test = request.form.get("is_test") == "on" # Checkbox value
|
||
+
|
||
+ # Berechne valid_until basierend auf Laufzeit
|
||
+ duration = int(request.form.get("duration", 1))
|
||
+ duration_type = request.form.get("duration_type", "years")
|
||
+
|
||
+ from datetime import datetime, timedelta
|
||
+ from dateutil.relativedelta import relativedelta
|
||
+
|
||
+ start_date = datetime.strptime(valid_from, "%Y-%m-%d")
|
||
+
|
||
+ if duration_type == "days":
|
||
+ end_date = start_date + timedelta(days=duration)
|
||
+ elif duration_type == "months":
|
||
+ end_date = start_date + relativedelta(months=duration)
|
||
+ else: # years
|
||
+ end_date = start_date + relativedelta(years=duration)
|
||
+
|
||
+ # Ein Tag abziehen, da der Starttag mitgezählt wird
|
||
+ end_date = end_date - timedelta(days=1)
|
||
+ valid_until = end_date.strftime("%Y-%m-%d")
|
||
+
|
||
+ # Validiere License Key Format
|
||
+ if not validate_license_key(license_key):
|
||
+ flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error')
|
||
+ return redirect(url_for('create_license'))
|
||
+
|
||
+ # Resource counts
|
||
+ domain_count = int(request.form.get("domain_count", 1))
|
||
+ ipv4_count = int(request.form.get("ipv4_count", 1))
|
||
+ phone_count = int(request.form.get("phone_count", 1))
|
||
+ device_limit = int(request.form.get("device_limit", 3))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Prüfe ob neuer Kunde oder bestehender
|
||
+ if customer_id == "new":
|
||
+ # Neuer Kunde
|
||
+ name = request.form.get("customer_name")
|
||
+ email = request.form.get("email")
|
||
+
|
||
+ if not name:
|
||
+ flash('Kundenname ist erforderlich!', 'error')
|
||
+ return redirect(url_for('create_license'))
|
||
+
|
||
+ # Prüfe ob E-Mail bereits existiert
|
||
+ if email:
|
||
+ cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||
+ existing = cur.fetchone()
|
||
+ if existing:
|
||
+ flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||
+ return redirect(url_for('create_license'))
|
||
+
|
||
+ # Kunde einfügen (erbt Test-Status von Lizenz)
|
||
+ cur.execute("""
|
||
+ INSERT INTO customers (name, email, is_test, created_at)
|
||
+ VALUES (%s, %s, %s, NOW())
|
||
+ RETURNING id
|
||
+ """, (name, email, is_test))
|
||
+ customer_id = cur.fetchone()[0]
|
||
+ customer_info = {'name': name, 'email': email, 'is_test': is_test}
|
||
+
|
||
+ # Audit-Log für neuen Kunden
|
||
+ log_audit('CREATE', 'customer', customer_id,
|
||
+ new_values={'name': name, 'email': email, 'is_test': is_test})
|
||
+ else:
|
||
+ # Bestehender Kunde - hole Infos für Audit-Log
|
||
+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
+ customer_data = cur.fetchone()
|
||
+ if not customer_data:
|
||
+ flash('Kunde nicht gefunden!', 'error')
|
||
+ return redirect(url_for('create_license'))
|
||
+ customer_info = {'name': customer_data[0], 'email': customer_data[1]}
|
||
+
|
||
+ # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren
|
||
+ if customer_data[2]: # is_test des Kunden
|
||
+ is_test = True
|
||
+
|
||
+ # Lizenz hinzufügen
|
||
+ cur.execute("""
|
||
+ INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active,
|
||
+ domain_count, ipv4_count, phone_count, device_limit, is_test)
|
||
+ VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s)
|
||
+ RETURNING id
|
||
+ """, (license_key, customer_id, license_type, valid_from, valid_until,
|
||
+ domain_count, ipv4_count, phone_count, device_limit, is_test))
|
||
+ license_id = cur.fetchone()[0]
|
||
+
|
||
+ # Ressourcen zuweisen
|
||
+ try:
|
||
+ # Prüfe Verfügbarkeit
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains,
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s,
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones
|
||
+ """, (is_test, is_test, is_test))
|
||
+ available = cur.fetchone()
|
||
+
|
||
+ if available[0] < domain_count:
|
||
+ raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})")
|
||
+ if available[1] < ipv4_count:
|
||
+ raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})")
|
||
+ if available[2] < phone_count:
|
||
+ raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})")
|
||
+
|
||
+ # Domains zuweisen
|
||
+ if domain_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, domain_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ # IPv4s zuweisen
|
||
+ if ipv4_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, ipv4_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ # Telefonnummern zuweisen
|
||
+ if phone_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, phone_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ except ValueError as e:
|
||
+ conn.rollback()
|
||
+ flash(str(e), 'error')
|
||
+ return redirect(url_for('create_license'))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('CREATE', 'license', license_id,
|
||
+ new_values={
|
||
+ 'license_key': license_key,
|
||
+ 'customer_name': customer_info['name'],
|
||
+ 'customer_email': customer_info['email'],
|
||
+ 'license_type': license_type,
|
||
+ 'valid_from': valid_from,
|
||
+ 'valid_until': valid_until,
|
||
+ 'device_limit': device_limit,
|
||
+ 'is_test': is_test
|
||
+ })
|
||
+
|
||
+ flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success')
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}")
|
||
+ flash('Fehler beim Erstellen der Lizenz!', 'error')
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Preserve show_test parameter if present
|
||
+ redirect_url = "/create"
|
||
+ if request.args.get('show_test') == 'true':
|
||
+ redirect_url += "?show_test=true"
|
||
+ return redirect(redirect_url)
|
||
+
|
||
+ # Unterstützung für vorausgewählten Kunden
|
||
+ preselected_customer_id = request.args.get('customer_id', type=int)
|
||
+ return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id)
|
||
+
|
||
+@app.route("/batch", methods=["GET", "POST"])
|
||
+@login_required
|
||
+def batch_licenses():
|
||
+ """Batch-Generierung mehrerer Lizenzen für einen Kunden"""
|
||
+ if request.method == "POST":
|
||
+ # Formulardaten
|
||
+ customer_id = request.form.get("customer_id")
|
||
+ license_type = request.form["license_type"]
|
||
+ quantity = int(request.form["quantity"])
|
||
+ valid_from = request.form["valid_from"]
|
||
+ is_test = request.form.get("is_test") == "on" # Checkbox value
|
||
+
|
||
+ # Berechne valid_until basierend auf Laufzeit
|
||
+ duration = int(request.form.get("duration", 1))
|
||
+ duration_type = request.form.get("duration_type", "years")
|
||
+
|
||
+ from datetime import datetime, timedelta
|
||
+ from dateutil.relativedelta import relativedelta
|
||
+
|
||
+ start_date = datetime.strptime(valid_from, "%Y-%m-%d")
|
||
+
|
||
+ if duration_type == "days":
|
||
+ end_date = start_date + timedelta(days=duration)
|
||
+ elif duration_type == "months":
|
||
+ end_date = start_date + relativedelta(months=duration)
|
||
+ else: # years
|
||
+ end_date = start_date + relativedelta(years=duration)
|
||
+
|
||
+ # Ein Tag abziehen, da der Starttag mitgezählt wird
|
||
+ end_date = end_date - timedelta(days=1)
|
||
+ valid_until = end_date.strftime("%Y-%m-%d")
|
||
+
|
||
+ # Resource counts
|
||
+ domain_count = int(request.form.get("domain_count", 1))
|
||
+ ipv4_count = int(request.form.get("ipv4_count", 1))
|
||
+ phone_count = int(request.form.get("phone_count", 1))
|
||
+ device_limit = int(request.form.get("device_limit", 3))
|
||
+
|
||
+ # Sicherheitslimit
|
||
+ if quantity < 1 or quantity > 100:
|
||
+ flash('Anzahl muss zwischen 1 und 100 liegen!', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Prüfe ob neuer Kunde oder bestehender
|
||
+ if customer_id == "new":
|
||
+ # Neuer Kunde
|
||
+ name = request.form.get("customer_name")
|
||
+ email = request.form.get("email")
|
||
+
|
||
+ if not name:
|
||
+ flash('Kundenname ist erforderlich!', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+
|
||
+ # Prüfe ob E-Mail bereits existiert
|
||
+ if email:
|
||
+ cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||
+ existing = cur.fetchone()
|
||
+ if existing:
|
||
+ flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+
|
||
+ # Kunde einfügen (erbt Test-Status von Lizenz)
|
||
+ cur.execute("""
|
||
+ INSERT INTO customers (name, email, is_test, created_at)
|
||
+ VALUES (%s, %s, %s, NOW())
|
||
+ RETURNING id
|
||
+ """, (name, email, is_test))
|
||
+ customer_id = cur.fetchone()[0]
|
||
+
|
||
+ # Audit-Log für neuen Kunden
|
||
+ log_audit('CREATE', 'customer', customer_id,
|
||
+ new_values={'name': name, 'email': email, 'is_test': is_test})
|
||
+ else:
|
||
+ # Bestehender Kunde - hole Infos
|
||
+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
+ customer_data = cur.fetchone()
|
||
+ if not customer_data:
|
||
+ flash('Kunde nicht gefunden!', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+ name = customer_data[0]
|
||
+ email = customer_data[1]
|
||
+
|
||
+ # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren
|
||
+ if customer_data[2]: # is_test des Kunden
|
||
+ is_test = True
|
||
+
|
||
+ # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch
|
||
+ total_domains_needed = domain_count * quantity
|
||
+ total_ipv4s_needed = ipv4_count * quantity
|
||
+ total_phones_needed = phone_count * quantity
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains,
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s,
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones
|
||
+ """, (is_test, is_test, is_test))
|
||
+ available = cur.fetchone()
|
||
+
|
||
+ if available[0] < total_domains_needed:
|
||
+ flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+ if available[1] < total_ipv4s_needed:
|
||
+ flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+ if available[2] < total_phones_needed:
|
||
+ flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+
|
||
+ # Lizenzen generieren und speichern
|
||
+ generated_licenses = []
|
||
+ for i in range(quantity):
|
||
+ # Eindeutigen Key generieren
|
||
+ attempts = 0
|
||
+ while attempts < 10:
|
||
+ license_key = generate_license_key(license_type)
|
||
+ cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,))
|
||
+ if not cur.fetchone():
|
||
+ break
|
||
+ attempts += 1
|
||
+
|
||
+ # Lizenz einfügen
|
||
+ cur.execute("""
|
||
+ INSERT INTO licenses (license_key, customer_id, license_type, is_test,
|
||
+ valid_from, valid_until, is_active,
|
||
+ domain_count, ipv4_count, phone_count, device_limit)
|
||
+ VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s)
|
||
+ RETURNING id
|
||
+ """, (license_key, customer_id, license_type, is_test, valid_from, valid_until,
|
||
+ domain_count, ipv4_count, phone_count, device_limit))
|
||
+ license_id = cur.fetchone()[0]
|
||
+
|
||
+ # Ressourcen für diese Lizenz zuweisen
|
||
+ # Domains
|
||
+ if domain_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, domain_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ # IPv4s
|
||
+ if ipv4_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, ipv4_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ # Telefonnummern
|
||
+ if phone_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, phone_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ generated_licenses.append({
|
||
+ 'id': license_id,
|
||
+ 'key': license_key,
|
||
+ 'type': license_type
|
||
+ })
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('CREATE_BATCH', 'license',
|
||
+ new_values={'customer': name, 'quantity': quantity, 'type': license_type},
|
||
+ additional_info=f"Batch-Generierung von {quantity} Lizenzen")
|
||
+
|
||
+ # Session für Export speichern
|
||
+ session['batch_export'] = {
|
||
+ 'customer': name,
|
||
+ 'email': email,
|
||
+ 'licenses': generated_licenses,
|
||
+ 'valid_from': valid_from,
|
||
+ 'valid_until': valid_until,
|
||
+ 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ }
|
||
+
|
||
+ flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success')
|
||
+ return render_template("batch_result.html",
|
||
+ customer=name,
|
||
+ email=email,
|
||
+ licenses=generated_licenses,
|
||
+ valid_from=valid_from,
|
||
+ valid_until=valid_until)
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ logging.error(f"Fehler bei Batch-Generierung: {str(e)}")
|
||
+ flash('Fehler bei der Batch-Generierung!', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # GET Request
|
||
+ return render_template("batch_form.html")
|
||
+
|
||
+@app.route("/batch/export")
|
||
+@login_required
|
||
+def export_batch():
|
||
+ """Exportiert die zuletzt generierten Batch-Lizenzen"""
|
||
+ batch_data = session.get('batch_export')
|
||
+ if not batch_data:
|
||
+ flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+
|
||
+ # CSV generieren
|
||
+ output = io.StringIO()
|
||
+ output.write('\ufeff') # UTF-8 BOM für Excel
|
||
+
|
||
+ # Header
|
||
+ output.write(f"Kunde: {batch_data['customer']}\n")
|
||
+ output.write(f"E-Mail: {batch_data['email']}\n")
|
||
+ output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n")
|
||
+ output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n")
|
||
+ output.write("\n")
|
||
+ output.write("Nr;Lizenzschlüssel;Typ\n")
|
||
+
|
||
+ # Lizenzen
|
||
+ for i, license in enumerate(batch_data['licenses'], 1):
|
||
+ typ_text = "Vollversion" if license['type'] == 'full' else "Testversion"
|
||
+ output.write(f"{i};{license['key']};{typ_text}\n")
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('EXPORT', 'batch_licenses',
|
||
+ additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen")
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
+ mimetype='text/csv',
|
||
+ as_attachment=True,
|
||
+ download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv"
|
||
+ )
|
||
+
|
||
+@app.route("/licenses")
|
||
+@login_required
|
||
+def licenses():
|
||
+ # Redirect zur kombinierten Ansicht
|
||
+ return redirect("/customers-licenses")
|
||
+
|
||
+@app.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
|
||
+@login_required
|
||
+def edit_license(license_id):
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ if request.method == "POST":
|
||
+ # Alte Werte für Audit-Log abrufen
|
||
+ cur.execute("""
|
||
+ SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit
|
||
+ FROM licenses WHERE id = %s
|
||
+ """, (license_id,))
|
||
+ old_license = cur.fetchone()
|
||
+
|
||
+ # Update license
|
||
+ license_key = request.form["license_key"]
|
||
+ license_type = request.form["license_type"]
|
||
+ valid_from = request.form["valid_from"]
|
||
+ valid_until = request.form["valid_until"]
|
||
+ is_active = request.form.get("is_active") == "on"
|
||
+ is_test = request.form.get("is_test") == "on"
|
||
+ device_limit = int(request.form.get("device_limit", 3))
|
||
+
|
||
+ cur.execute("""
|
||
+ UPDATE licenses
|
||
+ SET license_key = %s, license_type = %s, valid_from = %s,
|
||
+ valid_until = %s, is_active = %s, is_test = %s, device_limit = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('UPDATE', 'license', license_id,
|
||
+ old_values={
|
||
+ 'license_key': old_license[0],
|
||
+ 'license_type': old_license[1],
|
||
+ 'valid_from': str(old_license[2]),
|
||
+ 'valid_until': str(old_license[3]),
|
||
+ 'is_active': old_license[4],
|
||
+ 'is_test': old_license[5],
|
||
+ 'device_limit': old_license[6]
|
||
+ },
|
||
+ new_values={
|
||
+ 'license_key': license_key,
|
||
+ 'license_type': license_type,
|
||
+ 'valid_from': valid_from,
|
||
+ 'valid_until': valid_until,
|
||
+ 'is_active': is_active,
|
||
+ 'is_test': is_test,
|
||
+ 'device_limit': device_limit
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Redirect zurück zu customers-licenses mit beibehaltenen Parametern
|
||
+ redirect_url = "/customers-licenses"
|
||
+
|
||
+ # Behalte show_test Parameter bei (aus Form oder GET-Parameter)
|
||
+ show_test = request.form.get('show_test') or request.args.get('show_test')
|
||
+ if show_test == 'true':
|
||
+ redirect_url += "?show_test=true"
|
||
+
|
||
+ # Behalte customer_id bei wenn vorhanden
|
||
+ if request.referrer and 'customer_id=' in request.referrer:
|
||
+ import re
|
||
+ match = re.search(r'customer_id=(\d+)', request.referrer)
|
||
+ if match:
|
||
+ connector = "&" if "?" in redirect_url else "?"
|
||
+ redirect_url += f"{connector}customer_id={match.group(1)}"
|
||
+
|
||
+ return redirect(redirect_url)
|
||
+
|
||
+ # Get license data
|
||
+ cur.execute("""
|
||
+ SELECT l.id, l.license_key, c.name, c.email, l.license_type,
|
||
+ l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE l.id = %s
|
||
+ """, (license_id,))
|
||
+
|
||
+ license = cur.fetchone()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ if not license:
|
||
+ return redirect("/licenses")
|
||
+
|
||
+ return render_template("edit_license.html", license=license, username=session.get('username'))
|
||
+
|
||
+@app.route("/license/delete/<int:license_id>", methods=["POST"])
|
||
+@login_required
|
||
+def delete_license(license_id):
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Lizenzdetails für Audit-Log abrufen
|
||
+ cur.execute("""
|
||
+ SELECT l.license_key, c.name, l.license_type
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE l.id = %s
|
||
+ """, (license_id,))
|
||
+ license_info = cur.fetchone()
|
||
+
|
||
+ cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ if license_info:
|
||
+ log_audit('DELETE', 'license', license_id,
|
||
+ old_values={
|
||
+ 'license_key': license_info[0],
|
||
+ 'customer_name': license_info[1],
|
||
+ 'license_type': license_info[2]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return redirect("/licenses")
|
||
+
|
||
+@app.route("/customers")
|
||
+@login_required
|
||
+def customers():
|
||
+ # Redirect zur kombinierten Ansicht
|
||
+ return redirect("/customers-licenses")
|
||
+
|
||
+@app.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
|
||
+@login_required
|
||
+def edit_customer(customer_id):
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ if request.method == "POST":
|
||
+ # Alte Werte für Audit-Log abrufen
|
||
+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
+ old_customer = cur.fetchone()
|
||
+
|
||
+ # Update customer
|
||
+ name = request.form["name"]
|
||
+ email = request.form["email"]
|
||
+ is_test = request.form.get("is_test") == "on"
|
||
+
|
||
+ cur.execute("""
|
||
+ UPDATE customers
|
||
+ SET name = %s, email = %s, is_test = %s
|
||
+ WHERE id = %s
|
||
+ """, (name, email, is_test, customer_id))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('UPDATE', 'customer', customer_id,
|
||
+ old_values={
|
||
+ 'name': old_customer[0],
|
||
+ 'email': old_customer[1],
|
||
+ 'is_test': old_customer[2]
|
||
+ },
|
||
+ new_values={
|
||
+ 'name': name,
|
||
+ 'email': email,
|
||
+ 'is_test': is_test
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Redirect zurück zu customers-licenses mit beibehaltenen Parametern
|
||
+ redirect_url = "/customers-licenses"
|
||
+
|
||
+ # Behalte show_test Parameter bei (aus Form oder GET-Parameter)
|
||
+ show_test = request.form.get('show_test') or request.args.get('show_test')
|
||
+ if show_test == 'true':
|
||
+ redirect_url += "?show_test=true"
|
||
+
|
||
+ # Behalte customer_id bei (immer der aktuelle Kunde)
|
||
+ connector = "&" if "?" in redirect_url else "?"
|
||
+ redirect_url += f"{connector}customer_id={customer_id}"
|
||
+
|
||
+ return redirect(redirect_url)
|
||
+
|
||
+ # Get customer data with licenses
|
||
+ cur.execute("""
|
||
+ SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s
|
||
+ """, (customer_id,))
|
||
+
|
||
+ customer = cur.fetchone()
|
||
+ if not customer:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+ return "Kunde nicht gefunden", 404
|
||
+
|
||
+
|
||
+ # Get customer's licenses
|
||
+ cur.execute("""
|
||
+ SELECT id, license_key, license_type, valid_from, valid_until, is_active
|
||
+ FROM licenses
|
||
+ WHERE customer_id = %s
|
||
+ ORDER BY valid_until DESC
|
||
+ """, (customer_id,))
|
||
+
|
||
+ licenses = cur.fetchall()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ if not customer:
|
||
+ return redirect("/customers-licenses")
|
||
+
|
||
+ return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username'))
|
||
+
|
||
+@app.route("/customer/create", methods=["GET", "POST"])
|
||
+@login_required
|
||
+def create_customer():
|
||
+ """Erstellt einen neuen Kunden ohne Lizenz"""
|
||
+ if request.method == "POST":
|
||
+ name = request.form.get('name')
|
||
+ email = request.form.get('email')
|
||
+ is_test = request.form.get('is_test') == 'on'
|
||
+
|
||
+ if not name or not email:
|
||
+ flash("Name und E-Mail sind Pflichtfelder!", "error")
|
||
+ return render_template("create_customer.html", username=session.get('username'))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Prüfen ob E-Mail bereits existiert
|
||
+ cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,))
|
||
+ existing = cur.fetchone()
|
||
+ if existing:
|
||
+ flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error")
|
||
+ return render_template("create_customer.html", username=session.get('username'))
|
||
+
|
||
+ # Kunde erstellen
|
||
+ cur.execute("""
|
||
+ INSERT INTO customers (name, email, created_at, is_test)
|
||
+ VALUES (%s, %s, %s, %s) RETURNING id
|
||
+ """, (name, email, datetime.now(), is_test))
|
||
+
|
||
+ customer_id = cur.fetchone()[0]
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('CREATE', 'customer', customer_id,
|
||
+ new_values={
|
||
+ 'name': name,
|
||
+ 'email': email,
|
||
+ 'is_test': is_test
|
||
+ })
|
||
+
|
||
+ flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success")
|
||
+ return redirect(f"/customer/edit/{customer_id}")
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error")
|
||
+ return render_template("create_customer.html", username=session.get('username'))
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # GET Request - Formular anzeigen
|
||
+ return render_template("create_customer.html", username=session.get('username'))
|
||
+
|
||
+@app.route("/customer/delete/<int:customer_id>", methods=["POST"])
|
||
+@login_required
|
||
+def delete_customer(customer_id):
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Prüfen ob Kunde Lizenzen hat
|
||
+ cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,))
|
||
+ license_count = cur.fetchone()[0]
|
||
+
|
||
+ if license_count > 0:
|
||
+ # Kunde hat Lizenzen - nicht löschen
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+ return redirect("/customers")
|
||
+
|
||
+ # Kundendetails für Audit-Log abrufen
|
||
+ cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,))
|
||
+ customer_info = cur.fetchone()
|
||
+
|
||
+ # Kunde löschen wenn keine Lizenzen vorhanden
|
||
+ cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ if customer_info:
|
||
+ log_audit('DELETE', 'customer', customer_id,
|
||
+ old_values={
|
||
+ 'name': customer_info[0],
|
||
+ 'email': customer_info[1]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return redirect("/customers")
|
||
+
|
||
+@app.route("/customers-licenses")
|
||
+@login_required
|
||
+def customers_licenses():
|
||
+ """Kombinierte Ansicht für Kunden und deren Lizenzen"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht)
|
||
+ show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
+
|
||
+ query = """
|
||
+ SELECT
|
||
+ c.id,
|
||
+ c.name,
|
||
+ c.email,
|
||
+ c.created_at,
|
||
+ COUNT(l.id) as total_licenses,
|
||
+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ """
|
||
+
|
||
+ if not show_test:
|
||
+ query += " WHERE c.is_test = FALSE"
|
||
+
|
||
+ query += """
|
||
+ GROUP BY c.id, c.name, c.email, c.created_at
|
||
+ ORDER BY c.name
|
||
+ """
|
||
+
|
||
+ cur.execute(query)
|
||
+ customers = cur.fetchall()
|
||
+
|
||
+ # Hole ausgewählten Kunden nur wenn explizit in URL angegeben
|
||
+ selected_customer_id = request.args.get('customer_id', type=int)
|
||
+ licenses = []
|
||
+ selected_customer = None
|
||
+
|
||
+ if customers and selected_customer_id:
|
||
+ # Hole Daten des ausgewählten Kunden
|
||
+ for customer in customers:
|
||
+ if customer[0] == selected_customer_id:
|
||
+ selected_customer = customer
|
||
+ break
|
||
+
|
||
+ # Hole Lizenzen des ausgewählten Kunden
|
||
+ if selected_customer:
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ l.id,
|
||
+ l.license_key,
|
||
+ l.license_type,
|
||
+ l.valid_from,
|
||
+ l.valid_until,
|
||
+ l.is_active,
|
||
+ CASE
|
||
+ WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
+ ELSE 'aktiv'
|
||
+ END as status,
|
||
+ l.domain_count,
|
||
+ l.ipv4_count,
|
||
+ l.phone_count,
|
||
+ l.device_limit,
|
||
+ (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices,
|
||
+ -- Actual resource counts
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count,
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count,
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count
|
||
+ FROM licenses l
|
||
+ WHERE l.customer_id = %s
|
||
+ ORDER BY l.created_at DESC, l.id DESC
|
||
+ """, (selected_customer_id,))
|
||
+ licenses = cur.fetchall()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template("customers_licenses.html",
|
||
+ customers=customers,
|
||
+ selected_customer=selected_customer,
|
||
+ selected_customer_id=selected_customer_id,
|
||
+ licenses=licenses,
|
||
+ show_test=show_test)
|
||
+
|
||
+@app.route("/api/customer/<int:customer_id>/licenses")
|
||
+@login_required
|
||
+def api_customer_licenses(customer_id):
|
||
+ """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Hole Lizenzen des Kunden
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ l.id,
|
||
+ l.license_key,
|
||
+ l.license_type,
|
||
+ l.valid_from,
|
||
+ l.valid_until,
|
||
+ l.is_active,
|
||
+ CASE
|
||
+ WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
+ ELSE 'aktiv'
|
||
+ END as status,
|
||
+ l.domain_count,
|
||
+ l.ipv4_count,
|
||
+ l.phone_count,
|
||
+ l.device_limit,
|
||
+ (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices,
|
||
+ -- Actual resource counts
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count,
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count,
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count
|
||
+ FROM licenses l
|
||
+ WHERE l.customer_id = %s
|
||
+ ORDER BY l.created_at DESC, l.id DESC
|
||
+ """, (customer_id,))
|
||
+
|
||
+ licenses = []
|
||
+ for row in cur.fetchall():
|
||
+ license_id = row[0]
|
||
+
|
||
+ # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz
|
||
+ cur.execute("""
|
||
+ SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at
|
||
+ FROM resource_pools rp
|
||
+ JOIN license_resources lr ON rp.id = lr.resource_id
|
||
+ WHERE lr.license_id = %s AND lr.is_active = true
|
||
+ ORDER BY rp.resource_type, rp.resource_value
|
||
+ """, (license_id,))
|
||
+
|
||
+ resources = {
|
||
+ 'domains': [],
|
||
+ 'ipv4s': [],
|
||
+ 'phones': []
|
||
+ }
|
||
+
|
||
+ for res_row in cur.fetchall():
|
||
+ resource_info = {
|
||
+ 'id': res_row[0],
|
||
+ 'value': res_row[2],
|
||
+ 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else ''
|
||
+ }
|
||
+
|
||
+ if res_row[1] == 'domain':
|
||
+ resources['domains'].append(resource_info)
|
||
+ elif res_row[1] == 'ipv4':
|
||
+ resources['ipv4s'].append(resource_info)
|
||
+ elif res_row[1] == 'phone':
|
||
+ resources['phones'].append(resource_info)
|
||
+
|
||
+ licenses.append({
|
||
+ 'id': row[0],
|
||
+ 'license_key': row[1],
|
||
+ 'license_type': row[2],
|
||
+ 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '',
|
||
+ 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '',
|
||
+ 'is_active': row[5],
|
||
+ 'status': row[6],
|
||
+ 'domain_count': row[7], # limit
|
||
+ 'ipv4_count': row[8], # limit
|
||
+ 'phone_count': row[9], # limit
|
||
+ 'device_limit': row[10],
|
||
+ 'active_devices': row[11],
|
||
+ 'actual_domain_count': row[12], # actual count
|
||
+ 'actual_ipv4_count': row[13], # actual count
|
||
+ 'actual_phone_count': row[14], # actual count
|
||
+ 'resources': resources
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'licenses': licenses,
|
||
+ 'count': len(licenses)
|
||
+ })
|
||
+
|
||
+@app.route("/api/customer/<int:customer_id>/quick-stats")
|
||
+@login_required
|
||
+def api_customer_quick_stats(customer_id):
|
||
+ """API-Endpoint für Schnellstatistiken eines Kunden"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Hole Kundenstatistiken
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ COUNT(l.id) as total_licenses,
|
||
+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses,
|
||
+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon
|
||
+ FROM licenses l
|
||
+ WHERE l.customer_id = %s
|
||
+ """, (customer_id,))
|
||
+
|
||
+ stats = cur.fetchone()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'stats': {
|
||
+ 'total': stats[0],
|
||
+ 'active': stats[1],
|
||
+ 'expired': stats[2],
|
||
+ 'expiring_soon': stats[3]
|
||
+ }
|
||
+ })
|
||
+
|
||
+@app.route("/api/license/<int:license_id>/quick-edit", methods=['POST'])
|
||
+@login_required
|
||
+def api_license_quick_edit(license_id):
|
||
+ """API-Endpoint für schnelle Lizenz-Bearbeitung"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+
|
||
+ # Hole alte Werte für Audit-Log
|
||
+ cur.execute("""
|
||
+ SELECT is_active, valid_until, license_type
|
||
+ FROM licenses WHERE id = %s
|
||
+ """, (license_id,))
|
||
+ old_values = cur.fetchone()
|
||
+
|
||
+ if not old_values:
|
||
+ return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404
|
||
+
|
||
+ # Update-Felder vorbereiten
|
||
+ updates = []
|
||
+ params = []
|
||
+ new_values = {}
|
||
+
|
||
+ if 'is_active' in data:
|
||
+ updates.append("is_active = %s")
|
||
+ params.append(data['is_active'])
|
||
+ new_values['is_active'] = data['is_active']
|
||
+
|
||
+ if 'valid_until' in data:
|
||
+ updates.append("valid_until = %s")
|
||
+ params.append(data['valid_until'])
|
||
+ new_values['valid_until'] = data['valid_until']
|
||
+
|
||
+ if 'license_type' in data:
|
||
+ updates.append("license_type = %s")
|
||
+ params.append(data['license_type'])
|
||
+ new_values['license_type'] = data['license_type']
|
||
+
|
||
+ if updates:
|
||
+ params.append(license_id)
|
||
+ cur.execute(f"""
|
||
+ UPDATE licenses
|
||
+ SET {', '.join(updates)}
|
||
+ WHERE id = %s
|
||
+ """, params)
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('UPDATE', 'license', license_id,
|
||
+ old_values={
|
||
+ 'is_active': old_values[0],
|
||
+ 'valid_until': old_values[1].isoformat() if old_values[1] else None,
|
||
+ 'license_type': old_values[2]
|
||
+ },
|
||
+ new_values=new_values)
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True})
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+ return jsonify({'success': False, 'error': str(e)}), 500
|
||
+
|
||
+@app.route("/api/license/<int:license_id>/resources")
|
||
+@login_required
|
||
+def api_license_resources(license_id):
|
||
+ """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz
|
||
+ cur.execute("""
|
||
+ SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at
|
||
+ FROM resource_pools rp
|
||
+ JOIN license_resources lr ON rp.id = lr.resource_id
|
||
+ WHERE lr.license_id = %s AND lr.is_active = true
|
||
+ ORDER BY rp.resource_type, rp.resource_value
|
||
+ """, (license_id,))
|
||
+
|
||
+ resources = {
|
||
+ 'domains': [],
|
||
+ 'ipv4s': [],
|
||
+ 'phones': []
|
||
+ }
|
||
+
|
||
+ for row in cur.fetchall():
|
||
+ resource_info = {
|
||
+ 'id': row[0],
|
||
+ 'value': row[2],
|
||
+ 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else ''
|
||
+ }
|
||
+
|
||
+ if row[1] == 'domain':
|
||
+ resources['domains'].append(resource_info)
|
||
+ elif row[1] == 'ipv4':
|
||
+ resources['ipv4s'].append(resource_info)
|
||
+ elif row[1] == 'phone':
|
||
+ resources['phones'].append(resource_info)
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'resources': resources
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+ return jsonify({'success': False, 'error': str(e)}), 500
|
||
+
|
||
+@app.route("/sessions")
|
||
+@login_required
|
||
+def sessions():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Sortierparameter
|
||
+ active_sort = request.args.get('active_sort', 'last_heartbeat')
|
||
+ active_order = request.args.get('active_order', 'desc')
|
||
+ ended_sort = request.args.get('ended_sort', 'ended_at')
|
||
+ ended_order = request.args.get('ended_order', 'desc')
|
||
+
|
||
+ # Whitelist für erlaubte Sortierfelder - Aktive Sessions
|
||
+ active_sort_fields = {
|
||
+ 'customer': 'c.name',
|
||
+ 'license': 'l.license_key',
|
||
+ 'ip': 's.ip_address',
|
||
+ 'started': 's.started_at',
|
||
+ 'last_heartbeat': 's.last_heartbeat',
|
||
+ 'inactive': 'minutes_inactive'
|
||
+ }
|
||
+
|
||
+ # Whitelist für erlaubte Sortierfelder - Beendete Sessions
|
||
+ ended_sort_fields = {
|
||
+ 'customer': 'c.name',
|
||
+ 'license': 'l.license_key',
|
||
+ 'ip': 's.ip_address',
|
||
+ 'started': 's.started_at',
|
||
+ 'ended_at': 's.ended_at',
|
||
+ 'duration': 'duration_minutes'
|
||
+ }
|
||
+
|
||
+ # Validierung
|
||
+ if active_sort not in active_sort_fields:
|
||
+ active_sort = 'last_heartbeat'
|
||
+ if ended_sort not in ended_sort_fields:
|
||
+ ended_sort = 'ended_at'
|
||
+ if active_order not in ['asc', 'desc']:
|
||
+ active_order = 'desc'
|
||
+ if ended_order not in ['asc', 'desc']:
|
||
+ ended_order = 'desc'
|
||
+
|
||
+ # Aktive Sessions abrufen
|
||
+ cur.execute(f"""
|
||
+ SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address,
|
||
+ s.user_agent, s.started_at, s.last_heartbeat,
|
||
+ EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive
|
||
+ FROM sessions s
|
||
+ JOIN licenses l ON s.license_id = l.id
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE s.is_active = TRUE
|
||
+ ORDER BY {active_sort_fields[active_sort]} {active_order.upper()}
|
||
+ """)
|
||
+ active_sessions = cur.fetchall()
|
||
+
|
||
+ # Inaktive Sessions der letzten 24 Stunden
|
||
+ cur.execute(f"""
|
||
+ SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address,
|
||
+ s.started_at, s.ended_at,
|
||
+ EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes
|
||
+ FROM sessions s
|
||
+ JOIN licenses l ON s.license_id = l.id
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE s.is_active = FALSE
|
||
+ AND s.ended_at > NOW() - INTERVAL '24 hours'
|
||
+ ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()}
|
||
+ LIMIT 50
|
||
+ """)
|
||
+ recent_sessions = cur.fetchall()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template("sessions.html",
|
||
+ active_sessions=active_sessions,
|
||
+ recent_sessions=recent_sessions,
|
||
+ active_sort=active_sort,
|
||
+ active_order=active_order,
|
||
+ ended_sort=ended_sort,
|
||
+ ended_order=ended_order,
|
||
+ username=session.get('username'))
|
||
+
|
||
+@app.route("/session/end/<int:session_id>", methods=["POST"])
|
||
+@login_required
|
||
+def end_session(session_id):
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Session beenden
|
||
+ cur.execute("""
|
||
+ UPDATE sessions
|
||
+ SET is_active = FALSE, ended_at = NOW()
|
||
+ WHERE id = %s AND is_active = TRUE
|
||
+ """, (session_id,))
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return redirect("/sessions")
|
||
+
|
||
+@app.route("/export/licenses")
|
||
+@login_required
|
||
+def export_licenses():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht)
|
||
+ include_test = request.args.get('include_test', 'false').lower() == 'true'
|
||
+ customer_id = request.args.get('customer_id', type=int)
|
||
+
|
||
+ query = """
|
||
+ SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email,
|
||
+ l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test,
|
||
+ CASE
|
||
+ WHEN l.is_active = FALSE THEN 'Deaktiviert'
|
||
+ WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen'
|
||
+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab'
|
||
+ ELSE 'Aktiv'
|
||
+ END as status
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ """
|
||
+
|
||
+ # Build WHERE clause
|
||
+ where_conditions = []
|
||
+ params = []
|
||
+
|
||
+ if not include_test:
|
||
+ where_conditions.append("l.is_test = FALSE")
|
||
+
|
||
+ if customer_id:
|
||
+ where_conditions.append("l.customer_id = %s")
|
||
+ params.append(customer_id)
|
||
+
|
||
+ if where_conditions:
|
||
+ query += " WHERE " + " AND ".join(where_conditions)
|
||
+
|
||
+ query += " ORDER BY l.id"
|
||
+
|
||
+ cur.execute(query, params)
|
||
+
|
||
+ # Spaltennamen
|
||
+ columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ',
|
||
+ 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status']
|
||
+
|
||
+ # Daten in DataFrame
|
||
+ data = cur.fetchall()
|
||
+ df = pd.DataFrame(data, columns=columns)
|
||
+
|
||
+ # Datumsformatierung
|
||
+ df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y')
|
||
+ df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y')
|
||
+
|
||
+ # Typ und Aktiv Status anpassen
|
||
+ df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'})
|
||
+ df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'})
|
||
+ df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'})
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Export Format
|
||
+ export_format = request.args.get('format', 'excel')
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('EXPORT', 'license',
|
||
+ additional_info=f"Export aller Lizenzen als {export_format.upper()}")
|
||
+ filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}'
|
||
+
|
||
+ if export_format == 'csv':
|
||
+ # CSV Export
|
||
+ output = io.StringIO()
|
||
+ df.to_csv(output, index=False, encoding='utf-8-sig', sep=';')
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
+ mimetype='text/csv',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv'
|
||
+ )
|
||
+ else:
|
||
+ # Excel Export
|
||
+ output = io.BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, sheet_name='Lizenzen', index=False)
|
||
+
|
||
+ # Formatierung
|
||
+ worksheet = writer.sheets['Lizenzen']
|
||
+ for column in worksheet.columns:
|
||
+ max_length = 0
|
||
+ column_letter = column[0].column_letter
|
||
+ for cell in column:
|
||
+ try:
|
||
+ if len(str(cell.value)) > max_length:
|
||
+ max_length = len(str(cell.value))
|
||
+ except:
|
||
+ pass
|
||
+ adjusted_width = min(max_length + 2, 50)
|
||
+ worksheet.column_dimensions[column_letter].width = adjusted_width
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx'
|
||
+ )
|
||
+
|
||
+@app.route("/export/audit")
|
||
+@login_required
|
||
+def export_audit():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Holen der Filter-Parameter
|
||
+ filter_user = request.args.get('user', '')
|
||
+ filter_action = request.args.get('action', '')
|
||
+ filter_entity = request.args.get('entity', '')
|
||
+ export_format = request.args.get('format', 'excel')
|
||
+
|
||
+ # SQL Query mit Filtern
|
||
+ query = """
|
||
+ SELECT id, timestamp, username, action, entity_type, entity_id,
|
||
+ old_values, new_values, ip_address, user_agent, additional_info
|
||
+ FROM audit_log
|
||
+ WHERE 1=1
|
||
+ """
|
||
+ params = []
|
||
+
|
||
+ if filter_user:
|
||
+ query += " AND username ILIKE %s"
|
||
+ params.append(f'%{filter_user}%')
|
||
+
|
||
+ if filter_action:
|
||
+ query += " AND action = %s"
|
||
+ params.append(filter_action)
|
||
+
|
||
+ if filter_entity:
|
||
+ query += " AND entity_type = %s"
|
||
+ params.append(filter_entity)
|
||
+
|
||
+ query += " ORDER BY timestamp DESC"
|
||
+
|
||
+ cur.execute(query, params)
|
||
+ audit_logs = cur.fetchall()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Daten für Export vorbereiten
|
||
+ data = []
|
||
+ for log in audit_logs:
|
||
+ action_text = {
|
||
+ 'CREATE': 'Erstellt',
|
||
+ 'UPDATE': 'Bearbeitet',
|
||
+ 'DELETE': 'Gelöscht',
|
||
+ 'LOGIN': 'Anmeldung',
|
||
+ 'LOGOUT': 'Abmeldung',
|
||
+ 'AUTO_LOGOUT': 'Auto-Logout',
|
||
+ 'EXPORT': 'Export',
|
||
+ 'GENERATE_KEY': 'Key generiert',
|
||
+ 'CREATE_BATCH': 'Batch erstellt',
|
||
+ 'BACKUP': 'Backup erstellt',
|
||
+ 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung',
|
||
+ 'LOGIN_2FA_BACKUP': '2FA-Backup-Code',
|
||
+ 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen',
|
||
+ 'LOGIN_BLOCKED': 'Login-Blockiert',
|
||
+ 'RESTORE': 'Wiederhergestellt',
|
||
+ 'PASSWORD_CHANGE': 'Passwort geändert',
|
||
+ '2FA_ENABLED': '2FA aktiviert',
|
||
+ '2FA_DISABLED': '2FA deaktiviert'
|
||
+ }.get(log[3], log[3])
|
||
+
|
||
+ data.append({
|
||
+ 'ID': log[0],
|
||
+ 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'),
|
||
+ 'Benutzer': log[2],
|
||
+ 'Aktion': action_text,
|
||
+ 'Entität': log[4],
|
||
+ 'Entität-ID': log[5] or '',
|
||
+ 'IP-Adresse': log[8] or '',
|
||
+ 'Zusatzinfo': log[10] or ''
|
||
+ })
|
||
+
|
||
+ # DataFrame erstellen
|
||
+ df = pd.DataFrame(data)
|
||
+
|
||
+ # Timestamp für Dateiname
|
||
+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
+ filename = f'audit_log_export_{timestamp}'
|
||
+
|
||
+ # Audit Log für Export
|
||
+ log_audit('EXPORT', 'audit_log',
|
||
+ additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen")
|
||
+
|
||
+ if export_format == 'csv':
|
||
+ # CSV Export
|
||
+ output = io.StringIO()
|
||
+ # UTF-8 BOM für Excel
|
||
+ output.write('\ufeff')
|
||
+ df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8')),
|
||
+ mimetype='text/csv;charset=utf-8',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv'
|
||
+ )
|
||
+ else:
|
||
+ # Excel Export
|
||
+ output = BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, index=False, sheet_name='Audit Log')
|
||
+
|
||
+ # Spaltenbreiten anpassen
|
||
+ worksheet = writer.sheets['Audit Log']
|
||
+ for idx, col in enumerate(df.columns):
|
||
+ max_length = max(
|
||
+ df[col].astype(str).map(len).max(),
|
||
+ len(col)
|
||
+ ) + 2
|
||
+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx'
|
||
+ )
|
||
+
|
||
+@app.route("/export/customers")
|
||
+@login_required
|
||
+def export_customers():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Check if test data should be included
|
||
+ include_test = request.args.get('include_test', 'false').lower() == 'true'
|
||
+
|
||
+ # Build query based on test data filter
|
||
+ if include_test:
|
||
+ # Include all customers
|
||
+ query = """
|
||
+ SELECT c.id, c.name, c.email, c.created_at, c.is_test,
|
||
+ COUNT(l.id) as total_licenses,
|
||
+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ GROUP BY c.id, c.name, c.email, c.created_at, c.is_test
|
||
+ ORDER BY c.id
|
||
+ """
|
||
+ else:
|
||
+ # Exclude test customers and test licenses
|
||
+ query = """
|
||
+ SELECT c.id, c.name, c.email, c.created_at, c.is_test,
|
||
+ COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses,
|
||
+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses,
|
||
+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ WHERE c.is_test = FALSE
|
||
+ GROUP BY c.id, c.name, c.email, c.created_at, c.is_test
|
||
+ ORDER BY c.id
|
||
+ """
|
||
+
|
||
+ cur.execute(query)
|
||
+
|
||
+ # Spaltennamen
|
||
+ columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten',
|
||
+ 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen']
|
||
+
|
||
+ # Daten in DataFrame
|
||
+ data = cur.fetchall()
|
||
+ df = pd.DataFrame(data, columns=columns)
|
||
+
|
||
+ # Datumsformatierung
|
||
+ df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M')
|
||
+
|
||
+ # Testdaten formatting
|
||
+ df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'})
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Export Format
|
||
+ export_format = request.args.get('format', 'excel')
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('EXPORT', 'customer',
|
||
+ additional_info=f"Export aller Kunden als {export_format.upper()}")
|
||
+ filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}'
|
||
+
|
||
+ if export_format == 'csv':
|
||
+ # CSV Export
|
||
+ output = io.StringIO()
|
||
+ df.to_csv(output, index=False, encoding='utf-8-sig', sep=';')
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
+ mimetype='text/csv',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv'
|
||
+ )
|
||
+ else:
|
||
+ # Excel Export
|
||
+ output = io.BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, sheet_name='Kunden', index=False)
|
||
+
|
||
+ # Formatierung
|
||
+ worksheet = writer.sheets['Kunden']
|
||
+ for column in worksheet.columns:
|
||
+ max_length = 0
|
||
+ column_letter = column[0].column_letter
|
||
+ for cell in column:
|
||
+ try:
|
||
+ if len(str(cell.value)) > max_length:
|
||
+ max_length = len(str(cell.value))
|
||
+ except:
|
||
+ pass
|
||
+ adjusted_width = min(max_length + 2, 50)
|
||
+ worksheet.column_dimensions[column_letter].width = adjusted_width
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx'
|
||
+ )
|
||
+
|
||
+@app.route("/export/sessions")
|
||
+@login_required
|
||
+def export_sessions():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Holen des Session-Typs (active oder ended)
|
||
+ session_type = request.args.get('type', 'active')
|
||
+ export_format = request.args.get('format', 'excel')
|
||
+
|
||
+ # Daten je nach Typ abrufen
|
||
+ if session_type == 'active':
|
||
+ # Aktive Lizenz-Sessions
|
||
+ cur.execute("""
|
||
+ SELECT s.id, l.license_key, c.name as customer_name, s.session_id,
|
||
+ s.started_at, s.last_heartbeat,
|
||
+ EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds,
|
||
+ s.ip_address, s.user_agent
|
||
+ FROM sessions s
|
||
+ JOIN licenses l ON s.license_id = l.id
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE s.is_active = true
|
||
+ ORDER BY s.last_heartbeat DESC
|
||
+ """)
|
||
+ sessions = cur.fetchall()
|
||
+
|
||
+ # Daten für Export vorbereiten
|
||
+ data = []
|
||
+ for sess in sessions:
|
||
+ duration = sess[6]
|
||
+ hours = duration // 3600
|
||
+ minutes = (duration % 3600) // 60
|
||
+ seconds = duration % 60
|
||
+
|
||
+ data.append({
|
||
+ 'Session-ID': sess[0],
|
||
+ 'Lizenzschlüssel': sess[1],
|
||
+ 'Kunde': sess[2],
|
||
+ 'Session-ID (Tech)': sess[3],
|
||
+ 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'),
|
||
+ 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'),
|
||
+ 'Dauer': f"{hours}h {minutes}m {seconds}s",
|
||
+ 'IP-Adresse': sess[7],
|
||
+ 'Browser': sess[8]
|
||
+ })
|
||
+
|
||
+ sheet_name = 'Aktive Sessions'
|
||
+ filename_prefix = 'aktive_sessions'
|
||
+ else:
|
||
+ # Beendete Lizenz-Sessions
|
||
+ cur.execute("""
|
||
+ SELECT s.id, l.license_key, c.name as customer_name, s.session_id,
|
||
+ s.started_at, s.ended_at,
|
||
+ EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds,
|
||
+ s.ip_address, s.user_agent
|
||
+ FROM sessions s
|
||
+ JOIN licenses l ON s.license_id = l.id
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE s.is_active = false AND s.ended_at IS NOT NULL
|
||
+ ORDER BY s.ended_at DESC
|
||
+ LIMIT 1000
|
||
+ """)
|
||
+ sessions = cur.fetchall()
|
||
+
|
||
+ # Daten für Export vorbereiten
|
||
+ data = []
|
||
+ for sess in sessions:
|
||
+ duration = sess[6] if sess[6] else 0
|
||
+ hours = duration // 3600
|
||
+ minutes = (duration % 3600) // 60
|
||
+ seconds = duration % 60
|
||
+
|
||
+ data.append({
|
||
+ 'Session-ID': sess[0],
|
||
+ 'Lizenzschlüssel': sess[1],
|
||
+ 'Kunde': sess[2],
|
||
+ 'Session-ID (Tech)': sess[3],
|
||
+ 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'),
|
||
+ 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '',
|
||
+ 'Dauer': f"{hours}h {minutes}m {seconds}s",
|
||
+ 'IP-Adresse': sess[7],
|
||
+ 'Browser': sess[8]
|
||
+ })
|
||
+
|
||
+ sheet_name = 'Beendete Sessions'
|
||
+ filename_prefix = 'beendete_sessions'
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # DataFrame erstellen
|
||
+ df = pd.DataFrame(data)
|
||
+
|
||
+ # Timestamp für Dateiname
|
||
+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
+ filename = f'{filename_prefix}_export_{timestamp}'
|
||
+
|
||
+ # Audit Log für Export
|
||
+ log_audit('EXPORT', 'sessions',
|
||
+ additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen")
|
||
+
|
||
+ if export_format == 'csv':
|
||
+ # CSV Export
|
||
+ output = io.StringIO()
|
||
+ # UTF-8 BOM für Excel
|
||
+ output.write('\ufeff')
|
||
+ df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8')),
|
||
+ mimetype='text/csv;charset=utf-8',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv'
|
||
+ )
|
||
+ else:
|
||
+ # Excel Export
|
||
+ output = BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, index=False, sheet_name=sheet_name)
|
||
+
|
||
+ # Spaltenbreiten anpassen
|
||
+ worksheet = writer.sheets[sheet_name]
|
||
+ for idx, col in enumerate(df.columns):
|
||
+ max_length = max(
|
||
+ df[col].astype(str).map(len).max(),
|
||
+ len(col)
|
||
+ ) + 2
|
||
+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx'
|
||
+ )
|
||
+
|
||
+@app.route("/export/resources")
|
||
+@login_required
|
||
+def export_resources():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Holen der Filter-Parameter
|
||
+ filter_type = request.args.get('type', '')
|
||
+ filter_status = request.args.get('status', '')
|
||
+ search_query = request.args.get('search', '')
|
||
+ show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
+ export_format = request.args.get('format', 'excel')
|
||
+
|
||
+ # SQL Query mit Filtern
|
||
+ query = """
|
||
+ SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license,
|
||
+ r.created_at, r.status_changed_at,
|
||
+ l.license_key, c.name as customer_name, c.email as customer_email,
|
||
+ l.license_type
|
||
+ FROM resource_pools r
|
||
+ LEFT JOIN licenses l ON r.allocated_to_license = l.id
|
||
+ LEFT JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE 1=1
|
||
+ """
|
||
+ params = []
|
||
+
|
||
+ # Filter für Testdaten
|
||
+ if not show_test:
|
||
+ query += " AND (r.is_test = false OR r.is_test IS NULL)"
|
||
+
|
||
+ # Filter für Ressourcentyp
|
||
+ if filter_type:
|
||
+ query += " AND r.resource_type = %s"
|
||
+ params.append(filter_type)
|
||
+
|
||
+ # Filter für Status
|
||
+ if filter_status:
|
||
+ query += " AND r.status = %s"
|
||
+ params.append(filter_status)
|
||
+
|
||
+ # Suchfilter
|
||
+ if search_query:
|
||
+ query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)"
|
||
+ params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%'])
|
||
+
|
||
+ query += " ORDER BY r.id DESC"
|
||
+
|
||
+ cur.execute(query, params)
|
||
+ resources = cur.fetchall()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Daten für Export vorbereiten
|
||
+ data = []
|
||
+ for res in resources:
|
||
+ status_text = {
|
||
+ 'available': 'Verfügbar',
|
||
+ 'allocated': 'Zugewiesen',
|
||
+ 'quarantine': 'Quarantäne'
|
||
+ }.get(res[3], res[3])
|
||
+
|
||
+ type_text = {
|
||
+ 'domain': 'Domain',
|
||
+ 'ipv4': 'IPv4',
|
||
+ 'phone': 'Telefon'
|
||
+ }.get(res[1], res[1])
|
||
+
|
||
+ data.append({
|
||
+ 'ID': res[0],
|
||
+ 'Typ': type_text,
|
||
+ 'Ressource': res[2],
|
||
+ 'Status': status_text,
|
||
+ 'Lizenzschlüssel': res[7] or '',
|
||
+ 'Kunde': res[8] or '',
|
||
+ 'Kunden-Email': res[9] or '',
|
||
+ 'Lizenztyp': res[10] or '',
|
||
+ 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '',
|
||
+ 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else ''
|
||
+ })
|
||
+
|
||
+ # DataFrame erstellen
|
||
+ df = pd.DataFrame(data)
|
||
+
|
||
+ # Timestamp für Dateiname
|
||
+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
+ filename = f'resources_export_{timestamp}'
|
||
+
|
||
+ # Audit Log für Export
|
||
+ log_audit('EXPORT', 'resources',
|
||
+ additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen")
|
||
+
|
||
+ if export_format == 'csv':
|
||
+ # CSV Export
|
||
+ output = io.StringIO()
|
||
+ # UTF-8 BOM für Excel
|
||
+ output.write('\ufeff')
|
||
+ df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8')),
|
||
+ mimetype='text/csv;charset=utf-8',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv'
|
||
+ )
|
||
+ else:
|
||
+ # Excel Export
|
||
+ output = BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, index=False, sheet_name='Resources')
|
||
+
|
||
+ # Spaltenbreiten anpassen
|
||
+ worksheet = writer.sheets['Resources']
|
||
+ for idx, col in enumerate(df.columns):
|
||
+ max_length = max(
|
||
+ df[col].astype(str).map(len).max(),
|
||
+ len(col)
|
||
+ ) + 2
|
||
+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx'
|
||
+ )
|
||
+
|
||
+@app.route("/audit")
|
||
+@login_required
|
||
+def audit_log():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Parameter
|
||
+ filter_user = request.args.get('user', '').strip()
|
||
+ filter_action = request.args.get('action', '').strip()
|
||
+ filter_entity = request.args.get('entity', '').strip()
|
||
+ page = request.args.get('page', 1, type=int)
|
||
+ sort = request.args.get('sort', 'timestamp')
|
||
+ order = request.args.get('order', 'desc')
|
||
+ per_page = 50
|
||
+
|
||
+ # Whitelist für erlaubte Sortierfelder
|
||
+ allowed_sort_fields = {
|
||
+ 'timestamp': 'timestamp',
|
||
+ 'username': 'username',
|
||
+ 'action': 'action',
|
||
+ 'entity': 'entity_type',
|
||
+ 'ip': 'ip_address'
|
||
+ }
|
||
+
|
||
+ # Validierung
|
||
+ if sort not in allowed_sort_fields:
|
||
+ sort = 'timestamp'
|
||
+ if order not in ['asc', 'desc']:
|
||
+ order = 'desc'
|
||
+
|
||
+ sort_field = allowed_sort_fields[sort]
|
||
+
|
||
+ # SQL Query mit optionalen Filtern
|
||
+ query = """
|
||
+ SELECT id, timestamp, username, action, entity_type, entity_id,
|
||
+ old_values, new_values, ip_address, user_agent, additional_info
|
||
+ FROM audit_log
|
||
+ WHERE 1=1
|
||
+ """
|
||
+
|
||
+ params = []
|
||
+
|
||
+ # Filter
|
||
+ if filter_user:
|
||
+ query += " AND LOWER(username) LIKE LOWER(%s)"
|
||
+ params.append(f'%{filter_user}%')
|
||
+
|
||
+ if filter_action:
|
||
+ query += " AND action = %s"
|
||
+ params.append(filter_action)
|
||
+
|
||
+ if filter_entity:
|
||
+ query += " AND entity_type = %s"
|
||
+ params.append(filter_entity)
|
||
+
|
||
+ # Gesamtanzahl für Pagination
|
||
+ count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table"
|
||
+ cur.execute(count_query, params)
|
||
+ total = cur.fetchone()[0]
|
||
+
|
||
+ # Pagination
|
||
+ offset = (page - 1) * per_page
|
||
+ query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s"
|
||
+ params.extend([per_page, offset])
|
||
+
|
||
+ cur.execute(query, params)
|
||
+ logs = cur.fetchall()
|
||
+
|
||
+ # JSON-Werte parsen
|
||
+ parsed_logs = []
|
||
+ for log in logs:
|
||
+ parsed_log = list(log)
|
||
+ # old_values und new_values sind bereits Dictionaries (JSONB)
|
||
+ # Keine Konvertierung nötig
|
||
+ parsed_logs.append(parsed_log)
|
||
+
|
||
+ # Pagination Info
|
||
+ total_pages = (total + per_page - 1) // per_page
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template("audit_log.html",
|
||
+ logs=parsed_logs,
|
||
+ filter_user=filter_user,
|
||
+ filter_action=filter_action,
|
||
+ filter_entity=filter_entity,
|
||
+ page=page,
|
||
+ total_pages=total_pages,
|
||
+ total=total,
|
||
+ sort=sort,
|
||
+ order=order,
|
||
+ username=session.get('username'))
|
||
+
|
||
+@app.route("/backups")
|
||
+@login_required
|
||
+def backups():
|
||
+ """Zeigt die Backup-Historie an"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Letztes erfolgreiches Backup für Dashboard
|
||
+ cur.execute("""
|
||
+ SELECT created_at, filesize, duration_seconds
|
||
+ FROM backup_history
|
||
+ WHERE status = 'success'
|
||
+ ORDER BY created_at DESC
|
||
+ LIMIT 1
|
||
+ """)
|
||
+ last_backup = cur.fetchone()
|
||
+
|
||
+ # Alle Backups abrufen
|
||
+ cur.execute("""
|
||
+ SELECT id, filename, filesize, backup_type, status, error_message,
|
||
+ created_at, created_by, tables_count, records_count,
|
||
+ duration_seconds, is_encrypted
|
||
+ FROM backup_history
|
||
+ ORDER BY created_at DESC
|
||
+ """)
|
||
+ backups = cur.fetchall()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template("backups.html",
|
||
+ backups=backups,
|
||
+ last_backup=last_backup,
|
||
+ username=session.get('username'))
|
||
+
|
||
+@app.route("/backup/create", methods=["POST"])
|
||
+@login_required
|
||
+def create_backup_route():
|
||
+ """Erstellt ein manuelles Backup"""
|
||
+ username = session.get('username')
|
||
+ success, result = create_backup(backup_type="manual", created_by=username)
|
||
+
|
||
+ if success:
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'message': f'Backup erfolgreich erstellt: {result}'
|
||
+ })
|
||
+ else:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': f'Backup fehlgeschlagen: {result}'
|
||
+ }), 500
|
||
+
|
||
+@app.route("/backup/restore/<int:backup_id>", methods=["POST"])
|
||
+@login_required
|
||
+def restore_backup_route(backup_id):
|
||
+ """Stellt ein Backup wieder her"""
|
||
+ encryption_key = request.form.get('encryption_key')
|
||
+
|
||
+ success, message = restore_backup(backup_id, encryption_key)
|
||
+
|
||
+ if success:
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'message': message
|
||
+ })
|
||
+ else:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': message
|
||
+ }), 500
|
||
+
|
||
+@app.route("/backup/download/<int:backup_id>")
|
||
+@login_required
|
||
+def download_backup(backup_id):
|
||
+ """Lädt eine Backup-Datei herunter"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT filename, filepath
|
||
+ FROM backup_history
|
||
+ WHERE id = %s
|
||
+ """, (backup_id,))
|
||
+ backup_info = cur.fetchone()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ if not backup_info:
|
||
+ return "Backup nicht gefunden", 404
|
||
+
|
||
+ filename, filepath = backup_info
|
||
+ filepath = Path(filepath)
|
||
+
|
||
+ if not filepath.exists():
|
||
+ return "Backup-Datei nicht gefunden", 404
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('DOWNLOAD', 'backup', backup_id,
|
||
+ additional_info=f"Backup heruntergeladen: {filename}")
|
||
+
|
||
+ return send_file(filepath, as_attachment=True, download_name=filename)
|
||
+
|
||
+@app.route("/backup/delete/<int:backup_id>", methods=["DELETE"])
|
||
+@login_required
|
||
+def delete_backup(backup_id):
|
||
+ """Löscht ein Backup"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Backup-Informationen abrufen
|
||
+ cur.execute("""
|
||
+ SELECT filename, filepath
|
||
+ FROM backup_history
|
||
+ WHERE id = %s
|
||
+ """, (backup_id,))
|
||
+ backup_info = cur.fetchone()
|
||
+
|
||
+ if not backup_info:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': 'Backup nicht gefunden'
|
||
+ }), 404
|
||
+
|
||
+ filename, filepath = backup_info
|
||
+ filepath = Path(filepath)
|
||
+
|
||
+ # Datei löschen, wenn sie existiert
|
||
+ if filepath.exists():
|
||
+ filepath.unlink()
|
||
+
|
||
+ # Aus Datenbank löschen
|
||
+ cur.execute("""
|
||
+ DELETE FROM backup_history
|
||
+ WHERE id = %s
|
||
+ """, (backup_id,))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('DELETE', 'backup', backup_id,
|
||
+ additional_info=f"Backup gelöscht: {filename}")
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'message': f'Backup "{filename}" wurde erfolgreich gelöscht'
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': f'Fehler beim Löschen des Backups: {str(e)}'
|
||
+ }), 500
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+@app.route("/security/blocked-ips")
|
||
+@login_required
|
||
+def blocked_ips():
|
||
+ """Zeigt alle gesperrten IPs an"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ ip_address,
|
||
+ attempt_count,
|
||
+ first_attempt,
|
||
+ last_attempt,
|
||
+ blocked_until,
|
||
+ last_username_tried,
|
||
+ last_error_message
|
||
+ FROM login_attempts
|
||
+ WHERE blocked_until IS NOT NULL
|
||
+ ORDER BY blocked_until DESC
|
||
+ """)
|
||
+
|
||
+ blocked_ips_list = []
|
||
+ for ip in cur.fetchall():
|
||
+ blocked_ips_list.append({
|
||
+ 'ip_address': ip[0],
|
||
+ 'attempt_count': ip[1],
|
||
+ 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'),
|
||
+ 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'),
|
||
+ 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'),
|
||
+ 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None),
|
||
+ 'last_username': ip[5],
|
||
+ 'last_error': ip[6]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template("blocked_ips.html",
|
||
+ blocked_ips=blocked_ips_list,
|
||
+ username=session.get('username'))
|
||
+
|
||
+@app.route("/security/unblock-ip", methods=["POST"])
|
||
+@login_required
|
||
+def unblock_ip():
|
||
+ """Entsperrt eine IP-Adresse"""
|
||
+ ip_address = request.form.get('ip_address')
|
||
+
|
||
+ if ip_address:
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ cur.execute("""
|
||
+ UPDATE login_attempts
|
||
+ SET blocked_until = NULL
|
||
+ WHERE ip_address = %s
|
||
+ """, (ip_address,))
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('UNBLOCK_IP', 'security',
|
||
+ additional_info=f"IP {ip_address} manuell entsperrt")
|
||
+
|
||
+ return redirect(url_for('blocked_ips'))
|
||
+
|
||
+@app.route("/security/clear-attempts", methods=["POST"])
|
||
+@login_required
|
||
+def clear_attempts():
|
||
+ """Löscht alle Login-Versuche für eine IP"""
|
||
+ ip_address = request.form.get('ip_address')
|
||
+
|
||
+ if ip_address:
|
||
+ reset_login_attempts(ip_address)
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('CLEAR_ATTEMPTS', 'security',
|
||
+ additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt")
|
||
+
|
||
+ return redirect(url_for('blocked_ips'))
|
||
+
|
||
+# API Endpoints for License Management
|
||
+@app.route("/api/license/<int:license_id>/toggle", methods=["POST"])
|
||
+@login_required
|
||
+def toggle_license_api(license_id):
|
||
+ """Toggle license active status via API"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ is_active = data.get('is_active', False)
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Update license status
|
||
+ cur.execute("""
|
||
+ UPDATE licenses
|
||
+ SET is_active = %s
|
||
+ WHERE id = %s
|
||
+ """, (is_active, license_id))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Log the action
|
||
+ log_audit('UPDATE', 'license', license_id,
|
||
+ new_values={'is_active': is_active},
|
||
+ additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle")
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': 'Status erfolgreich geändert'})
|
||
+ except Exception as e:
|
||
+ return jsonify({'success': False, 'message': str(e)}), 500
|
||
+
|
||
+@app.route("/api/licenses/bulk-activate", methods=["POST"])
|
||
+@login_required
|
||
+def bulk_activate_licenses():
|
||
+ """Activate multiple licenses at once"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ license_ids = data.get('ids', [])
|
||
+
|
||
+ if not license_ids:
|
||
+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Update all selected licenses (nur Live-Daten)
|
||
+ cur.execute("""
|
||
+ UPDATE licenses
|
||
+ SET is_active = TRUE
|
||
+ WHERE id = ANY(%s) AND is_test = FALSE
|
||
+ """, (license_ids,))
|
||
+
|
||
+ affected_rows = cur.rowcount
|
||
+ conn.commit()
|
||
+
|
||
+ # Log the bulk action
|
||
+ log_audit('BULK_UPDATE', 'licenses', None,
|
||
+ new_values={'is_active': True, 'count': affected_rows},
|
||
+ additional_info=f"{affected_rows} Lizenzen aktiviert")
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'})
|
||
+ except Exception as e:
|
||
+ return jsonify({'success': False, 'message': str(e)}), 500
|
||
+
|
||
+@app.route("/api/licenses/bulk-deactivate", methods=["POST"])
|
||
+@login_required
|
||
+def bulk_deactivate_licenses():
|
||
+ """Deactivate multiple licenses at once"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ license_ids = data.get('ids', [])
|
||
+
|
||
+ if not license_ids:
|
||
+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Update all selected licenses (nur Live-Daten)
|
||
+ cur.execute("""
|
||
+ UPDATE licenses
|
||
+ SET is_active = FALSE
|
||
+ WHERE id = ANY(%s) AND is_test = FALSE
|
||
+ """, (license_ids,))
|
||
+
|
||
+ affected_rows = cur.rowcount
|
||
+ conn.commit()
|
||
+
|
||
+ # Log the bulk action
|
||
+ log_audit('BULK_UPDATE', 'licenses', None,
|
||
+ new_values={'is_active': False, 'count': affected_rows},
|
||
+ additional_info=f"{affected_rows} Lizenzen deaktiviert")
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'})
|
||
+ except Exception as e:
|
||
+ return jsonify({'success': False, 'message': str(e)}), 500
|
||
+
|
||
+@app.route("/api/license/<int:license_id>/devices")
|
||
+@login_required
|
||
+def get_license_devices(license_id):
|
||
+ """Hole alle registrierten Geräte einer Lizenz"""
|
||
+ try:
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Prüfe ob Lizenz existiert und hole device_limit
|
||
+ cur.execute("""
|
||
+ SELECT device_limit FROM licenses WHERE id = %s
|
||
+ """, (license_id,))
|
||
+ license_data = cur.fetchone()
|
||
+
|
||
+ if not license_data:
|
||
+ return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404
|
||
+
|
||
+ device_limit = license_data[0]
|
||
+
|
||
+ # Hole alle Geräte für diese Lizenz
|
||
+ cur.execute("""
|
||
+ SELECT id, hardware_id, device_name, operating_system,
|
||
+ first_seen, last_seen, is_active, ip_address
|
||
+ FROM device_registrations
|
||
+ WHERE license_id = %s
|
||
+ ORDER BY is_active DESC, last_seen DESC
|
||
+ """, (license_id,))
|
||
+
|
||
+ devices = []
|
||
+ for row in cur.fetchall():
|
||
+ devices.append({
|
||
+ 'id': row[0],
|
||
+ 'hardware_id': row[1],
|
||
+ 'device_name': row[2] or 'Unbekanntes Gerät',
|
||
+ 'operating_system': row[3] or 'Unbekannt',
|
||
+ 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '',
|
||
+ 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '',
|
||
+ 'is_active': row[6],
|
||
+ 'ip_address': row[7] or '-'
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'devices': devices,
|
||
+ 'device_limit': device_limit,
|
||
+ 'active_count': sum(1 for d in devices if d['is_active'])
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}")
|
||
+ return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500
|
||
+
|
||
+@app.route("/api/license/<int:license_id>/register-device", methods=["POST"])
|
||
+def register_device(license_id):
|
||
+ """Registriere ein neues Gerät für eine Lizenz"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ hardware_id = data.get('hardware_id')
|
||
+ device_name = data.get('device_name', '')
|
||
+ operating_system = data.get('operating_system', '')
|
||
+
|
||
+ if not hardware_id:
|
||
+ return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Prüfe ob Lizenz existiert und aktiv ist
|
||
+ cur.execute("""
|
||
+ SELECT device_limit, is_active, valid_until
|
||
+ FROM licenses
|
||
+ WHERE id = %s
|
||
+ """, (license_id,))
|
||
+ license_data = cur.fetchone()
|
||
+
|
||
+ if not license_data:
|
||
+ return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404
|
||
+
|
||
+ device_limit, is_active, valid_until = license_data
|
||
+
|
||
+ # Prüfe ob Lizenz aktiv und gültig ist
|
||
+ if not is_active:
|
||
+ return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403
|
||
+
|
||
+ if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date():
|
||
+ return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403
|
||
+
|
||
+ # Prüfe ob Gerät bereits registriert ist
|
||
+ cur.execute("""
|
||
+ SELECT id, is_active FROM device_registrations
|
||
+ WHERE license_id = %s AND hardware_id = %s
|
||
+ """, (license_id, hardware_id))
|
||
+ existing_device = cur.fetchone()
|
||
+
|
||
+ if existing_device:
|
||
+ device_id, is_device_active = existing_device
|
||
+ if is_device_active:
|
||
+ # Gerät ist bereits aktiv, update last_seen
|
||
+ cur.execute("""
|
||
+ UPDATE device_registrations
|
||
+ SET last_seen = CURRENT_TIMESTAMP,
|
||
+ ip_address = %s,
|
||
+ user_agent = %s
|
||
+ WHERE id = %s
|
||
+ """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
+ conn.commit()
|
||
+ return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id})
|
||
+ else:
|
||
+ # Gerät war deaktiviert, prüfe ob wir es reaktivieren können
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM device_registrations
|
||
+ WHERE license_id = %s AND is_active = TRUE
|
||
+ """, (license_id,))
|
||
+ active_count = cur.fetchone()[0]
|
||
+
|
||
+ if active_count >= device_limit:
|
||
+ return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
+
|
||
+ # Reaktiviere das Gerät
|
||
+ cur.execute("""
|
||
+ UPDATE device_registrations
|
||
+ SET is_active = TRUE,
|
||
+ last_seen = CURRENT_TIMESTAMP,
|
||
+ deactivated_at = NULL,
|
||
+ deactivated_by = NULL,
|
||
+ ip_address = %s,
|
||
+ user_agent = %s
|
||
+ WHERE id = %s
|
||
+ """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
+ conn.commit()
|
||
+ return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id})
|
||
+
|
||
+ # Neues Gerät - prüfe Gerätelimit
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM device_registrations
|
||
+ WHERE license_id = %s AND is_active = TRUE
|
||
+ """, (license_id,))
|
||
+ active_count = cur.fetchone()[0]
|
||
+
|
||
+ if active_count >= device_limit:
|
||
+ return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
+
|
||
+ # Registriere neues Gerät
|
||
+ cur.execute("""
|
||
+ INSERT INTO device_registrations
|
||
+ (license_id, hardware_id, device_name, operating_system, ip_address, user_agent)
|
||
+ VALUES (%s, %s, %s, %s, %s, %s)
|
||
+ RETURNING id
|
||
+ """, (license_id, hardware_id, device_name, operating_system,
|
||
+ get_client_ip(), request.headers.get('User-Agent', '')))
|
||
+ device_id = cur.fetchone()[0]
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit Log
|
||
+ log_audit('DEVICE_REGISTER', 'device', device_id,
|
||
+ new_values={'license_id': license_id, 'hardware_id': hardware_id})
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id})
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}")
|
||
+ return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500
|
||
+
|
||
+@app.route("/api/license/<int:license_id>/deactivate-device/<int:device_id>", methods=["POST"])
|
||
+@login_required
|
||
+def deactivate_device(license_id, device_id):
|
||
+ """Deaktiviere ein registriertes Gerät"""
|
||
+ try:
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Prüfe ob das Gerät zu dieser Lizenz gehört
|
||
+ cur.execute("""
|
||
+ SELECT id FROM device_registrations
|
||
+ WHERE id = %s AND license_id = %s AND is_active = TRUE
|
||
+ """, (device_id, license_id))
|
||
+
|
||
+ if not cur.fetchone():
|
||
+ return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404
|
||
+
|
||
+ # Deaktiviere das Gerät
|
||
+ cur.execute("""
|
||
+ UPDATE device_registrations
|
||
+ SET is_active = FALSE,
|
||
+ deactivated_at = CURRENT_TIMESTAMP,
|
||
+ deactivated_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (session['username'], device_id))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit Log
|
||
+ log_audit('DEVICE_DEACTIVATE', 'device', device_id,
|
||
+ old_values={'is_active': True},
|
||
+ new_values={'is_active': False})
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'})
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}")
|
||
+ return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500
|
||
+
|
||
+@app.route("/api/licenses/bulk-delete", methods=["POST"])
|
||
+@login_required
|
||
+def bulk_delete_licenses():
|
||
+ """Delete multiple licenses at once"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ license_ids = data.get('ids', [])
|
||
+
|
||
+ if not license_ids:
|
||
+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Get license info for audit log (nur Live-Daten)
|
||
+ cur.execute("""
|
||
+ SELECT license_key
|
||
+ FROM licenses
|
||
+ WHERE id = ANY(%s) AND is_test = FALSE
|
||
+ """, (license_ids,))
|
||
+ license_keys = [row[0] for row in cur.fetchall()]
|
||
+
|
||
+ # Delete all selected licenses (nur Live-Daten)
|
||
+ cur.execute("""
|
||
+ DELETE FROM licenses
|
||
+ WHERE id = ANY(%s) AND is_test = FALSE
|
||
+ """, (license_ids,))
|
||
+
|
||
+ affected_rows = cur.rowcount
|
||
+ conn.commit()
|
||
+
|
||
+ # Log the bulk action
|
||
+ log_audit('BULK_DELETE', 'licenses', None,
|
||
+ old_values={'license_keys': license_keys, 'count': affected_rows},
|
||
+ additional_info=f"{affected_rows} Lizenzen gelöscht")
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'})
|
||
+ except Exception as e:
|
||
+ return jsonify({'success': False, 'message': str(e)}), 500
|
||
+
|
||
+# ===================== RESOURCE POOL MANAGEMENT =====================
|
||
+
|
||
+@app.route('/resources')
|
||
+@login_required
|
||
+def resources():
|
||
+ """Resource Pool Hauptübersicht"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden)
|
||
+ show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
+
|
||
+ # Statistiken abrufen
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ resource_type,
|
||
+ COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
+ COUNT(*) as total
|
||
+ FROM resource_pools
|
||
+ WHERE is_test = %s
|
||
+ GROUP BY resource_type
|
||
+ """, (show_test,))
|
||
+
|
||
+ stats = {}
|
||
+ for row in cur.fetchall():
|
||
+ stats[row[0]] = {
|
||
+ 'available': row[1],
|
||
+ 'allocated': row[2],
|
||
+ 'quarantine': row[3],
|
||
+ 'total': row[4],
|
||
+ 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1)
|
||
+ }
|
||
+
|
||
+ # Letzte Aktivitäten (gefiltert nach Test/Live)
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ rh.action,
|
||
+ rh.action_by,
|
||
+ rh.action_at,
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ rh.details
|
||
+ FROM resource_history rh
|
||
+ JOIN resource_pools rp ON rh.resource_id = rp.id
|
||
+ WHERE rp.is_test = %s
|
||
+ ORDER BY rh.action_at DESC
|
||
+ LIMIT 10
|
||
+ """, (show_test,))
|
||
+ recent_activities = cur.fetchall()
|
||
+
|
||
+ # Ressourcen-Liste mit Pagination
|
||
+ page = request.args.get('page', 1, type=int)
|
||
+ per_page = 50
|
||
+ offset = (page - 1) * per_page
|
||
+
|
||
+ resource_type = request.args.get('type', '')
|
||
+ status_filter = request.args.get('status', '')
|
||
+ search = request.args.get('search', '')
|
||
+
|
||
+ # Sortierung
|
||
+ sort_by = request.args.get('sort', 'id')
|
||
+ sort_order = request.args.get('order', 'desc')
|
||
+
|
||
+ # Base Query
|
||
+ query = """
|
||
+ SELECT
|
||
+ rp.id,
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ rp.status,
|
||
+ rp.allocated_to_license,
|
||
+ l.license_key,
|
||
+ c.name as customer_name,
|
||
+ rp.status_changed_at,
|
||
+ rp.quarantine_reason,
|
||
+ rp.quarantine_until,
|
||
+ c.id as customer_id
|
||
+ FROM resource_pools rp
|
||
+ LEFT JOIN licenses l ON rp.allocated_to_license = l.id
|
||
+ LEFT JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE rp.is_test = %s
|
||
+ """
|
||
+ params = [show_test]
|
||
+
|
||
+ if resource_type:
|
||
+ query += " AND rp.resource_type = %s"
|
||
+ params.append(resource_type)
|
||
+
|
||
+ if status_filter:
|
||
+ query += " AND rp.status = %s"
|
||
+ params.append(status_filter)
|
||
+
|
||
+ if search:
|
||
+ query += " AND rp.resource_value ILIKE %s"
|
||
+ params.append(f'%{search}%')
|
||
+
|
||
+ # Count total
|
||
+ count_query = f"SELECT COUNT(*) FROM ({query}) as cnt"
|
||
+ cur.execute(count_query, params)
|
||
+ total = cur.fetchone()[0]
|
||
+ total_pages = (total + per_page - 1) // per_page
|
||
+
|
||
+ # Get paginated results with dynamic sorting
|
||
+ sort_column_map = {
|
||
+ 'id': 'rp.id',
|
||
+ 'type': 'rp.resource_type',
|
||
+ 'resource': 'rp.resource_value',
|
||
+ 'status': 'rp.status',
|
||
+ 'assigned': 'c.name',
|
||
+ 'changed': 'rp.status_changed_at'
|
||
+ }
|
||
+
|
||
+ sort_column = sort_column_map.get(sort_by, 'rp.id')
|
||
+ sort_direction = 'ASC' if sort_order == 'asc' else 'DESC'
|
||
+
|
||
+ query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s"
|
||
+ params.extend([per_page, offset])
|
||
+
|
||
+ cur.execute(query, params)
|
||
+ resources = cur.fetchall()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template('resources.html',
|
||
+ stats=stats,
|
||
+ resources=resources,
|
||
+ recent_activities=recent_activities,
|
||
+ page=page,
|
||
+ total_pages=total_pages,
|
||
+ total=total,
|
||
+ resource_type=resource_type,
|
||
+ status_filter=status_filter,
|
||
+ search=search,
|
||
+ show_test=show_test,
|
||
+ sort_by=sort_by,
|
||
+ sort_order=sort_order,
|
||
+ datetime=datetime,
|
||
+ timedelta=timedelta)
|
||
+
|
||
+@app.route('/resources/add', methods=['GET', 'POST'])
|
||
+@login_required
|
||
+def add_resources():
|
||
+ """Ressourcen zum Pool hinzufügen"""
|
||
+ # Hole show_test Parameter für die Anzeige
|
||
+ show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
+
|
||
+ if request.method == 'POST':
|
||
+ resource_type = request.form.get('resource_type')
|
||
+ resources_text = request.form.get('resources_text', '')
|
||
+ is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten
|
||
+
|
||
+ # Parse resources (one per line)
|
||
+ resources = [r.strip() for r in resources_text.split('\n') if r.strip()]
|
||
+
|
||
+ if not resources:
|
||
+ flash('Keine Ressourcen angegeben', 'error')
|
||
+ return redirect(url_for('add_resources', show_test=show_test))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ added = 0
|
||
+ duplicates = 0
|
||
+
|
||
+ for resource_value in resources:
|
||
+ try:
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test)
|
||
+ VALUES (%s, %s, %s, %s)
|
||
+ ON CONFLICT (resource_type, resource_value) DO NOTHING
|
||
+ """, (resource_type, resource_value, session['username'], is_test))
|
||
+
|
||
+ if cur.rowcount > 0:
|
||
+ added += 1
|
||
+ # Get the inserted ID
|
||
+ cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s",
|
||
+ (resource_type, resource_value))
|
||
+ resource_id = cur.fetchone()[0]
|
||
+
|
||
+ # Log in history
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, action, action_by, ip_address)
|
||
+ VALUES (%s, 'created', %s, %s)
|
||
+ """, (resource_id, session['username'], get_client_ip()))
|
||
+ else:
|
||
+ duplicates += 1
|
||
+
|
||
+ except Exception as e:
|
||
+ app.logger.error(f"Error adding resource {resource_value}: {e}")
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ log_audit('CREATE', 'resource_pool', None,
|
||
+ new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test},
|
||
+ additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen")
|
||
+
|
||
+ flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success')
|
||
+ return redirect(url_for('resources', show_test=show_test))
|
||
+
|
||
+ return render_template('add_resources.html', show_test=show_test)
|
||
+
|
||
+@app.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
|
||
+@login_required
|
||
+def quarantine_resource(resource_id):
|
||
+ """Ressource in Quarantäne setzen"""
|
||
+ reason = request.form.get('reason', 'review')
|
||
+ until_date = request.form.get('until_date')
|
||
+ notes = request.form.get('notes', '')
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Get current resource info
|
||
+ cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,))
|
||
+ resource = cur.fetchone()
|
||
+
|
||
+ if not resource:
|
||
+ flash('Ressource nicht gefunden', 'error')
|
||
+ return redirect(url_for('resources'))
|
||
+
|
||
+ old_status = resource[2]
|
||
+
|
||
+ # Update resource
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'quarantine',
|
||
+ quarantine_reason = %s,
|
||
+ quarantine_until = %s,
|
||
+ notes = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP,
|
||
+ status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (reason, until_date if until_date else None, notes, session['username'], resource_id))
|
||
+
|
||
+ # Log in history
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, action, action_by, ip_address, details)
|
||
+ VALUES (%s, 'quarantined', %s, %s, %s)
|
||
+ """, (resource_id, session['username'], get_client_ip(),
|
||
+ Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status})))
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ log_audit('UPDATE', 'resource', resource_id,
|
||
+ old_values={'status': old_status},
|
||
+ new_values={'status': 'quarantine', 'reason': reason},
|
||
+ additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne")
|
||
+
|
||
+ flash('Ressource in Quarantäne gesetzt', 'success')
|
||
+
|
||
+ # Redirect mit allen aktuellen Filtern
|
||
+ return redirect(url_for('resources',
|
||
+ show_test=request.args.get('show_test', request.form.get('show_test', 'false')),
|
||
+ type=request.args.get('type', request.form.get('type', '')),
|
||
+ status=request.args.get('status', request.form.get('status', '')),
|
||
+ search=request.args.get('search', request.form.get('search', ''))))
|
||
+
|
||
+@app.route('/resources/release', methods=['POST'])
|
||
+@login_required
|
||
+def release_resources():
|
||
+ """Ressourcen aus Quarantäne freigeben"""
|
||
+ resource_ids = request.form.getlist('resource_ids')
|
||
+
|
||
+ if not resource_ids:
|
||
+ flash('Keine Ressourcen ausgewählt', 'error')
|
||
+ return redirect(url_for('resources'))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ released = 0
|
||
+ for resource_id in resource_ids:
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'available',
|
||
+ quarantine_reason = NULL,
|
||
+ quarantine_until = NULL,
|
||
+ allocated_to_license = NULL,
|
||
+ status_changed_at = CURRENT_TIMESTAMP,
|
||
+ status_changed_by = %s
|
||
+ WHERE id = %s AND status = 'quarantine'
|
||
+ """, (session['username'], resource_id))
|
||
+
|
||
+ if cur.rowcount > 0:
|
||
+ released += 1
|
||
+ # Log in history
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, action, action_by, ip_address)
|
||
+ VALUES (%s, 'released', %s, %s)
|
||
+ """, (resource_id, session['username'], get_client_ip()))
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ log_audit('UPDATE', 'resource_pool', None,
|
||
+ new_values={'released': released},
|
||
+ additional_info=f"{released} Ressourcen aus Quarantäne freigegeben")
|
||
+
|
||
+ flash(f'{released} Ressourcen freigegeben', 'success')
|
||
+
|
||
+ # Redirect mit allen aktuellen Filtern
|
||
+ return redirect(url_for('resources',
|
||
+ show_test=request.args.get('show_test', request.form.get('show_test', 'false')),
|
||
+ type=request.args.get('type', request.form.get('type', '')),
|
||
+ status=request.args.get('status', request.form.get('status', '')),
|
||
+ search=request.args.get('search', request.form.get('search', ''))))
|
||
+
|
||
+@app.route('/api/resources/allocate', methods=['POST'])
|
||
+@login_required
|
||
+def allocate_resources_api():
|
||
+ """API für Ressourcen-Zuweisung bei Lizenzerstellung"""
|
||
+ data = request.json
|
||
+ license_id = data.get('license_id')
|
||
+ domain_count = data.get('domain_count', 1)
|
||
+ ipv4_count = data.get('ipv4_count', 1)
|
||
+ phone_count = data.get('phone_count', 1)
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ allocated = {'domains': [], 'ipv4s': [], 'phones': []}
|
||
+
|
||
+ # Allocate domains
|
||
+ if domain_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id, resource_value FROM resource_pools
|
||
+ WHERE resource_type = 'domain' AND status = 'available'
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (domain_count,))
|
||
+ domains = cur.fetchall()
|
||
+
|
||
+ if len(domains) < domain_count:
|
||
+ raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})")
|
||
+
|
||
+ for domain_id, domain_value in domains:
|
||
+ # Update resource status
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated',
|
||
+ allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP,
|
||
+ status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], domain_id))
|
||
+
|
||
+ # Create assignment
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, domain_id, session['username']))
|
||
+
|
||
+ # Log history
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (domain_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ allocated['domains'].append(domain_value)
|
||
+
|
||
+ # Allocate IPv4s (similar logic)
|
||
+ if ipv4_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id, resource_value FROM resource_pools
|
||
+ WHERE resource_type = 'ipv4' AND status = 'available'
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (ipv4_count,))
|
||
+ ipv4s = cur.fetchall()
|
||
+
|
||
+ if len(ipv4s) < ipv4_count:
|
||
+ raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar")
|
||
+
|
||
+ for ipv4_id, ipv4_value in ipv4s:
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated',
|
||
+ allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP,
|
||
+ status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], ipv4_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, ipv4_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (ipv4_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ allocated['ipv4s'].append(ipv4_value)
|
||
+
|
||
+ # Allocate phones (similar logic)
|
||
+ if phone_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id, resource_value FROM resource_pools
|
||
+ WHERE resource_type = 'phone' AND status = 'available'
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (phone_count,))
|
||
+ phones = cur.fetchall()
|
||
+
|
||
+ if len(phones) < phone_count:
|
||
+ raise ValueError(f"Nicht genügend Telefonnummern verfügbar")
|
||
+
|
||
+ for phone_id, phone_value in phones:
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated',
|
||
+ allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP,
|
||
+ status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], phone_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, phone_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (phone_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ allocated['phones'].append(phone_value)
|
||
+
|
||
+ # Update license resource counts
|
||
+ cur.execute("""
|
||
+ UPDATE licenses
|
||
+ SET domain_count = %s,
|
||
+ ipv4_count = %s,
|
||
+ phone_count = %s
|
||
+ WHERE id = %s
|
||
+ """, (domain_count, ipv4_count, phone_count, license_id))
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'allocated': allocated
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'error': str(e)
|
||
+ }), 400
|
||
+
|
||
+@app.route('/api/resources/check-availability', methods=['GET'])
|
||
+@login_required
|
||
+def check_resource_availability():
|
||
+ """Prüft verfügbare Ressourcen"""
|
||
+ resource_type = request.args.get('type', '')
|
||
+ count = request.args.get('count', 10, type=int)
|
||
+ show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ if resource_type:
|
||
+ # Spezifische Ressourcen für einen Typ
|
||
+ cur.execute("""
|
||
+ SELECT id, resource_value
|
||
+ FROM resource_pools
|
||
+ WHERE status = 'available'
|
||
+ AND resource_type = %s
|
||
+ AND is_test = %s
|
||
+ ORDER BY resource_value
|
||
+ LIMIT %s
|
||
+ """, (resource_type, show_test, count))
|
||
+
|
||
+ resources = []
|
||
+ for row in cur.fetchall():
|
||
+ resources.append({
|
||
+ 'id': row[0],
|
||
+ 'value': row[1]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'available': resources,
|
||
+ 'type': resource_type,
|
||
+ 'count': len(resources)
|
||
+ })
|
||
+ else:
|
||
+ # Zusammenfassung aller Typen
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ resource_type,
|
||
+ COUNT(*) as available
|
||
+ FROM resource_pools
|
||
+ WHERE status = 'available'
|
||
+ AND is_test = %s
|
||
+ GROUP BY resource_type
|
||
+ """, (show_test,))
|
||
+
|
||
+ availability = {}
|
||
+ for row in cur.fetchall():
|
||
+ availability[row[0]] = row[1]
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify(availability)
|
||
+
|
||
+@app.route('/api/global-search', methods=['GET'])
|
||
+@login_required
|
||
+def global_search():
|
||
+ """Global search API endpoint for searching customers and licenses"""
|
||
+ query = request.args.get('q', '').strip()
|
||
+
|
||
+ if not query or len(query) < 2:
|
||
+ return jsonify({'customers': [], 'licenses': []})
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Search pattern with wildcards
|
||
+ search_pattern = f'%{query}%'
|
||
+
|
||
+ # Search customers
|
||
+ cur.execute("""
|
||
+ SELECT id, name, email, company_name
|
||
+ FROM customers
|
||
+ WHERE (LOWER(name) LIKE LOWER(%s)
|
||
+ OR LOWER(email) LIKE LOWER(%s)
|
||
+ OR LOWER(company_name) LIKE LOWER(%s))
|
||
+ AND is_test = FALSE
|
||
+ ORDER BY name
|
||
+ LIMIT 5
|
||
+ """, (search_pattern, search_pattern, search_pattern))
|
||
+
|
||
+ customers = []
|
||
+ for row in cur.fetchall():
|
||
+ customers.append({
|
||
+ 'id': row[0],
|
||
+ 'name': row[1],
|
||
+ 'email': row[2],
|
||
+ 'company_name': row[3]
|
||
+ })
|
||
+
|
||
+ # Search licenses
|
||
+ cur.execute("""
|
||
+ SELECT l.id, l.license_key, c.name as customer_name
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE LOWER(l.license_key) LIKE LOWER(%s)
|
||
+ AND l.is_test = FALSE
|
||
+ ORDER BY l.created_at DESC
|
||
+ LIMIT 5
|
||
+ """, (search_pattern,))
|
||
+
|
||
+ licenses = []
|
||
+ for row in cur.fetchall():
|
||
+ licenses.append({
|
||
+ 'id': row[0],
|
||
+ 'license_key': row[1],
|
||
+ 'customer_name': row[2]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'customers': customers,
|
||
+ 'licenses': licenses
|
||
+ })
|
||
+
|
||
+@app.route('/resources/history/<int:resource_id>')
|
||
+@login_required
|
||
+def resource_history(resource_id):
|
||
+ """Zeigt die komplette Historie einer Ressource"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Get complete resource info using named columns
|
||
+ cur.execute("""
|
||
+ SELECT id, resource_type, resource_value, status, allocated_to_license,
|
||
+ status_changed_at, status_changed_by, quarantine_reason,
|
||
+ quarantine_until, created_at, notes
|
||
+ FROM resource_pools
|
||
+ WHERE id = %s
|
||
+ """, (resource_id,))
|
||
+ row = cur.fetchone()
|
||
+
|
||
+ if not row:
|
||
+ flash('Ressource nicht gefunden', 'error')
|
||
+ return redirect(url_for('resources'))
|
||
+
|
||
+ # Create resource object with named attributes
|
||
+ resource = {
|
||
+ 'id': row[0],
|
||
+ 'resource_type': row[1],
|
||
+ 'resource_value': row[2],
|
||
+ 'status': row[3],
|
||
+ 'allocated_to_license': row[4],
|
||
+ 'status_changed_at': row[5],
|
||
+ 'status_changed_by': row[6],
|
||
+ 'quarantine_reason': row[7],
|
||
+ 'quarantine_until': row[8],
|
||
+ 'created_at': row[9],
|
||
+ 'notes': row[10]
|
||
+ }
|
||
+
|
||
+ # Get license info if allocated
|
||
+ license_info = None
|
||
+ if resource['allocated_to_license']:
|
||
+ cur.execute("SELECT license_key FROM licenses WHERE id = %s",
|
||
+ (resource['allocated_to_license'],))
|
||
+ lic = cur.fetchone()
|
||
+ if lic:
|
||
+ license_info = {'license_key': lic[0]}
|
||
+
|
||
+ # Get history with named columns
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ rh.action,
|
||
+ rh.action_by,
|
||
+ rh.action_at,
|
||
+ rh.details,
|
||
+ rh.license_id,
|
||
+ rh.ip_address
|
||
+ FROM resource_history rh
|
||
+ WHERE rh.resource_id = %s
|
||
+ ORDER BY rh.action_at DESC
|
||
+ """, (resource_id,))
|
||
+
|
||
+ history = []
|
||
+ for row in cur.fetchall():
|
||
+ history.append({
|
||
+ 'action': row[0],
|
||
+ 'action_by': row[1],
|
||
+ 'action_at': row[2],
|
||
+ 'details': row[3],
|
||
+ 'license_id': row[4],
|
||
+ 'ip_address': row[5]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Convert to object-like for template
|
||
+ class ResourceObj:
|
||
+ def __init__(self, data):
|
||
+ for key, value in data.items():
|
||
+ setattr(self, key, value)
|
||
+
|
||
+ resource_obj = ResourceObj(resource)
|
||
+ history_objs = [ResourceObj(h) for h in history]
|
||
+
|
||
+ return render_template('resource_history.html',
|
||
+ resource=resource_obj,
|
||
+ license_info=license_info,
|
||
+ history=history_objs)
|
||
+
|
||
+@app.route('/resources/metrics')
|
||
+@login_required
|
||
+def resources_metrics():
|
||
+ """Dashboard für Resource Metrics und Reports"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Overall stats with fallback values
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ COUNT(DISTINCT resource_id) as total_resources,
|
||
+ COALESCE(AVG(performance_score), 0) as avg_performance,
|
||
+ COALESCE(SUM(cost), 0) as total_cost,
|
||
+ COALESCE(SUM(revenue), 0) as total_revenue,
|
||
+ COALESCE(SUM(issues_count), 0) as total_issues
|
||
+ FROM resource_metrics
|
||
+ WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
+ """)
|
||
+ row = cur.fetchone()
|
||
+
|
||
+ # Calculate ROI
|
||
+ roi = 0
|
||
+ if row[2] > 0: # if total_cost > 0
|
||
+ roi = row[3] / row[2] # revenue / cost
|
||
+
|
||
+ stats = {
|
||
+ 'total_resources': row[0] or 0,
|
||
+ 'avg_performance': row[1] or 0,
|
||
+ 'total_cost': row[2] or 0,
|
||
+ 'total_revenue': row[3] or 0,
|
||
+ 'total_issues': row[4] or 0,
|
||
+ 'roi': roi
|
||
+ }
|
||
+
|
||
+ # Performance by type
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ rp.resource_type,
|
||
+ COALESCE(AVG(rm.performance_score), 0) as avg_score,
|
||
+ COUNT(DISTINCT rp.id) as resource_count
|
||
+ FROM resource_pools rp
|
||
+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
+ GROUP BY rp.resource_type
|
||
+ ORDER BY rp.resource_type
|
||
+ """)
|
||
+ performance_by_type = cur.fetchall()
|
||
+
|
||
+ # Utilization data
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ resource_type,
|
||
+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
+ COUNT(*) as total,
|
||
+ ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent
|
||
+ FROM resource_pools
|
||
+ GROUP BY resource_type
|
||
+ """)
|
||
+ utilization_rows = cur.fetchall()
|
||
+ utilization_data = [
|
||
+ {
|
||
+ 'type': row[0].upper(),
|
||
+ 'allocated': row[1],
|
||
+ 'total': row[2],
|
||
+ 'allocated_percent': row[3]
|
||
+ }
|
||
+ for row in utilization_rows
|
||
+ ]
|
||
+
|
||
+ # Top performing resources
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ rp.id,
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ COALESCE(AVG(rm.performance_score), 0) as avg_score,
|
||
+ COALESCE(SUM(rm.revenue), 0) as total_revenue,
|
||
+ COALESCE(SUM(rm.cost), 1) as total_cost,
|
||
+ CASE
|
||
+ WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0
|
||
+ ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1)
|
||
+ END as roi
|
||
+ FROM resource_pools rp
|
||
+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
+ WHERE rp.status != 'quarantine'
|
||
+ GROUP BY rp.id, rp.resource_type, rp.resource_value
|
||
+ HAVING AVG(rm.performance_score) IS NOT NULL
|
||
+ ORDER BY avg_score DESC
|
||
+ LIMIT 10
|
||
+ """)
|
||
+ top_rows = cur.fetchall()
|
||
+ top_performers = [
|
||
+ {
|
||
+ 'id': row[0],
|
||
+ 'resource_type': row[1],
|
||
+ 'resource_value': row[2],
|
||
+ 'avg_score': row[3],
|
||
+ 'roi': row[6]
|
||
+ }
|
||
+ for row in top_rows
|
||
+ ]
|
||
+
|
||
+ # Resources with issues
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ rp.id,
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ rp.status,
|
||
+ COALESCE(SUM(rm.issues_count), 0) as total_issues
|
||
+ FROM resource_pools rp
|
||
+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
+ WHERE rm.issues_count > 0 OR rp.status = 'quarantine'
|
||
+ GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status
|
||
+ HAVING SUM(rm.issues_count) > 0
|
||
+ ORDER BY total_issues DESC
|
||
+ LIMIT 10
|
||
+ """)
|
||
+ problem_rows = cur.fetchall()
|
||
+ problem_resources = [
|
||
+ {
|
||
+ 'id': row[0],
|
||
+ 'resource_type': row[1],
|
||
+ 'resource_value': row[2],
|
||
+ 'status': row[3],
|
||
+ 'total_issues': row[4]
|
||
+ }
|
||
+ for row in problem_rows
|
||
+ ]
|
||
+
|
||
+ # Daily metrics for trend chart (last 30 days)
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ metric_date,
|
||
+ COALESCE(AVG(performance_score), 0) as avg_performance,
|
||
+ COALESCE(SUM(issues_count), 0) as total_issues
|
||
+ FROM resource_metrics
|
||
+ WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
+ GROUP BY metric_date
|
||
+ ORDER BY metric_date
|
||
+ """)
|
||
+ daily_rows = cur.fetchall()
|
||
+ daily_metrics = [
|
||
+ {
|
||
+ 'date': row[0].strftime('%d.%m'),
|
||
+ 'performance': float(row[1]),
|
||
+ 'issues': int(row[2])
|
||
+ }
|
||
+ for row in daily_rows
|
||
+ ]
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template('resource_metrics.html',
|
||
+ stats=stats,
|
||
+ performance_by_type=performance_by_type,
|
||
+ utilization_data=utilization_data,
|
||
+ top_performers=top_performers,
|
||
+ problem_resources=problem_resources,
|
||
+ daily_metrics=daily_metrics)
|
||
+
|
||
+@app.route('/resources/report', methods=['GET'])
|
||
+@login_required
|
||
+def resources_report():
|
||
+ """Generiert Ressourcen-Reports oder zeigt Report-Formular"""
|
||
+ # Prüfe ob Download angefordert wurde
|
||
+ if request.args.get('download') == 'true':
|
||
+ report_type = request.args.get('type', 'usage')
|
||
+ format_type = request.args.get('format', 'excel')
|
||
+ date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d'))
|
||
+ date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d'))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ if report_type == 'usage':
|
||
+ # Auslastungsreport
|
||
+ query = """
|
||
+ SELECT
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ rp.status,
|
||
+ COUNT(DISTINCT rh.license_id) as unique_licenses,
|
||
+ COUNT(rh.id) as total_allocations,
|
||
+ MIN(rh.action_at) as first_used,
|
||
+ MAX(rh.action_at) as last_used
|
||
+ FROM resource_pools rp
|
||
+ LEFT JOIN resource_history rh ON rp.id = rh.resource_id
|
||
+ AND rh.action = 'allocated'
|
||
+ AND rh.action_at BETWEEN %s AND %s
|
||
+ GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status
|
||
+ ORDER BY rp.resource_type, total_allocations DESC
|
||
+ """
|
||
+ cur.execute(query, (date_from, date_to))
|
||
+ columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung']
|
||
+
|
||
+ elif report_type == 'performance':
|
||
+ # Performance-Report
|
||
+ query = """
|
||
+ SELECT
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ AVG(rm.performance_score) as avg_performance,
|
||
+ SUM(rm.usage_count) as total_usage,
|
||
+ SUM(rm.revenue) as total_revenue,
|
||
+ SUM(rm.cost) as total_cost,
|
||
+ SUM(rm.revenue - rm.cost) as profit,
|
||
+ SUM(rm.issues_count) as total_issues
|
||
+ FROM resource_pools rp
|
||
+ JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
+ WHERE rm.metric_date BETWEEN %s AND %s
|
||
+ GROUP BY rp.id, rp.resource_type, rp.resource_value
|
||
+ ORDER BY profit DESC
|
||
+ """
|
||
+ cur.execute(query, (date_from, date_to))
|
||
+ columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues']
|
||
+
|
||
+ elif report_type == 'compliance':
|
||
+ # Compliance-Report
|
||
+ query = """
|
||
+ SELECT
|
||
+ rh.action_at,
|
||
+ rh.action,
|
||
+ rh.action_by,
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ l.license_key,
|
||
+ c.name as customer_name,
|
||
+ rh.ip_address
|
||
+ FROM resource_history rh
|
||
+ JOIN resource_pools rp ON rh.resource_id = rp.id
|
||
+ LEFT JOIN licenses l ON rh.license_id = l.id
|
||
+ LEFT JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE rh.action_at BETWEEN %s AND %s
|
||
+ ORDER BY rh.action_at DESC
|
||
+ """
|
||
+ cur.execute(query, (date_from, date_to))
|
||
+ columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse']
|
||
+
|
||
+ else: # inventory report
|
||
+ # Inventar-Report
|
||
+ query = """
|
||
+ SELECT
|
||
+ resource_type,
|
||
+ COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
+ COUNT(*) as total
|
||
+ FROM resource_pools
|
||
+ GROUP BY resource_type
|
||
+ ORDER BY resource_type
|
||
+ """
|
||
+ cur.execute(query)
|
||
+ columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt']
|
||
+
|
||
+ # Convert to DataFrame
|
||
+ data = cur.fetchall()
|
||
+ df = pd.DataFrame(data, columns=columns)
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Generate file
|
||
+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
+ filename = f"resource_report_{report_type}_{timestamp}"
|
||
+
|
||
+ if format_type == 'excel':
|
||
+ output = io.BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, sheet_name='Report', index=False)
|
||
+
|
||
+ # Auto-adjust columns width
|
||
+ worksheet = writer.sheets['Report']
|
||
+ for column in worksheet.columns:
|
||
+ max_length = 0
|
||
+ column = [cell for cell in column]
|
||
+ for cell in column:
|
||
+ try:
|
||
+ if len(str(cell.value)) > max_length:
|
||
+ max_length = len(str(cell.value))
|
||
+ except:
|
||
+ pass
|
||
+ adjusted_width = (max_length + 2)
|
||
+ worksheet.column_dimensions[column[0].column_letter].width = adjusted_width
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ log_audit('EXPORT', 'resource_report', None,
|
||
+ new_values={'type': report_type, 'format': 'excel', 'rows': len(df)},
|
||
+ additional_info=f"Resource Report {report_type} exportiert")
|
||
+
|
||
+ return send_file(output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx')
|
||
+
|
||
+ else: # CSV
|
||
+ output = io.StringIO()
|
||
+ df.to_csv(output, index=False, sep=';', encoding='utf-8-sig')
|
||
+ output.seek(0)
|
||
+
|
||
+ log_audit('EXPORT', 'resource_report', None,
|
||
+ new_values={'type': report_type, 'format': 'csv', 'rows': len(df)},
|
||
+ additional_info=f"Resource Report {report_type} exportiert")
|
||
+
|
||
+ return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
+ mimetype='text/csv',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv')
|
||
+
|
||
+ # Wenn kein Download, zeige Report-Formular
|
||
+ return render_template('resource_report.html',
|
||
+ datetime=datetime,
|
||
+ timedelta=timedelta,
|
||
+ username=session.get('username'))
|
||
+
|
||
+if __name__ == "__main__":
|
||
+ app.run(host="0.0.0.0", port=5000)
|
||
diff --git a/v2_adminpanel/app.py.old b/v2_adminpanel/app.py.old
|
||
index 3849500..34c3f29 100644
|
||
--- a/v2_adminpanel/app.py.old
|
||
+++ b/v2_adminpanel/app.py.old
|
||
@@ -1,5021 +1,5021 @@
|
||
-import os
|
||
-import time
|
||
-import json
|
||
-import logging
|
||
-import requests
|
||
-from io import BytesIO
|
||
-from datetime import datetime, timedelta
|
||
-from zoneinfo import ZoneInfo
|
||
-from pathlib import Path
|
||
-
|
||
-from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash
|
||
-from flask_session import Session
|
||
-from werkzeug.middleware.proxy_fix import ProxyFix
|
||
-from apscheduler.schedulers.background import BackgroundScheduler
|
||
-import pandas as pd
|
||
-from psycopg2.extras import Json
|
||
-
|
||
-# Import our new modules
|
||
-import config
|
||
-from db import get_connection, get_db_connection, get_db_cursor, execute_query
|
||
-from auth.decorators import login_required
|
||
-from auth.password import hash_password, verify_password
|
||
-from auth.two_factor import (
|
||
- generate_totp_secret, generate_qr_code, verify_totp,
|
||
- generate_backup_codes, hash_backup_code, verify_backup_code
|
||
-)
|
||
-from auth.rate_limiting import (
|
||
- get_client_ip, check_ip_blocked, record_failed_attempt,
|
||
- reset_login_attempts, get_login_attempts
|
||
-)
|
||
-from utils.audit import log_audit
|
||
-from utils.license import generate_license_key, validate_license_key
|
||
-from utils.backup import create_backup, restore_backup, get_or_create_encryption_key
|
||
-from utils.export import (
|
||
- create_excel_export, format_datetime_for_export,
|
||
- prepare_license_export_data, prepare_customer_export_data,
|
||
- prepare_session_export_data, prepare_audit_export_data
|
||
-)
|
||
-
|
||
-app = Flask(__name__)
|
||
-# Load configuration from config module
|
||
-app.config['SECRET_KEY'] = config.SECRET_KEY
|
||
-app.config['SESSION_TYPE'] = config.SESSION_TYPE
|
||
-app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII
|
||
-app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE
|
||
-app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME
|
||
-app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY
|
||
-app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE
|
||
-app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE
|
||
-app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME
|
||
-app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST
|
||
-Session(app)
|
||
-
|
||
-# ProxyFix für korrekte IP-Adressen hinter Nginx
|
||
-app.wsgi_app = ProxyFix(
|
||
- app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
|
||
-)
|
||
-
|
||
-# Configuration is now loaded from config module
|
||
-
|
||
-# Scheduler für automatische Backups
|
||
-scheduler = BackgroundScheduler()
|
||
-scheduler.start()
|
||
-
|
||
-# Logging konfigurieren
|
||
-logging.basicConfig(level=logging.INFO)
|
||
-
|
||
-
|
||
-# Login decorator
|
||
-def login_required(f):
|
||
- @wraps(f)
|
||
- def decorated_function(*args, **kwargs):
|
||
- if 'logged_in' not in session:
|
||
- return redirect(url_for('login'))
|
||
-
|
||
- # Prüfe ob Session abgelaufen ist
|
||
- if 'last_activity' in session:
|
||
- last_activity = datetime.fromisoformat(session['last_activity'])
|
||
- time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity
|
||
-
|
||
- # Debug-Logging
|
||
- app.logger.info(f"Session check for {session.get('username', 'unknown')}: "
|
||
- f"Last activity: {last_activity}, "
|
||
- f"Time since: {time_since_activity.total_seconds()} seconds")
|
||
-
|
||
- if time_since_activity > timedelta(minutes=5):
|
||
- # Session abgelaufen - Logout
|
||
- username = session.get('username', 'unbekannt')
|
||
- app.logger.info(f"Session timeout for user {username} - auto logout")
|
||
- # Audit-Log für automatischen Logout (vor session.clear()!)
|
||
- try:
|
||
- log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username})
|
||
- except:
|
||
- pass
|
||
- session.clear()
|
||
- flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning')
|
||
- return redirect(url_for('login'))
|
||
-
|
||
- # Aktivität NICHT automatisch aktualisieren
|
||
- # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht)
|
||
- return f(*args, **kwargs)
|
||
- return decorated_function
|
||
-
|
||
-# DB-Verbindung mit UTF-8 Encoding
|
||
-def get_connection():
|
||
- conn = psycopg2.connect(
|
||
- host=os.getenv("POSTGRES_HOST", "postgres"),
|
||
- port=os.getenv("POSTGRES_PORT", "5432"),
|
||
- dbname=os.getenv("POSTGRES_DB"),
|
||
- user=os.getenv("POSTGRES_USER"),
|
||
- password=os.getenv("POSTGRES_PASSWORD"),
|
||
- options='-c client_encoding=UTF8'
|
||
- )
|
||
- conn.set_client_encoding('UTF8')
|
||
- return conn
|
||
-
|
||
-# User Authentication Helper Functions
|
||
-def hash_password(password):
|
||
- """Hash a password using bcrypt"""
|
||
- return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||
-
|
||
-def verify_password(password, hashed):
|
||
- """Verify a password against its hash"""
|
||
- return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
||
-
|
||
-def get_user_by_username(username):
|
||
- """Get user from database by username"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- try:
|
||
- cur.execute("""
|
||
- SELECT id, username, password_hash, email, totp_secret, totp_enabled,
|
||
- backup_codes, last_password_change, failed_2fa_attempts
|
||
- FROM users WHERE username = %s
|
||
- """, (username,))
|
||
- user = cur.fetchone()
|
||
- if user:
|
||
- return {
|
||
- 'id': user[0],
|
||
- 'username': user[1],
|
||
- 'password_hash': user[2],
|
||
- 'email': user[3],
|
||
- 'totp_secret': user[4],
|
||
- 'totp_enabled': user[5],
|
||
- 'backup_codes': user[6],
|
||
- 'last_password_change': user[7],
|
||
- 'failed_2fa_attempts': user[8]
|
||
- }
|
||
- return None
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
-def generate_totp_secret():
|
||
- """Generate a new TOTP secret"""
|
||
- return pyotp.random_base32()
|
||
-
|
||
-def generate_qr_code(username, totp_secret):
|
||
- """Generate QR code for TOTP setup"""
|
||
- totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
|
||
- name=username,
|
||
- issuer_name='V2 Admin Panel'
|
||
- )
|
||
-
|
||
- qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||
- qr.add_data(totp_uri)
|
||
- qr.make(fit=True)
|
||
-
|
||
- img = qr.make_image(fill_color="black", back_color="white")
|
||
- buf = BytesIO()
|
||
- img.save(buf, format='PNG')
|
||
- buf.seek(0)
|
||
-
|
||
- return base64.b64encode(buf.getvalue()).decode()
|
||
-
|
||
-def verify_totp(totp_secret, token):
|
||
- """Verify a TOTP token"""
|
||
- totp = pyotp.TOTP(totp_secret)
|
||
- return totp.verify(token, valid_window=1)
|
||
-
|
||
-def generate_backup_codes(count=8):
|
||
- """Generate backup codes for 2FA recovery"""
|
||
- codes = []
|
||
- for _ in range(count):
|
||
- code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||
- codes.append(code)
|
||
- return codes
|
||
-
|
||
-def hash_backup_code(code):
|
||
- """Hash a backup code for storage"""
|
||
- return hashlib.sha256(code.encode()).hexdigest()
|
||
-
|
||
-def verify_backup_code(code, hashed_codes):
|
||
- """Verify a backup code against stored hashes"""
|
||
- code_hash = hashlib.sha256(code.encode()).hexdigest()
|
||
- return code_hash in hashed_codes
|
||
-
|
||
-# Audit-Log-Funktion
|
||
-def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None):
|
||
- """Protokolliert Änderungen im Audit-Log"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- username = session.get('username', 'system')
|
||
- ip_address = get_client_ip() if request else None
|
||
- user_agent = request.headers.get('User-Agent') if request else None
|
||
-
|
||
- # Debug logging
|
||
- app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}")
|
||
-
|
||
- # Konvertiere Dictionaries zu JSONB
|
||
- old_json = Json(old_values) if old_values else None
|
||
- new_json = Json(new_values) if new_values else None
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO audit_log
|
||
- (username, action, entity_type, entity_id, old_values, new_values,
|
||
- ip_address, user_agent, additional_info)
|
||
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
- """, (username, action, entity_type, entity_id, old_json, new_json,
|
||
- ip_address, user_agent, additional_info))
|
||
-
|
||
- conn.commit()
|
||
- except Exception as e:
|
||
- print(f"Audit log error: {e}")
|
||
- conn.rollback()
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
-# Verschlüsselungs-Funktionen
|
||
-def get_or_create_encryption_key():
|
||
- """Holt oder erstellt einen Verschlüsselungsschlüssel"""
|
||
- key_file = BACKUP_DIR / ".backup_key"
|
||
-
|
||
- # Versuche Key aus Umgebungsvariable zu lesen
|
||
- env_key = os.getenv("BACKUP_ENCRYPTION_KEY")
|
||
- if env_key:
|
||
- try:
|
||
- # Validiere den Key
|
||
- Fernet(env_key.encode())
|
||
- return env_key.encode()
|
||
- except:
|
||
- pass
|
||
-
|
||
- # Wenn kein gültiger Key in ENV, prüfe Datei
|
||
- if key_file.exists():
|
||
- return key_file.read_bytes()
|
||
-
|
||
- # Erstelle neuen Key
|
||
- key = Fernet.generate_key()
|
||
- key_file.write_bytes(key)
|
||
- logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt")
|
||
- return key
|
||
-
|
||
-# Backup-Funktionen
|
||
-def create_backup(backup_type="manual", created_by=None):
|
||
- """Erstellt ein verschlüsseltes Backup der Datenbank"""
|
||
- start_time = time.time()
|
||
- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")
|
||
- filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc"
|
||
- filepath = BACKUP_DIR / filename
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Backup-Eintrag erstellen
|
||
- cur.execute("""
|
||
- INSERT INTO backup_history
|
||
- (filename, filepath, backup_type, status, created_by, is_encrypted)
|
||
- VALUES (%s, %s, %s, %s, %s, %s)
|
||
- RETURNING id
|
||
- """, (filename, str(filepath), backup_type, 'in_progress',
|
||
- created_by or 'system', True))
|
||
- backup_id = cur.fetchone()[0]
|
||
- conn.commit()
|
||
-
|
||
- try:
|
||
- # PostgreSQL Dump erstellen
|
||
- dump_command = [
|
||
- 'pg_dump',
|
||
- '-h', os.getenv("POSTGRES_HOST", "postgres"),
|
||
- '-p', os.getenv("POSTGRES_PORT", "5432"),
|
||
- '-U', os.getenv("POSTGRES_USER"),
|
||
- '-d', os.getenv("POSTGRES_DB"),
|
||
- '--no-password',
|
||
- '--verbose'
|
||
- ]
|
||
-
|
||
- # PGPASSWORD setzen
|
||
- env = os.environ.copy()
|
||
- env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
|
||
-
|
||
- # Dump ausführen
|
||
- result = subprocess.run(dump_command, capture_output=True, text=True, env=env)
|
||
-
|
||
- if result.returncode != 0:
|
||
- raise Exception(f"pg_dump failed: {result.stderr}")
|
||
-
|
||
- dump_data = result.stdout.encode('utf-8')
|
||
-
|
||
- # Komprimieren
|
||
- compressed_data = gzip.compress(dump_data)
|
||
-
|
||
- # Verschlüsseln
|
||
- key = get_or_create_encryption_key()
|
||
- f = Fernet(key)
|
||
- encrypted_data = f.encrypt(compressed_data)
|
||
-
|
||
- # Speichern
|
||
- filepath.write_bytes(encrypted_data)
|
||
-
|
||
- # Statistiken sammeln
|
||
- cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'")
|
||
- tables_count = cur.fetchone()[0]
|
||
-
|
||
- cur.execute("""
|
||
- SELECT SUM(n_live_tup)
|
||
- FROM pg_stat_user_tables
|
||
- """)
|
||
- records_count = cur.fetchone()[0] or 0
|
||
-
|
||
- duration = time.time() - start_time
|
||
- filesize = filepath.stat().st_size
|
||
-
|
||
- # Backup-Eintrag aktualisieren
|
||
- cur.execute("""
|
||
- UPDATE backup_history
|
||
- SET status = %s, filesize = %s, tables_count = %s,
|
||
- records_count = %s, duration_seconds = %s
|
||
- WHERE id = %s
|
||
- """, ('success', filesize, tables_count, records_count, duration, backup_id))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('BACKUP', 'database', backup_id,
|
||
- additional_info=f"Backup erstellt: {filename} ({filesize} bytes)")
|
||
-
|
||
- # E-Mail-Benachrichtigung (wenn konfiguriert)
|
||
- send_backup_notification(True, filename, filesize, duration)
|
||
-
|
||
- logging.info(f"Backup erfolgreich erstellt: {filename}")
|
||
- return True, filename
|
||
-
|
||
- except Exception as e:
|
||
- # Fehler protokollieren
|
||
- cur.execute("""
|
||
- UPDATE backup_history
|
||
- SET status = %s, error_message = %s, duration_seconds = %s
|
||
- WHERE id = %s
|
||
- """, ('failed', str(e), time.time() - start_time, backup_id))
|
||
- conn.commit()
|
||
-
|
||
- logging.error(f"Backup fehlgeschlagen: {e}")
|
||
- send_backup_notification(False, filename, error=str(e))
|
||
-
|
||
- return False, str(e)
|
||
-
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
-def restore_backup(backup_id, encryption_key=None):
|
||
- """Stellt ein Backup wieder her"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Backup-Info abrufen
|
||
- cur.execute("""
|
||
- SELECT filename, filepath, is_encrypted
|
||
- FROM backup_history
|
||
- WHERE id = %s
|
||
- """, (backup_id,))
|
||
- backup_info = cur.fetchone()
|
||
-
|
||
- if not backup_info:
|
||
- raise Exception("Backup nicht gefunden")
|
||
-
|
||
- filename, filepath, is_encrypted = backup_info
|
||
- filepath = Path(filepath)
|
||
-
|
||
- if not filepath.exists():
|
||
- raise Exception("Backup-Datei nicht gefunden")
|
||
-
|
||
- # Datei lesen
|
||
- encrypted_data = filepath.read_bytes()
|
||
-
|
||
- # Entschlüsseln
|
||
- if is_encrypted:
|
||
- key = encryption_key.encode() if encryption_key else get_or_create_encryption_key()
|
||
- try:
|
||
- f = Fernet(key)
|
||
- compressed_data = f.decrypt(encrypted_data)
|
||
- except:
|
||
- raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?")
|
||
- else:
|
||
- compressed_data = encrypted_data
|
||
-
|
||
- # Dekomprimieren
|
||
- dump_data = gzip.decompress(compressed_data)
|
||
- sql_commands = dump_data.decode('utf-8')
|
||
-
|
||
- # Bestehende Verbindungen schließen
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Datenbank wiederherstellen
|
||
- restore_command = [
|
||
- 'psql',
|
||
- '-h', os.getenv("POSTGRES_HOST", "postgres"),
|
||
- '-p', os.getenv("POSTGRES_PORT", "5432"),
|
||
- '-U', os.getenv("POSTGRES_USER"),
|
||
- '-d', os.getenv("POSTGRES_DB"),
|
||
- '--no-password'
|
||
- ]
|
||
-
|
||
- env = os.environ.copy()
|
||
- env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
|
||
-
|
||
- result = subprocess.run(restore_command, input=sql_commands,
|
||
- capture_output=True, text=True, env=env)
|
||
-
|
||
- if result.returncode != 0:
|
||
- raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}")
|
||
-
|
||
- # Audit-Log (neue Verbindung)
|
||
- log_audit('RESTORE', 'database', backup_id,
|
||
- additional_info=f"Backup wiederhergestellt: {filename}")
|
||
-
|
||
- return True, "Backup erfolgreich wiederhergestellt"
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Wiederherstellung fehlgeschlagen: {e}")
|
||
- return False, str(e)
|
||
-
|
||
-def send_backup_notification(success, filename, filesize=None, duration=None, error=None):
|
||
- """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)"""
|
||
- if not os.getenv("EMAIL_ENABLED", "false").lower() == "true":
|
||
- return
|
||
-
|
||
- # E-Mail-Funktion vorbereitet aber deaktiviert
|
||
- # TODO: Implementieren wenn E-Mail-Server konfiguriert ist
|
||
- logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}")
|
||
-
|
||
-# Scheduled Backup Job
|
||
-def scheduled_backup():
|
||
- """Führt ein geplantes Backup aus"""
|
||
- logging.info("Starte geplantes Backup...")
|
||
- create_backup(backup_type="scheduled", created_by="scheduler")
|
||
-
|
||
-# Scheduler konfigurieren - täglich um 3:00 Uhr
|
||
-scheduler.add_job(
|
||
- scheduled_backup,
|
||
- 'cron',
|
||
- hour=3,
|
||
- minute=0,
|
||
- id='daily_backup',
|
||
- replace_existing=True
|
||
-)
|
||
-
|
||
-# Rate-Limiting Funktionen
|
||
-def get_client_ip():
|
||
- """Ermittelt die echte IP-Adresse des Clients"""
|
||
- # Debug logging
|
||
- app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}")
|
||
-
|
||
- # Try X-Real-IP first (set by nginx)
|
||
- if request.headers.get('X-Real-IP'):
|
||
- return request.headers.get('X-Real-IP')
|
||
- # Then X-Forwarded-For
|
||
- elif request.headers.get('X-Forwarded-For'):
|
||
- # X-Forwarded-For can contain multiple IPs, take the first one
|
||
- return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||
- # Fallback to remote_addr
|
||
- else:
|
||
- return request.remote_addr
|
||
-
|
||
-def check_ip_blocked(ip_address):
|
||
- """Prüft ob eine IP-Adresse gesperrt ist"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- cur.execute("""
|
||
- SELECT blocked_until FROM login_attempts
|
||
- WHERE ip_address = %s AND blocked_until IS NOT NULL
|
||
- """, (ip_address,))
|
||
-
|
||
- result = cur.fetchone()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- if result and result[0]:
|
||
- if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None):
|
||
- return True, result[0]
|
||
- return False, None
|
||
-
|
||
-def record_failed_attempt(ip_address, username):
|
||
- """Zeichnet einen fehlgeschlagenen Login-Versuch auf"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Random Fehlermeldung
|
||
- error_message = random.choice(FAIL_MESSAGES)
|
||
-
|
||
- try:
|
||
- # Prüfen ob IP bereits existiert
|
||
- cur.execute("""
|
||
- SELECT attempt_count FROM login_attempts
|
||
- WHERE ip_address = %s
|
||
- """, (ip_address,))
|
||
-
|
||
- result = cur.fetchone()
|
||
-
|
||
- if result:
|
||
- # Update bestehenden Eintrag
|
||
- new_count = result[0] + 1
|
||
- blocked_until = None
|
||
-
|
||
- if new_count >= MAX_LOGIN_ATTEMPTS:
|
||
- blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS)
|
||
- # E-Mail-Benachrichtigung (wenn aktiviert)
|
||
- if os.getenv("EMAIL_ENABLED", "false").lower() == "true":
|
||
- send_security_alert_email(ip_address, username, new_count)
|
||
-
|
||
- cur.execute("""
|
||
- UPDATE login_attempts
|
||
- SET attempt_count = %s,
|
||
- last_attempt = CURRENT_TIMESTAMP,
|
||
- blocked_until = %s,
|
||
- last_username_tried = %s,
|
||
- last_error_message = %s
|
||
- WHERE ip_address = %s
|
||
- """, (new_count, blocked_until, username, error_message, ip_address))
|
||
- else:
|
||
- # Neuen Eintrag erstellen
|
||
- cur.execute("""
|
||
- INSERT INTO login_attempts
|
||
- (ip_address, attempt_count, last_username_tried, last_error_message)
|
||
- VALUES (%s, 1, %s, %s)
|
||
- """, (ip_address, username, error_message))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('LOGIN_FAILED', 'user',
|
||
- additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}")
|
||
-
|
||
- except Exception as e:
|
||
- print(f"Rate limiting error: {e}")
|
||
- conn.rollback()
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return error_message
|
||
-
|
||
-def reset_login_attempts(ip_address):
|
||
- """Setzt die Login-Versuche für eine IP zurück"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- cur.execute("""
|
||
- DELETE FROM login_attempts
|
||
- WHERE ip_address = %s
|
||
- """, (ip_address,))
|
||
- conn.commit()
|
||
- except Exception as e:
|
||
- print(f"Reset attempts error: {e}")
|
||
- conn.rollback()
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
-def get_login_attempts(ip_address):
|
||
- """Gibt die Anzahl der Login-Versuche für eine IP zurück"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- cur.execute("""
|
||
- SELECT attempt_count FROM login_attempts
|
||
- WHERE ip_address = %s
|
||
- """, (ip_address,))
|
||
-
|
||
- result = cur.fetchone()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return result[0] if result else 0
|
||
-
|
||
-def send_security_alert_email(ip_address, username, attempt_count):
|
||
- """Sendet eine Sicherheitswarnung per E-Mail"""
|
||
- subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche"
|
||
- body = f"""
|
||
- WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt!
|
||
-
|
||
- IP-Adresse: {ip_address}
|
||
- Versuchter Benutzername: {username}
|
||
- Anzahl Versuche: {attempt_count}
|
||
- Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')}
|
||
-
|
||
- Die IP-Adresse wurde für 24 Stunden gesperrt.
|
||
-
|
||
- Dies ist eine automatische Nachricht vom v2-Docker Admin Panel.
|
||
- """
|
||
-
|
||
- # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert
|
||
- logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}")
|
||
- print(f"E-Mail würde gesendet: {subject}")
|
||
-
|
||
-def verify_recaptcha(response):
|
||
- """Verifiziert die reCAPTCHA v2 Response mit Google"""
|
||
- secret_key = os.getenv('RECAPTCHA_SECRET_KEY')
|
||
-
|
||
- # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC)
|
||
- if not secret_key:
|
||
- logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen")
|
||
- return True
|
||
-
|
||
- # Verifizierung bei Google
|
||
- try:
|
||
- verify_url = 'https://www.google.com/recaptcha/api/siteverify'
|
||
- data = {
|
||
- 'secret': secret_key,
|
||
- 'response': response
|
||
- }
|
||
-
|
||
- # Timeout für Request setzen
|
||
- r = requests.post(verify_url, data=data, timeout=5)
|
||
- result = r.json()
|
||
-
|
||
- # Log für Debugging
|
||
- if not result.get('success'):
|
||
- logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}")
|
||
-
|
||
- return result.get('success', False)
|
||
-
|
||
- except requests.exceptions.RequestException as e:
|
||
- logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}")
|
||
- # Bei Netzwerkfehlern CAPTCHA als bestanden werten
|
||
- return True
|
||
- except Exception as e:
|
||
- logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}")
|
||
- return False
|
||
-
|
||
-def generate_license_key(license_type='full'):
|
||
- """
|
||
- Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ
|
||
-
|
||
- AF = Account Factory (Produktkennung)
|
||
- F/T = F für Fullversion, T für Testversion
|
||
- YYYY = Jahr
|
||
- MM = Monat
|
||
- XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen
|
||
- """
|
||
- # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l)
|
||
- chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||
-
|
||
- # Datum-Teil
|
||
- now = datetime.now(ZoneInfo("Europe/Berlin"))
|
||
- date_part = now.strftime('%Y%m')
|
||
- type_char = 'F' if license_type == 'full' else 'T'
|
||
-
|
||
- # Zufällige Teile generieren (3 Blöcke à 4 Zeichen)
|
||
- parts = []
|
||
- for _ in range(3):
|
||
- part = ''.join(secrets.choice(chars) for _ in range(4))
|
||
- parts.append(part)
|
||
-
|
||
- # Key zusammensetzen
|
||
- key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}"
|
||
-
|
||
- return key
|
||
-
|
||
-def validate_license_key(key):
|
||
- """
|
||
- Validiert das License Key Format
|
||
- Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ
|
||
- """
|
||
- if not key:
|
||
- return False
|
||
-
|
||
- # Pattern für das neue Format
|
||
- # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen
|
||
- pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||
-
|
||
- # Großbuchstaben für Vergleich
|
||
- return bool(re.match(pattern, key.upper()))
|
||
-
|
||
-@app.route("/login", methods=["GET", "POST"])
|
||
-def login():
|
||
- # Timing-Attack Schutz - Start Zeit merken
|
||
- start_time = time.time()
|
||
-
|
||
- # IP-Adresse ermitteln
|
||
- ip_address = get_client_ip()
|
||
-
|
||
- # Prüfen ob IP gesperrt ist
|
||
- is_blocked, blocked_until = check_ip_blocked(ip_address)
|
||
- if is_blocked:
|
||
- time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600
|
||
- error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten."
|
||
- return render_template("login.html", error=error_msg, error_type="blocked")
|
||
-
|
||
- # Anzahl bisheriger Versuche
|
||
- attempt_count = get_login_attempts(ip_address)
|
||
-
|
||
- if request.method == "POST":
|
||
- username = request.form.get("username")
|
||
- password = request.form.get("password")
|
||
- captcha_response = request.form.get("g-recaptcha-response")
|
||
-
|
||
- # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind
|
||
- recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY')
|
||
- if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key:
|
||
- if not captcha_response:
|
||
- # Timing-Attack Schutz
|
||
- elapsed = time.time() - start_time
|
||
- if elapsed < 1.0:
|
||
- time.sleep(1.0 - elapsed)
|
||
- return render_template("login.html",
|
||
- error="CAPTCHA ERFORDERLICH!",
|
||
- show_captcha=True,
|
||
- error_type="captcha",
|
||
- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
- recaptcha_site_key=recaptcha_site_key)
|
||
-
|
||
- # CAPTCHA validieren
|
||
- if not verify_recaptcha(captcha_response):
|
||
- # Timing-Attack Schutz
|
||
- elapsed = time.time() - start_time
|
||
- if elapsed < 1.0:
|
||
- time.sleep(1.0 - elapsed)
|
||
- return render_template("login.html",
|
||
- error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.",
|
||
- show_captcha=True,
|
||
- error_type="captcha",
|
||
- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
- recaptcha_site_key=recaptcha_site_key)
|
||
-
|
||
- # Check user in database first, fallback to env vars
|
||
- user = get_user_by_username(username)
|
||
- login_success = False
|
||
- needs_2fa = False
|
||
-
|
||
- if user:
|
||
- # Database user authentication
|
||
- if verify_password(password, user['password_hash']):
|
||
- login_success = True
|
||
- needs_2fa = user['totp_enabled']
|
||
- else:
|
||
- # Fallback to environment variables for backward compatibility
|
||
- admin1_user = os.getenv("ADMIN1_USERNAME")
|
||
- admin1_pass = os.getenv("ADMIN1_PASSWORD")
|
||
- admin2_user = os.getenv("ADMIN2_USERNAME")
|
||
- admin2_pass = os.getenv("ADMIN2_PASSWORD")
|
||
-
|
||
- if ((username == admin1_user and password == admin1_pass) or
|
||
- (username == admin2_user and password == admin2_pass)):
|
||
- login_success = True
|
||
-
|
||
- # Timing-Attack Schutz - Mindestens 1 Sekunde warten
|
||
- elapsed = time.time() - start_time
|
||
- if elapsed < 1.0:
|
||
- time.sleep(1.0 - elapsed)
|
||
-
|
||
- if login_success:
|
||
- # Erfolgreicher Login
|
||
- if needs_2fa:
|
||
- # Store temporary session for 2FA verification
|
||
- session['temp_username'] = username
|
||
- session['temp_user_id'] = user['id']
|
||
- session['awaiting_2fa'] = True
|
||
- return redirect(url_for('verify_2fa'))
|
||
- else:
|
||
- # Complete login without 2FA
|
||
- session.permanent = True # Aktiviert das Timeout
|
||
- session['logged_in'] = True
|
||
- session['username'] = username
|
||
- session['user_id'] = user['id'] if user else None
|
||
- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- reset_login_attempts(ip_address)
|
||
- log_audit('LOGIN_SUCCESS', 'user',
|
||
- additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}")
|
||
- return redirect(url_for('dashboard'))
|
||
- else:
|
||
- # Fehlgeschlagener Login
|
||
- error_message = record_failed_attempt(ip_address, username)
|
||
- new_attempt_count = get_login_attempts(ip_address)
|
||
-
|
||
- # Prüfen ob jetzt gesperrt
|
||
- is_now_blocked, _ = check_ip_blocked(ip_address)
|
||
- if is_now_blocked:
|
||
- log_audit('LOGIN_BLOCKED', 'security',
|
||
- additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt")
|
||
-
|
||
- return render_template("login.html",
|
||
- error=error_message,
|
||
- show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')),
|
||
- error_type="failed",
|
||
- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count),
|
||
- recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY'))
|
||
-
|
||
- # GET Request
|
||
- return render_template("login.html",
|
||
- show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')),
|
||
- attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
- recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY'))
|
||
-
|
||
-@app.route("/logout")
|
||
-def logout():
|
||
- username = session.get('username', 'unknown')
|
||
- log_audit('LOGOUT', 'user', additional_info=f"Abmeldung")
|
||
- session.pop('logged_in', None)
|
||
- session.pop('username', None)
|
||
- session.pop('user_id', None)
|
||
- session.pop('temp_username', None)
|
||
- session.pop('temp_user_id', None)
|
||
- session.pop('awaiting_2fa', None)
|
||
- return redirect(url_for('login'))
|
||
-
|
||
-@app.route("/verify-2fa", methods=["GET", "POST"])
|
||
-def verify_2fa():
|
||
- if not session.get('awaiting_2fa'):
|
||
- return redirect(url_for('login'))
|
||
-
|
||
- if request.method == "POST":
|
||
- token = request.form.get('token', '').replace(' ', '')
|
||
- username = session.get('temp_username')
|
||
- user_id = session.get('temp_user_id')
|
||
-
|
||
- if not username or not user_id:
|
||
- flash('Session expired. Please login again.', 'error')
|
||
- return redirect(url_for('login'))
|
||
-
|
||
- user = get_user_by_username(username)
|
||
- if not user:
|
||
- flash('User not found.', 'error')
|
||
- return redirect(url_for('login'))
|
||
-
|
||
- # Check if it's a backup code
|
||
- if len(token) == 8 and token.isupper():
|
||
- # Try backup code
|
||
- backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else []
|
||
- if verify_backup_code(token, backup_codes):
|
||
- # Remove used backup code
|
||
- code_hash = hash_backup_code(token)
|
||
- backup_codes.remove(code_hash)
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s",
|
||
- (json.dumps(backup_codes), user_id))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Complete login
|
||
- session.permanent = True
|
||
- session['logged_in'] = True
|
||
- session['username'] = username
|
||
- session['user_id'] = user_id
|
||
- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- session.pop('temp_username', None)
|
||
- session.pop('temp_user_id', None)
|
||
- session.pop('awaiting_2fa', None)
|
||
-
|
||
- flash('Login successful using backup code. Please generate new backup codes.', 'warning')
|
||
- log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code")
|
||
- return redirect(url_for('dashboard'))
|
||
- else:
|
||
- # Try TOTP token
|
||
- if verify_totp(user['totp_secret'], token):
|
||
- # Complete login
|
||
- session.permanent = True
|
||
- session['logged_in'] = True
|
||
- session['username'] = username
|
||
- session['user_id'] = user_id
|
||
- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- session.pop('temp_username', None)
|
||
- session.pop('temp_user_id', None)
|
||
- session.pop('awaiting_2fa', None)
|
||
-
|
||
- log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful")
|
||
- return redirect(url_for('dashboard'))
|
||
-
|
||
- # Failed verification
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s",
|
||
- (datetime.now(), user_id))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- flash('Invalid authentication code. Please try again.', 'error')
|
||
- log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt")
|
||
-
|
||
- return render_template('verify_2fa.html')
|
||
-
|
||
-@app.route("/profile")
|
||
-@login_required
|
||
-def profile():
|
||
- user = get_user_by_username(session['username'])
|
||
- if not user:
|
||
- # For environment-based users, redirect with message
|
||
- flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info')
|
||
- return redirect(url_for('dashboard'))
|
||
- return render_template('profile.html', user=user)
|
||
-
|
||
-@app.route("/profile/change-password", methods=["POST"])
|
||
-@login_required
|
||
-def change_password():
|
||
- current_password = request.form.get('current_password')
|
||
- new_password = request.form.get('new_password')
|
||
- confirm_password = request.form.get('confirm_password')
|
||
-
|
||
- user = get_user_by_username(session['username'])
|
||
-
|
||
- # Verify current password
|
||
- if not verify_password(current_password, user['password_hash']):
|
||
- flash('Current password is incorrect.', 'error')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
- # Check new password
|
||
- if new_password != confirm_password:
|
||
- flash('New passwords do not match.', 'error')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
- if len(new_password) < 8:
|
||
- flash('Password must be at least 8 characters long.', 'error')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
- # Update password
|
||
- new_hash = hash_password(new_password)
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s",
|
||
- (new_hash, datetime.now(), user['id']))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'],
|
||
- additional_info="Password changed successfully")
|
||
- flash('Password changed successfully.', 'success')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
-@app.route("/profile/setup-2fa")
|
||
-@login_required
|
||
-def setup_2fa():
|
||
- user = get_user_by_username(session['username'])
|
||
-
|
||
- if user['totp_enabled']:
|
||
- flash('2FA is already enabled for your account.', 'info')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
- # Generate new TOTP secret
|
||
- totp_secret = generate_totp_secret()
|
||
- session['temp_totp_secret'] = totp_secret
|
||
-
|
||
- # Generate QR code
|
||
- qr_code = generate_qr_code(user['username'], totp_secret)
|
||
-
|
||
- return render_template('setup_2fa.html',
|
||
- totp_secret=totp_secret,
|
||
- qr_code=qr_code)
|
||
-
|
||
-@app.route("/profile/enable-2fa", methods=["POST"])
|
||
-@login_required
|
||
-def enable_2fa():
|
||
- token = request.form.get('token', '').replace(' ', '')
|
||
- totp_secret = session.get('temp_totp_secret')
|
||
-
|
||
- if not totp_secret:
|
||
- flash('2FA setup session expired. Please try again.', 'error')
|
||
- return redirect(url_for('setup_2fa'))
|
||
-
|
||
- # Verify the token
|
||
- if not verify_totp(totp_secret, token):
|
||
- flash('Invalid authentication code. Please try again.', 'error')
|
||
- return redirect(url_for('setup_2fa'))
|
||
-
|
||
- # Generate backup codes
|
||
- backup_codes = generate_backup_codes()
|
||
- hashed_codes = [hash_backup_code(code) for code in backup_codes]
|
||
-
|
||
- # Enable 2FA
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("""
|
||
- UPDATE users
|
||
- SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s
|
||
- WHERE username = %s
|
||
- """, (totp_secret, json.dumps(hashed_codes), session['username']))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- session.pop('temp_totp_secret', None)
|
||
-
|
||
- log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully")
|
||
-
|
||
- # Show backup codes
|
||
- return render_template('backup_codes.html', backup_codes=backup_codes)
|
||
-
|
||
-@app.route("/profile/disable-2fa", methods=["POST"])
|
||
-@login_required
|
||
-def disable_2fa():
|
||
- password = request.form.get('password')
|
||
- user = get_user_by_username(session['username'])
|
||
-
|
||
- # Verify password
|
||
- if not verify_password(password, user['password_hash']):
|
||
- flash('Incorrect password.', 'error')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
- # Disable 2FA
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
- cur.execute("""
|
||
- UPDATE users
|
||
- SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL
|
||
- WHERE username = %s
|
||
- """, (session['username'],))
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled")
|
||
- flash('2FA has been disabled for your account.', 'success')
|
||
- return redirect(url_for('profile'))
|
||
-
|
||
-@app.route("/heartbeat", methods=['POST'])
|
||
-@login_required
|
||
-def heartbeat():
|
||
- """Endpoint für Session Keep-Alive - aktualisiert last_activity"""
|
||
- # Aktualisiere last_activity nur wenn explizit angefordert
|
||
- session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- # Force session save
|
||
- session.modified = True
|
||
-
|
||
- return jsonify({
|
||
- 'status': 'ok',
|
||
- 'last_activity': session['last_activity'],
|
||
- 'username': session.get('username')
|
||
- })
|
||
-
|
||
-@app.route("/api/generate-license-key", methods=['POST'])
|
||
-@login_required
|
||
-def api_generate_key():
|
||
- """API Endpoint zur Generierung eines neuen Lizenzschlüssels"""
|
||
- try:
|
||
- # Lizenztyp aus Request holen (default: full)
|
||
- data = request.get_json() or {}
|
||
- license_type = data.get('type', 'full')
|
||
-
|
||
- # Key generieren
|
||
- key = generate_license_key(license_type)
|
||
-
|
||
- # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher)
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Wiederhole bis eindeutiger Key gefunden
|
||
- attempts = 0
|
||
- while attempts < 10: # Max 10 Versuche
|
||
- cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,))
|
||
- if not cur.fetchone():
|
||
- break # Key ist eindeutig
|
||
- key = generate_license_key(license_type)
|
||
- attempts += 1
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Log für Audit
|
||
- log_audit('GENERATE_KEY', 'license',
|
||
- additional_info={'type': license_type, 'key': key})
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'key': key,
|
||
- 'type': license_type
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Fehler bei Key-Generierung: {str(e)}")
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'error': 'Fehler bei der Key-Generierung'
|
||
- }), 500
|
||
-
|
||
-@app.route("/api/customers", methods=['GET'])
|
||
-@login_required
|
||
-def api_customers():
|
||
- """API Endpoint für die Kundensuche mit Select2"""
|
||
- try:
|
||
- # Suchparameter
|
||
- search = request.args.get('q', '').strip()
|
||
- page = request.args.get('page', 1, type=int)
|
||
- per_page = 20
|
||
- customer_id = request.args.get('id', type=int)
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Einzelnen Kunden per ID abrufen
|
||
- if customer_id:
|
||
- cur.execute("""
|
||
- SELECT c.id, c.name, c.email,
|
||
- COUNT(l.id) as license_count
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- WHERE c.id = %s
|
||
- GROUP BY c.id, c.name, c.email
|
||
- """, (customer_id,))
|
||
-
|
||
- customer = cur.fetchone()
|
||
- results = []
|
||
- if customer:
|
||
- results.append({
|
||
- 'id': customer[0],
|
||
- 'text': f"{customer[1]} ({customer[2]})",
|
||
- 'name': customer[1],
|
||
- 'email': customer[2],
|
||
- 'license_count': customer[3]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'results': results,
|
||
- 'pagination': {'more': False}
|
||
- })
|
||
-
|
||
- # SQL Query mit optionaler Suche
|
||
- elif search:
|
||
- cur.execute("""
|
||
- SELECT c.id, c.name, c.email,
|
||
- COUNT(l.id) as license_count
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- WHERE LOWER(c.name) LIKE LOWER(%s)
|
||
- OR LOWER(c.email) LIKE LOWER(%s)
|
||
- GROUP BY c.id, c.name, c.email
|
||
- ORDER BY c.name
|
||
- LIMIT %s OFFSET %s
|
||
- """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page))
|
||
- else:
|
||
- cur.execute("""
|
||
- SELECT c.id, c.name, c.email,
|
||
- COUNT(l.id) as license_count
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- GROUP BY c.id, c.name, c.email
|
||
- ORDER BY c.name
|
||
- LIMIT %s OFFSET %s
|
||
- """, (per_page, (page - 1) * per_page))
|
||
-
|
||
- customers = cur.fetchall()
|
||
-
|
||
- # Format für Select2
|
||
- results = []
|
||
- for customer in customers:
|
||
- results.append({
|
||
- 'id': customer[0],
|
||
- 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)",
|
||
- 'name': customer[1],
|
||
- 'email': customer[2],
|
||
- 'license_count': customer[3]
|
||
- })
|
||
-
|
||
- # Gesamtanzahl für Pagination
|
||
- if search:
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM customers
|
||
- WHERE LOWER(name) LIKE LOWER(%s)
|
||
- OR LOWER(email) LIKE LOWER(%s)
|
||
- """, (f'%{search}%', f'%{search}%'))
|
||
- else:
|
||
- cur.execute("SELECT COUNT(*) FROM customers")
|
||
-
|
||
- total_count = cur.fetchone()[0]
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Select2 Response Format
|
||
- return jsonify({
|
||
- 'results': results,
|
||
- 'pagination': {
|
||
- 'more': (page * per_page) < total_count
|
||
- }
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Fehler bei Kundensuche: {str(e)}")
|
||
- return jsonify({
|
||
- 'results': [],
|
||
- 'error': 'Fehler bei der Kundensuche'
|
||
- }), 500
|
||
-
|
||
-@app.route("/")
|
||
-@login_required
|
||
-def dashboard():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Statistiken abrufen
|
||
- # Gesamtanzahl Kunden (ohne Testdaten)
|
||
- cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE")
|
||
- total_customers = cur.fetchone()[0]
|
||
-
|
||
- # Gesamtanzahl Lizenzen (ohne Testdaten)
|
||
- cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE")
|
||
- total_licenses = cur.fetchone()[0]
|
||
-
|
||
- # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM licenses
|
||
- WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE
|
||
- """)
|
||
- active_licenses = cur.fetchone()[0]
|
||
-
|
||
- # Aktive Sessions
|
||
- cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE")
|
||
- active_sessions_count = cur.fetchone()[0]
|
||
-
|
||
- # Abgelaufene Lizenzen (ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM licenses
|
||
- WHERE valid_until < CURRENT_DATE AND is_test = FALSE
|
||
- """)
|
||
- expired_licenses = cur.fetchone()[0]
|
||
-
|
||
- # Deaktivierte Lizenzen (ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM licenses
|
||
- WHERE is_active = FALSE AND is_test = FALSE
|
||
- """)
|
||
- inactive_licenses = cur.fetchone()[0]
|
||
-
|
||
- # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM licenses
|
||
- WHERE valid_until >= CURRENT_DATE
|
||
- AND valid_until < CURRENT_DATE + INTERVAL '30 days'
|
||
- AND is_active = TRUE
|
||
- AND is_test = FALSE
|
||
- """)
|
||
- expiring_soon = cur.fetchone()[0]
|
||
-
|
||
- # Testlizenzen vs Vollversionen (ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT license_type, COUNT(*)
|
||
- FROM licenses
|
||
- WHERE is_test = FALSE
|
||
- GROUP BY license_type
|
||
- """)
|
||
- license_types = dict(cur.fetchall())
|
||
-
|
||
- # Anzahl Testdaten
|
||
- cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE")
|
||
- test_data_count = cur.fetchone()[0]
|
||
-
|
||
- # Anzahl Test-Kunden
|
||
- cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE")
|
||
- test_customers_count = cur.fetchone()[0]
|
||
-
|
||
- # Anzahl Test-Ressourcen
|
||
- cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE")
|
||
- test_resources_count = cur.fetchone()[0]
|
||
-
|
||
- # Letzte 5 erstellten Lizenzen (ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT l.id, l.license_key, c.name, l.valid_until,
|
||
- CASE
|
||
- WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
- ELSE 'aktiv'
|
||
- END as status
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE l.is_test = FALSE
|
||
- ORDER BY l.id DESC
|
||
- LIMIT 5
|
||
- """)
|
||
- recent_licenses = cur.fetchall()
|
||
-
|
||
- # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten)
|
||
- cur.execute("""
|
||
- SELECT l.id, l.license_key, c.name, l.valid_until,
|
||
- l.valid_until - CURRENT_DATE as days_left
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE l.valid_until >= CURRENT_DATE
|
||
- AND l.valid_until < CURRENT_DATE + INTERVAL '30 days'
|
||
- AND l.is_active = TRUE
|
||
- AND l.is_test = FALSE
|
||
- ORDER BY l.valid_until
|
||
- LIMIT 10
|
||
- """)
|
||
- expiring_licenses = cur.fetchall()
|
||
-
|
||
- # Letztes Backup
|
||
- cur.execute("""
|
||
- SELECT created_at, filesize, duration_seconds, backup_type, status
|
||
- FROM backup_history
|
||
- ORDER BY created_at DESC
|
||
- LIMIT 1
|
||
- """)
|
||
- last_backup_info = cur.fetchone()
|
||
-
|
||
- # Sicherheitsstatistiken
|
||
- # Gesperrte IPs
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM login_attempts
|
||
- WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP
|
||
- """)
|
||
- blocked_ips_count = cur.fetchone()[0]
|
||
-
|
||
- # Fehlversuche heute
|
||
- cur.execute("""
|
||
- SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts
|
||
- WHERE last_attempt::date = CURRENT_DATE
|
||
- """)
|
||
- failed_attempts_today = cur.fetchone()[0]
|
||
-
|
||
- # Letzte 5 Sicherheitsereignisse
|
||
- cur.execute("""
|
||
- SELECT
|
||
- la.ip_address,
|
||
- la.attempt_count,
|
||
- la.last_attempt,
|
||
- la.blocked_until,
|
||
- la.last_username_tried,
|
||
- la.last_error_message
|
||
- FROM login_attempts la
|
||
- ORDER BY la.last_attempt DESC
|
||
- LIMIT 5
|
||
- """)
|
||
- recent_security_events = []
|
||
- for event in cur.fetchall():
|
||
- recent_security_events.append({
|
||
- 'ip_address': event[0],
|
||
- 'attempt_count': event[1],
|
||
- 'last_attempt': event[2].strftime('%d.%m %H:%M'),
|
||
- 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None,
|
||
- 'username_tried': event[4],
|
||
- 'error_message': event[5]
|
||
- })
|
||
-
|
||
- # Sicherheitslevel berechnen
|
||
- if blocked_ips_count > 5 or failed_attempts_today > 50:
|
||
- security_level = 'danger'
|
||
- security_level_text = 'KRITISCH'
|
||
- elif blocked_ips_count > 2 or failed_attempts_today > 20:
|
||
- security_level = 'warning'
|
||
- security_level_text = 'ERHÖHT'
|
||
- else:
|
||
- security_level = 'success'
|
||
- security_level_text = 'NORMAL'
|
||
-
|
||
- # Resource Pool Statistiken (nur Live-Daten, keine Testdaten)
|
||
- cur.execute("""
|
||
- SELECT
|
||
- resource_type,
|
||
- COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
- COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
- COUNT(*) as total
|
||
- FROM resource_pools
|
||
- WHERE is_test = FALSE
|
||
- GROUP BY resource_type
|
||
- """)
|
||
-
|
||
- resource_stats = {}
|
||
- resource_warning = None
|
||
-
|
||
- for row in cur.fetchall():
|
||
- available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1)
|
||
- resource_stats[row[0]] = {
|
||
- 'available': row[1],
|
||
- 'allocated': row[2],
|
||
- 'quarantine': row[3],
|
||
- 'total': row[4],
|
||
- 'available_percent': available_percent,
|
||
- 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success'
|
||
- }
|
||
-
|
||
- # Warnung bei niedrigem Bestand
|
||
- if row[1] < 50:
|
||
- if not resource_warning:
|
||
- resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!"
|
||
- else:
|
||
- resource_warning += f" | {row[0].upper()}: {row[1]}"
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- stats = {
|
||
- 'total_customers': total_customers,
|
||
- 'total_licenses': total_licenses,
|
||
- 'active_licenses': active_licenses,
|
||
- 'expired_licenses': expired_licenses,
|
||
- 'inactive_licenses': inactive_licenses,
|
||
- 'expiring_soon': expiring_soon,
|
||
- 'full_licenses': license_types.get('full', 0),
|
||
- 'test_licenses': license_types.get('test', 0),
|
||
- 'test_data_count': test_data_count,
|
||
- 'test_customers_count': test_customers_count,
|
||
- 'test_resources_count': test_resources_count,
|
||
- 'recent_licenses': recent_licenses,
|
||
- 'expiring_licenses': expiring_licenses,
|
||
- 'active_sessions': active_sessions_count,
|
||
- 'last_backup': last_backup_info,
|
||
- # Sicherheitsstatistiken
|
||
- 'blocked_ips_count': blocked_ips_count,
|
||
- 'failed_attempts_today': failed_attempts_today,
|
||
- 'recent_security_events': recent_security_events,
|
||
- 'security_level': security_level,
|
||
- 'security_level_text': security_level_text,
|
||
- 'resource_stats': resource_stats
|
||
- }
|
||
-
|
||
- return render_template("dashboard.html",
|
||
- stats=stats,
|
||
- resource_stats=resource_stats,
|
||
- resource_warning=resource_warning,
|
||
- username=session.get('username'))
|
||
-
|
||
-@app.route("/create", methods=["GET", "POST"])
|
||
-@login_required
|
||
-def create_license():
|
||
- if request.method == "POST":
|
||
- customer_id = request.form.get("customer_id")
|
||
- license_key = request.form["license_key"].upper() # Immer Großbuchstaben
|
||
- license_type = request.form["license_type"]
|
||
- valid_from = request.form["valid_from"]
|
||
- is_test = request.form.get("is_test") == "on" # Checkbox value
|
||
-
|
||
- # Berechne valid_until basierend auf Laufzeit
|
||
- duration = int(request.form.get("duration", 1))
|
||
- duration_type = request.form.get("duration_type", "years")
|
||
-
|
||
- from datetime import datetime, timedelta
|
||
- from dateutil.relativedelta import relativedelta
|
||
-
|
||
- start_date = datetime.strptime(valid_from, "%Y-%m-%d")
|
||
-
|
||
- if duration_type == "days":
|
||
- end_date = start_date + timedelta(days=duration)
|
||
- elif duration_type == "months":
|
||
- end_date = start_date + relativedelta(months=duration)
|
||
- else: # years
|
||
- end_date = start_date + relativedelta(years=duration)
|
||
-
|
||
- # Ein Tag abziehen, da der Starttag mitgezählt wird
|
||
- end_date = end_date - timedelta(days=1)
|
||
- valid_until = end_date.strftime("%Y-%m-%d")
|
||
-
|
||
- # Validiere License Key Format
|
||
- if not validate_license_key(license_key):
|
||
- flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error')
|
||
- return redirect(url_for('create_license'))
|
||
-
|
||
- # Resource counts
|
||
- domain_count = int(request.form.get("domain_count", 1))
|
||
- ipv4_count = int(request.form.get("ipv4_count", 1))
|
||
- phone_count = int(request.form.get("phone_count", 1))
|
||
- device_limit = int(request.form.get("device_limit", 3))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Prüfe ob neuer Kunde oder bestehender
|
||
- if customer_id == "new":
|
||
- # Neuer Kunde
|
||
- name = request.form.get("customer_name")
|
||
- email = request.form.get("email")
|
||
-
|
||
- if not name:
|
||
- flash('Kundenname ist erforderlich!', 'error')
|
||
- return redirect(url_for('create_license'))
|
||
-
|
||
- # Prüfe ob E-Mail bereits existiert
|
||
- if email:
|
||
- cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||
- existing = cur.fetchone()
|
||
- if existing:
|
||
- flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||
- return redirect(url_for('create_license'))
|
||
-
|
||
- # Kunde einfügen (erbt Test-Status von Lizenz)
|
||
- cur.execute("""
|
||
- INSERT INTO customers (name, email, is_test, created_at)
|
||
- VALUES (%s, %s, %s, NOW())
|
||
- RETURNING id
|
||
- """, (name, email, is_test))
|
||
- customer_id = cur.fetchone()[0]
|
||
- customer_info = {'name': name, 'email': email, 'is_test': is_test}
|
||
-
|
||
- # Audit-Log für neuen Kunden
|
||
- log_audit('CREATE', 'customer', customer_id,
|
||
- new_values={'name': name, 'email': email, 'is_test': is_test})
|
||
- else:
|
||
- # Bestehender Kunde - hole Infos für Audit-Log
|
||
- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
- customer_data = cur.fetchone()
|
||
- if not customer_data:
|
||
- flash('Kunde nicht gefunden!', 'error')
|
||
- return redirect(url_for('create_license'))
|
||
- customer_info = {'name': customer_data[0], 'email': customer_data[1]}
|
||
-
|
||
- # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren
|
||
- if customer_data[2]: # is_test des Kunden
|
||
- is_test = True
|
||
-
|
||
- # Lizenz hinzufügen
|
||
- cur.execute("""
|
||
- INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active,
|
||
- domain_count, ipv4_count, phone_count, device_limit, is_test)
|
||
- VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s)
|
||
- RETURNING id
|
||
- """, (license_key, customer_id, license_type, valid_from, valid_until,
|
||
- domain_count, ipv4_count, phone_count, device_limit, is_test))
|
||
- license_id = cur.fetchone()[0]
|
||
-
|
||
- # Ressourcen zuweisen
|
||
- try:
|
||
- # Prüfe Verfügbarkeit
|
||
- cur.execute("""
|
||
- SELECT
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains,
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s,
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones
|
||
- """, (is_test, is_test, is_test))
|
||
- available = cur.fetchone()
|
||
-
|
||
- if available[0] < domain_count:
|
||
- raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})")
|
||
- if available[1] < ipv4_count:
|
||
- raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})")
|
||
- if available[2] < phone_count:
|
||
- raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})")
|
||
-
|
||
- # Domains zuweisen
|
||
- if domain_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, domain_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- # IPv4s zuweisen
|
||
- if ipv4_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, ipv4_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- # Telefonnummern zuweisen
|
||
- if phone_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, phone_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- except ValueError as e:
|
||
- conn.rollback()
|
||
- flash(str(e), 'error')
|
||
- return redirect(url_for('create_license'))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('CREATE', 'license', license_id,
|
||
- new_values={
|
||
- 'license_key': license_key,
|
||
- 'customer_name': customer_info['name'],
|
||
- 'customer_email': customer_info['email'],
|
||
- 'license_type': license_type,
|
||
- 'valid_from': valid_from,
|
||
- 'valid_until': valid_until,
|
||
- 'device_limit': device_limit,
|
||
- 'is_test': is_test
|
||
- })
|
||
-
|
||
- flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success')
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}")
|
||
- flash('Fehler beim Erstellen der Lizenz!', 'error')
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Preserve show_test parameter if present
|
||
- redirect_url = "/create"
|
||
- if request.args.get('show_test') == 'true':
|
||
- redirect_url += "?show_test=true"
|
||
- return redirect(redirect_url)
|
||
-
|
||
- # Unterstützung für vorausgewählten Kunden
|
||
- preselected_customer_id = request.args.get('customer_id', type=int)
|
||
- return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id)
|
||
-
|
||
-@app.route("/batch", methods=["GET", "POST"])
|
||
-@login_required
|
||
-def batch_licenses():
|
||
- """Batch-Generierung mehrerer Lizenzen für einen Kunden"""
|
||
- if request.method == "POST":
|
||
- # Formulardaten
|
||
- customer_id = request.form.get("customer_id")
|
||
- license_type = request.form["license_type"]
|
||
- quantity = int(request.form["quantity"])
|
||
- valid_from = request.form["valid_from"]
|
||
- is_test = request.form.get("is_test") == "on" # Checkbox value
|
||
-
|
||
- # Berechne valid_until basierend auf Laufzeit
|
||
- duration = int(request.form.get("duration", 1))
|
||
- duration_type = request.form.get("duration_type", "years")
|
||
-
|
||
- from datetime import datetime, timedelta
|
||
- from dateutil.relativedelta import relativedelta
|
||
-
|
||
- start_date = datetime.strptime(valid_from, "%Y-%m-%d")
|
||
-
|
||
- if duration_type == "days":
|
||
- end_date = start_date + timedelta(days=duration)
|
||
- elif duration_type == "months":
|
||
- end_date = start_date + relativedelta(months=duration)
|
||
- else: # years
|
||
- end_date = start_date + relativedelta(years=duration)
|
||
-
|
||
- # Ein Tag abziehen, da der Starttag mitgezählt wird
|
||
- end_date = end_date - timedelta(days=1)
|
||
- valid_until = end_date.strftime("%Y-%m-%d")
|
||
-
|
||
- # Resource counts
|
||
- domain_count = int(request.form.get("domain_count", 1))
|
||
- ipv4_count = int(request.form.get("ipv4_count", 1))
|
||
- phone_count = int(request.form.get("phone_count", 1))
|
||
- device_limit = int(request.form.get("device_limit", 3))
|
||
-
|
||
- # Sicherheitslimit
|
||
- if quantity < 1 or quantity > 100:
|
||
- flash('Anzahl muss zwischen 1 und 100 liegen!', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Prüfe ob neuer Kunde oder bestehender
|
||
- if customer_id == "new":
|
||
- # Neuer Kunde
|
||
- name = request.form.get("customer_name")
|
||
- email = request.form.get("email")
|
||
-
|
||
- if not name:
|
||
- flash('Kundenname ist erforderlich!', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
-
|
||
- # Prüfe ob E-Mail bereits existiert
|
||
- if email:
|
||
- cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||
- existing = cur.fetchone()
|
||
- if existing:
|
||
- flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
-
|
||
- # Kunde einfügen (erbt Test-Status von Lizenz)
|
||
- cur.execute("""
|
||
- INSERT INTO customers (name, email, is_test, created_at)
|
||
- VALUES (%s, %s, %s, NOW())
|
||
- RETURNING id
|
||
- """, (name, email, is_test))
|
||
- customer_id = cur.fetchone()[0]
|
||
-
|
||
- # Audit-Log für neuen Kunden
|
||
- log_audit('CREATE', 'customer', customer_id,
|
||
- new_values={'name': name, 'email': email, 'is_test': is_test})
|
||
- else:
|
||
- # Bestehender Kunde - hole Infos
|
||
- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
- customer_data = cur.fetchone()
|
||
- if not customer_data:
|
||
- flash('Kunde nicht gefunden!', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
- name = customer_data[0]
|
||
- email = customer_data[1]
|
||
-
|
||
- # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren
|
||
- if customer_data[2]: # is_test des Kunden
|
||
- is_test = True
|
||
-
|
||
- # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch
|
||
- total_domains_needed = domain_count * quantity
|
||
- total_ipv4s_needed = ipv4_count * quantity
|
||
- total_phones_needed = phone_count * quantity
|
||
-
|
||
- cur.execute("""
|
||
- SELECT
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains,
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s,
|
||
- (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones
|
||
- """, (is_test, is_test, is_test))
|
||
- available = cur.fetchone()
|
||
-
|
||
- if available[0] < total_domains_needed:
|
||
- flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
- if available[1] < total_ipv4s_needed:
|
||
- flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
- if available[2] < total_phones_needed:
|
||
- flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
-
|
||
- # Lizenzen generieren und speichern
|
||
- generated_licenses = []
|
||
- for i in range(quantity):
|
||
- # Eindeutigen Key generieren
|
||
- attempts = 0
|
||
- while attempts < 10:
|
||
- license_key = generate_license_key(license_type)
|
||
- cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,))
|
||
- if not cur.fetchone():
|
||
- break
|
||
- attempts += 1
|
||
-
|
||
- # Lizenz einfügen
|
||
- cur.execute("""
|
||
- INSERT INTO licenses (license_key, customer_id, license_type, is_test,
|
||
- valid_from, valid_until, is_active,
|
||
- domain_count, ipv4_count, phone_count, device_limit)
|
||
- VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s)
|
||
- RETURNING id
|
||
- """, (license_key, customer_id, license_type, is_test, valid_from, valid_until,
|
||
- domain_count, ipv4_count, phone_count, device_limit))
|
||
- license_id = cur.fetchone()[0]
|
||
-
|
||
- # Ressourcen für diese Lizenz zuweisen
|
||
- # Domains
|
||
- if domain_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, domain_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- # IPv4s
|
||
- if ipv4_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, ipv4_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- # Telefonnummern
|
||
- if phone_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id FROM resource_pools
|
||
- WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (is_test, phone_count))
|
||
- for (resource_id,) in cur.fetchall():
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated', allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], resource_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, resource_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- generated_licenses.append({
|
||
- 'id': license_id,
|
||
- 'key': license_key,
|
||
- 'type': license_type
|
||
- })
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('CREATE_BATCH', 'license',
|
||
- new_values={'customer': name, 'quantity': quantity, 'type': license_type},
|
||
- additional_info=f"Batch-Generierung von {quantity} Lizenzen")
|
||
-
|
||
- # Session für Export speichern
|
||
- session['batch_export'] = {
|
||
- 'customer': name,
|
||
- 'email': email,
|
||
- 'licenses': generated_licenses,
|
||
- 'valid_from': valid_from,
|
||
- 'valid_until': valid_until,
|
||
- 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
- }
|
||
-
|
||
- flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success')
|
||
- return render_template("batch_result.html",
|
||
- customer=name,
|
||
- email=email,
|
||
- licenses=generated_licenses,
|
||
- valid_from=valid_from,
|
||
- valid_until=valid_until)
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- logging.error(f"Fehler bei Batch-Generierung: {str(e)}")
|
||
- flash('Fehler bei der Batch-Generierung!', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # GET Request
|
||
- return render_template("batch_form.html")
|
||
-
|
||
-@app.route("/batch/export")
|
||
-@login_required
|
||
-def export_batch():
|
||
- """Exportiert die zuletzt generierten Batch-Lizenzen"""
|
||
- batch_data = session.get('batch_export')
|
||
- if not batch_data:
|
||
- flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error')
|
||
- return redirect(url_for('batch_licenses'))
|
||
-
|
||
- # CSV generieren
|
||
- output = io.StringIO()
|
||
- output.write('\ufeff') # UTF-8 BOM für Excel
|
||
-
|
||
- # Header
|
||
- output.write(f"Kunde: {batch_data['customer']}\n")
|
||
- output.write(f"E-Mail: {batch_data['email']}\n")
|
||
- output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n")
|
||
- output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n")
|
||
- output.write("\n")
|
||
- output.write("Nr;Lizenzschlüssel;Typ\n")
|
||
-
|
||
- # Lizenzen
|
||
- for i, license in enumerate(batch_data['licenses'], 1):
|
||
- typ_text = "Vollversion" if license['type'] == 'full' else "Testversion"
|
||
- output.write(f"{i};{license['key']};{typ_text}\n")
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('EXPORT', 'batch_licenses',
|
||
- additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen")
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
- mimetype='text/csv',
|
||
- as_attachment=True,
|
||
- download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv"
|
||
- )
|
||
-
|
||
-@app.route("/licenses")
|
||
-@login_required
|
||
-def licenses():
|
||
- # Redirect zur kombinierten Ansicht
|
||
- return redirect("/customers-licenses")
|
||
-
|
||
-@app.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
|
||
-@login_required
|
||
-def edit_license(license_id):
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- if request.method == "POST":
|
||
- # Alte Werte für Audit-Log abrufen
|
||
- cur.execute("""
|
||
- SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit
|
||
- FROM licenses WHERE id = %s
|
||
- """, (license_id,))
|
||
- old_license = cur.fetchone()
|
||
-
|
||
- # Update license
|
||
- license_key = request.form["license_key"]
|
||
- license_type = request.form["license_type"]
|
||
- valid_from = request.form["valid_from"]
|
||
- valid_until = request.form["valid_until"]
|
||
- is_active = request.form.get("is_active") == "on"
|
||
- is_test = request.form.get("is_test") == "on"
|
||
- device_limit = int(request.form.get("device_limit", 3))
|
||
-
|
||
- cur.execute("""
|
||
- UPDATE licenses
|
||
- SET license_key = %s, license_type = %s, valid_from = %s,
|
||
- valid_until = %s, is_active = %s, is_test = %s, device_limit = %s
|
||
- WHERE id = %s
|
||
- """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('UPDATE', 'license', license_id,
|
||
- old_values={
|
||
- 'license_key': old_license[0],
|
||
- 'license_type': old_license[1],
|
||
- 'valid_from': str(old_license[2]),
|
||
- 'valid_until': str(old_license[3]),
|
||
- 'is_active': old_license[4],
|
||
- 'is_test': old_license[5],
|
||
- 'device_limit': old_license[6]
|
||
- },
|
||
- new_values={
|
||
- 'license_key': license_key,
|
||
- 'license_type': license_type,
|
||
- 'valid_from': valid_from,
|
||
- 'valid_until': valid_until,
|
||
- 'is_active': is_active,
|
||
- 'is_test': is_test,
|
||
- 'device_limit': device_limit
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Redirect zurück zu customers-licenses mit beibehaltenen Parametern
|
||
- redirect_url = "/customers-licenses"
|
||
-
|
||
- # Behalte show_test Parameter bei (aus Form oder GET-Parameter)
|
||
- show_test = request.form.get('show_test') or request.args.get('show_test')
|
||
- if show_test == 'true':
|
||
- redirect_url += "?show_test=true"
|
||
-
|
||
- # Behalte customer_id bei wenn vorhanden
|
||
- if request.referrer and 'customer_id=' in request.referrer:
|
||
- import re
|
||
- match = re.search(r'customer_id=(\d+)', request.referrer)
|
||
- if match:
|
||
- connector = "&" if "?" in redirect_url else "?"
|
||
- redirect_url += f"{connector}customer_id={match.group(1)}"
|
||
-
|
||
- return redirect(redirect_url)
|
||
-
|
||
- # Get license data
|
||
- cur.execute("""
|
||
- SELECT l.id, l.license_key, c.name, c.email, l.license_type,
|
||
- l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE l.id = %s
|
||
- """, (license_id,))
|
||
-
|
||
- license = cur.fetchone()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- if not license:
|
||
- return redirect("/licenses")
|
||
-
|
||
- return render_template("edit_license.html", license=license, username=session.get('username'))
|
||
-
|
||
-@app.route("/license/delete/<int:license_id>", methods=["POST"])
|
||
-@login_required
|
||
-def delete_license(license_id):
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Lizenzdetails für Audit-Log abrufen
|
||
- cur.execute("""
|
||
- SELECT l.license_key, c.name, l.license_type
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE l.id = %s
|
||
- """, (license_id,))
|
||
- license_info = cur.fetchone()
|
||
-
|
||
- cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- if license_info:
|
||
- log_audit('DELETE', 'license', license_id,
|
||
- old_values={
|
||
- 'license_key': license_info[0],
|
||
- 'customer_name': license_info[1],
|
||
- 'license_type': license_info[2]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return redirect("/licenses")
|
||
-
|
||
-@app.route("/customers")
|
||
-@login_required
|
||
-def customers():
|
||
- # Redirect zur kombinierten Ansicht
|
||
- return redirect("/customers-licenses")
|
||
-
|
||
-@app.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
|
||
-@login_required
|
||
-def edit_customer(customer_id):
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- if request.method == "POST":
|
||
- # Alte Werte für Audit-Log abrufen
|
||
- cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
- old_customer = cur.fetchone()
|
||
-
|
||
- # Update customer
|
||
- name = request.form["name"]
|
||
- email = request.form["email"]
|
||
- is_test = request.form.get("is_test") == "on"
|
||
-
|
||
- cur.execute("""
|
||
- UPDATE customers
|
||
- SET name = %s, email = %s, is_test = %s
|
||
- WHERE id = %s
|
||
- """, (name, email, is_test, customer_id))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('UPDATE', 'customer', customer_id,
|
||
- old_values={
|
||
- 'name': old_customer[0],
|
||
- 'email': old_customer[1],
|
||
- 'is_test': old_customer[2]
|
||
- },
|
||
- new_values={
|
||
- 'name': name,
|
||
- 'email': email,
|
||
- 'is_test': is_test
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Redirect zurück zu customers-licenses mit beibehaltenen Parametern
|
||
- redirect_url = "/customers-licenses"
|
||
-
|
||
- # Behalte show_test Parameter bei (aus Form oder GET-Parameter)
|
||
- show_test = request.form.get('show_test') or request.args.get('show_test')
|
||
- if show_test == 'true':
|
||
- redirect_url += "?show_test=true"
|
||
-
|
||
- # Behalte customer_id bei (immer der aktuelle Kunde)
|
||
- connector = "&" if "?" in redirect_url else "?"
|
||
- redirect_url += f"{connector}customer_id={customer_id}"
|
||
-
|
||
- return redirect(redirect_url)
|
||
-
|
||
- # Get customer data with licenses
|
||
- cur.execute("""
|
||
- SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s
|
||
- """, (customer_id,))
|
||
-
|
||
- customer = cur.fetchone()
|
||
- if not customer:
|
||
- cur.close()
|
||
- conn.close()
|
||
- return "Kunde nicht gefunden", 404
|
||
-
|
||
-
|
||
- # Get customer's licenses
|
||
- cur.execute("""
|
||
- SELECT id, license_key, license_type, valid_from, valid_until, is_active
|
||
- FROM licenses
|
||
- WHERE customer_id = %s
|
||
- ORDER BY valid_until DESC
|
||
- """, (customer_id,))
|
||
-
|
||
- licenses = cur.fetchall()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- if not customer:
|
||
- return redirect("/customers-licenses")
|
||
-
|
||
- return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username'))
|
||
-
|
||
-@app.route("/customer/create", methods=["GET", "POST"])
|
||
-@login_required
|
||
-def create_customer():
|
||
- """Erstellt einen neuen Kunden ohne Lizenz"""
|
||
- if request.method == "POST":
|
||
- name = request.form.get('name')
|
||
- email = request.form.get('email')
|
||
- is_test = request.form.get('is_test') == 'on'
|
||
-
|
||
- if not name or not email:
|
||
- flash("Name und E-Mail sind Pflichtfelder!", "error")
|
||
- return render_template("create_customer.html", username=session.get('username'))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Prüfen ob E-Mail bereits existiert
|
||
- cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,))
|
||
- existing = cur.fetchone()
|
||
- if existing:
|
||
- flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error")
|
||
- return render_template("create_customer.html", username=session.get('username'))
|
||
-
|
||
- # Kunde erstellen
|
||
- cur.execute("""
|
||
- INSERT INTO customers (name, email, created_at, is_test)
|
||
- VALUES (%s, %s, %s, %s) RETURNING id
|
||
- """, (name, email, datetime.now(), is_test))
|
||
-
|
||
- customer_id = cur.fetchone()[0]
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('CREATE', 'customer', customer_id,
|
||
- new_values={
|
||
- 'name': name,
|
||
- 'email': email,
|
||
- 'is_test': is_test
|
||
- })
|
||
-
|
||
- flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success")
|
||
- return redirect(f"/customer/edit/{customer_id}")
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error")
|
||
- return render_template("create_customer.html", username=session.get('username'))
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # GET Request - Formular anzeigen
|
||
- return render_template("create_customer.html", username=session.get('username'))
|
||
-
|
||
-@app.route("/customer/delete/<int:customer_id>", methods=["POST"])
|
||
-@login_required
|
||
-def delete_customer(customer_id):
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Prüfen ob Kunde Lizenzen hat
|
||
- cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,))
|
||
- license_count = cur.fetchone()[0]
|
||
-
|
||
- if license_count > 0:
|
||
- # Kunde hat Lizenzen - nicht löschen
|
||
- cur.close()
|
||
- conn.close()
|
||
- return redirect("/customers")
|
||
-
|
||
- # Kundendetails für Audit-Log abrufen
|
||
- cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,))
|
||
- customer_info = cur.fetchone()
|
||
-
|
||
- # Kunde löschen wenn keine Lizenzen vorhanden
|
||
- cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- if customer_info:
|
||
- log_audit('DELETE', 'customer', customer_id,
|
||
- old_values={
|
||
- 'name': customer_info[0],
|
||
- 'email': customer_info[1]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return redirect("/customers")
|
||
-
|
||
-@app.route("/customers-licenses")
|
||
-@login_required
|
||
-def customers_licenses():
|
||
- """Kombinierte Ansicht für Kunden und deren Lizenzen"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht)
|
||
- show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
-
|
||
- query = """
|
||
- SELECT
|
||
- c.id,
|
||
- c.name,
|
||
- c.email,
|
||
- c.created_at,
|
||
- COUNT(l.id) as total_licenses,
|
||
- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- """
|
||
-
|
||
- if not show_test:
|
||
- query += " WHERE c.is_test = FALSE"
|
||
-
|
||
- query += """
|
||
- GROUP BY c.id, c.name, c.email, c.created_at
|
||
- ORDER BY c.name
|
||
- """
|
||
-
|
||
- cur.execute(query)
|
||
- customers = cur.fetchall()
|
||
-
|
||
- # Hole ausgewählten Kunden nur wenn explizit in URL angegeben
|
||
- selected_customer_id = request.args.get('customer_id', type=int)
|
||
- licenses = []
|
||
- selected_customer = None
|
||
-
|
||
- if customers and selected_customer_id:
|
||
- # Hole Daten des ausgewählten Kunden
|
||
- for customer in customers:
|
||
- if customer[0] == selected_customer_id:
|
||
- selected_customer = customer
|
||
- break
|
||
-
|
||
- # Hole Lizenzen des ausgewählten Kunden
|
||
- if selected_customer:
|
||
- cur.execute("""
|
||
- SELECT
|
||
- l.id,
|
||
- l.license_key,
|
||
- l.license_type,
|
||
- l.valid_from,
|
||
- l.valid_until,
|
||
- l.is_active,
|
||
- CASE
|
||
- WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
- ELSE 'aktiv'
|
||
- END as status,
|
||
- l.domain_count,
|
||
- l.ipv4_count,
|
||
- l.phone_count,
|
||
- l.device_limit,
|
||
- (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices,
|
||
- -- Actual resource counts
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count,
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count,
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count
|
||
- FROM licenses l
|
||
- WHERE l.customer_id = %s
|
||
- ORDER BY l.created_at DESC, l.id DESC
|
||
- """, (selected_customer_id,))
|
||
- licenses = cur.fetchall()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template("customers_licenses.html",
|
||
- customers=customers,
|
||
- selected_customer=selected_customer,
|
||
- selected_customer_id=selected_customer_id,
|
||
- licenses=licenses,
|
||
- show_test=show_test)
|
||
-
|
||
-@app.route("/api/customer/<int:customer_id>/licenses")
|
||
-@login_required
|
||
-def api_customer_licenses(customer_id):
|
||
- """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Hole Lizenzen des Kunden
|
||
- cur.execute("""
|
||
- SELECT
|
||
- l.id,
|
||
- l.license_key,
|
||
- l.license_type,
|
||
- l.valid_from,
|
||
- l.valid_until,
|
||
- l.is_active,
|
||
- CASE
|
||
- WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
- WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
- ELSE 'aktiv'
|
||
- END as status,
|
||
- l.domain_count,
|
||
- l.ipv4_count,
|
||
- l.phone_count,
|
||
- l.device_limit,
|
||
- (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices,
|
||
- -- Actual resource counts
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count,
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count,
|
||
- (SELECT COUNT(*) FROM license_resources lr
|
||
- JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
- WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count
|
||
- FROM licenses l
|
||
- WHERE l.customer_id = %s
|
||
- ORDER BY l.created_at DESC, l.id DESC
|
||
- """, (customer_id,))
|
||
-
|
||
- licenses = []
|
||
- for row in cur.fetchall():
|
||
- license_id = row[0]
|
||
-
|
||
- # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz
|
||
- cur.execute("""
|
||
- SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at
|
||
- FROM resource_pools rp
|
||
- JOIN license_resources lr ON rp.id = lr.resource_id
|
||
- WHERE lr.license_id = %s AND lr.is_active = true
|
||
- ORDER BY rp.resource_type, rp.resource_value
|
||
- """, (license_id,))
|
||
-
|
||
- resources = {
|
||
- 'domains': [],
|
||
- 'ipv4s': [],
|
||
- 'phones': []
|
||
- }
|
||
-
|
||
- for res_row in cur.fetchall():
|
||
- resource_info = {
|
||
- 'id': res_row[0],
|
||
- 'value': res_row[2],
|
||
- 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else ''
|
||
- }
|
||
-
|
||
- if res_row[1] == 'domain':
|
||
- resources['domains'].append(resource_info)
|
||
- elif res_row[1] == 'ipv4':
|
||
- resources['ipv4s'].append(resource_info)
|
||
- elif res_row[1] == 'phone':
|
||
- resources['phones'].append(resource_info)
|
||
-
|
||
- licenses.append({
|
||
- 'id': row[0],
|
||
- 'license_key': row[1],
|
||
- 'license_type': row[2],
|
||
- 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '',
|
||
- 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '',
|
||
- 'is_active': row[5],
|
||
- 'status': row[6],
|
||
- 'domain_count': row[7], # limit
|
||
- 'ipv4_count': row[8], # limit
|
||
- 'phone_count': row[9], # limit
|
||
- 'device_limit': row[10],
|
||
- 'active_devices': row[11],
|
||
- 'actual_domain_count': row[12], # actual count
|
||
- 'actual_ipv4_count': row[13], # actual count
|
||
- 'actual_phone_count': row[14], # actual count
|
||
- 'resources': resources
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'licenses': licenses,
|
||
- 'count': len(licenses)
|
||
- })
|
||
-
|
||
-@app.route("/api/customer/<int:customer_id>/quick-stats")
|
||
-@login_required
|
||
-def api_customer_quick_stats(customer_id):
|
||
- """API-Endpoint für Schnellstatistiken eines Kunden"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Hole Kundenstatistiken
|
||
- cur.execute("""
|
||
- SELECT
|
||
- COUNT(l.id) as total_licenses,
|
||
- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses,
|
||
- COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon
|
||
- FROM licenses l
|
||
- WHERE l.customer_id = %s
|
||
- """, (customer_id,))
|
||
-
|
||
- stats = cur.fetchone()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'stats': {
|
||
- 'total': stats[0],
|
||
- 'active': stats[1],
|
||
- 'expired': stats[2],
|
||
- 'expiring_soon': stats[3]
|
||
- }
|
||
- })
|
||
-
|
||
-@app.route("/api/license/<int:license_id>/quick-edit", methods=['POST'])
|
||
-@login_required
|
||
-def api_license_quick_edit(license_id):
|
||
- """API-Endpoint für schnelle Lizenz-Bearbeitung"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- data = request.get_json()
|
||
-
|
||
- # Hole alte Werte für Audit-Log
|
||
- cur.execute("""
|
||
- SELECT is_active, valid_until, license_type
|
||
- FROM licenses WHERE id = %s
|
||
- """, (license_id,))
|
||
- old_values = cur.fetchone()
|
||
-
|
||
- if not old_values:
|
||
- return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404
|
||
-
|
||
- # Update-Felder vorbereiten
|
||
- updates = []
|
||
- params = []
|
||
- new_values = {}
|
||
-
|
||
- if 'is_active' in data:
|
||
- updates.append("is_active = %s")
|
||
- params.append(data['is_active'])
|
||
- new_values['is_active'] = data['is_active']
|
||
-
|
||
- if 'valid_until' in data:
|
||
- updates.append("valid_until = %s")
|
||
- params.append(data['valid_until'])
|
||
- new_values['valid_until'] = data['valid_until']
|
||
-
|
||
- if 'license_type' in data:
|
||
- updates.append("license_type = %s")
|
||
- params.append(data['license_type'])
|
||
- new_values['license_type'] = data['license_type']
|
||
-
|
||
- if updates:
|
||
- params.append(license_id)
|
||
- cur.execute(f"""
|
||
- UPDATE licenses
|
||
- SET {', '.join(updates)}
|
||
- WHERE id = %s
|
||
- """, params)
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('UPDATE', 'license', license_id,
|
||
- old_values={
|
||
- 'is_active': old_values[0],
|
||
- 'valid_until': old_values[1].isoformat() if old_values[1] else None,
|
||
- 'license_type': old_values[2]
|
||
- },
|
||
- new_values=new_values)
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True})
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- cur.close()
|
||
- conn.close()
|
||
- return jsonify({'success': False, 'error': str(e)}), 500
|
||
-
|
||
-@app.route("/api/license/<int:license_id>/resources")
|
||
-@login_required
|
||
-def api_license_resources(license_id):
|
||
- """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz
|
||
- cur.execute("""
|
||
- SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at
|
||
- FROM resource_pools rp
|
||
- JOIN license_resources lr ON rp.id = lr.resource_id
|
||
- WHERE lr.license_id = %s AND lr.is_active = true
|
||
- ORDER BY rp.resource_type, rp.resource_value
|
||
- """, (license_id,))
|
||
-
|
||
- resources = {
|
||
- 'domains': [],
|
||
- 'ipv4s': [],
|
||
- 'phones': []
|
||
- }
|
||
-
|
||
- for row in cur.fetchall():
|
||
- resource_info = {
|
||
- 'id': row[0],
|
||
- 'value': row[2],
|
||
- 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else ''
|
||
- }
|
||
-
|
||
- if row[1] == 'domain':
|
||
- resources['domains'].append(resource_info)
|
||
- elif row[1] == 'ipv4':
|
||
- resources['ipv4s'].append(resource_info)
|
||
- elif row[1] == 'phone':
|
||
- resources['phones'].append(resource_info)
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'resources': resources
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- cur.close()
|
||
- conn.close()
|
||
- return jsonify({'success': False, 'error': str(e)}), 500
|
||
-
|
||
-@app.route("/sessions")
|
||
-@login_required
|
||
-def sessions():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Sortierparameter
|
||
- active_sort = request.args.get('active_sort', 'last_heartbeat')
|
||
- active_order = request.args.get('active_order', 'desc')
|
||
- ended_sort = request.args.get('ended_sort', 'ended_at')
|
||
- ended_order = request.args.get('ended_order', 'desc')
|
||
-
|
||
- # Whitelist für erlaubte Sortierfelder - Aktive Sessions
|
||
- active_sort_fields = {
|
||
- 'customer': 'c.name',
|
||
- 'license': 'l.license_key',
|
||
- 'ip': 's.ip_address',
|
||
- 'started': 's.started_at',
|
||
- 'last_heartbeat': 's.last_heartbeat',
|
||
- 'inactive': 'minutes_inactive'
|
||
- }
|
||
-
|
||
- # Whitelist für erlaubte Sortierfelder - Beendete Sessions
|
||
- ended_sort_fields = {
|
||
- 'customer': 'c.name',
|
||
- 'license': 'l.license_key',
|
||
- 'ip': 's.ip_address',
|
||
- 'started': 's.started_at',
|
||
- 'ended_at': 's.ended_at',
|
||
- 'duration': 'duration_minutes'
|
||
- }
|
||
-
|
||
- # Validierung
|
||
- if active_sort not in active_sort_fields:
|
||
- active_sort = 'last_heartbeat'
|
||
- if ended_sort not in ended_sort_fields:
|
||
- ended_sort = 'ended_at'
|
||
- if active_order not in ['asc', 'desc']:
|
||
- active_order = 'desc'
|
||
- if ended_order not in ['asc', 'desc']:
|
||
- ended_order = 'desc'
|
||
-
|
||
- # Aktive Sessions abrufen
|
||
- cur.execute(f"""
|
||
- SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address,
|
||
- s.user_agent, s.started_at, s.last_heartbeat,
|
||
- EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive
|
||
- FROM sessions s
|
||
- JOIN licenses l ON s.license_id = l.id
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE s.is_active = TRUE
|
||
- ORDER BY {active_sort_fields[active_sort]} {active_order.upper()}
|
||
- """)
|
||
- active_sessions = cur.fetchall()
|
||
-
|
||
- # Inaktive Sessions der letzten 24 Stunden
|
||
- cur.execute(f"""
|
||
- SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address,
|
||
- s.started_at, s.ended_at,
|
||
- EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes
|
||
- FROM sessions s
|
||
- JOIN licenses l ON s.license_id = l.id
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE s.is_active = FALSE
|
||
- AND s.ended_at > NOW() - INTERVAL '24 hours'
|
||
- ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()}
|
||
- LIMIT 50
|
||
- """)
|
||
- recent_sessions = cur.fetchall()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template("sessions.html",
|
||
- active_sessions=active_sessions,
|
||
- recent_sessions=recent_sessions,
|
||
- active_sort=active_sort,
|
||
- active_order=active_order,
|
||
- ended_sort=ended_sort,
|
||
- ended_order=ended_order,
|
||
- username=session.get('username'))
|
||
-
|
||
-@app.route("/session/end/<int:session_id>", methods=["POST"])
|
||
-@login_required
|
||
-def end_session(session_id):
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Session beenden
|
||
- cur.execute("""
|
||
- UPDATE sessions
|
||
- SET is_active = FALSE, ended_at = NOW()
|
||
- WHERE id = %s AND is_active = TRUE
|
||
- """, (session_id,))
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return redirect("/sessions")
|
||
-
|
||
-@app.route("/export/licenses")
|
||
-@login_required
|
||
-def export_licenses():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht)
|
||
- include_test = request.args.get('include_test', 'false').lower() == 'true'
|
||
- customer_id = request.args.get('customer_id', type=int)
|
||
-
|
||
- query = """
|
||
- SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email,
|
||
- l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test,
|
||
- CASE
|
||
- WHEN l.is_active = FALSE THEN 'Deaktiviert'
|
||
- WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen'
|
||
- WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab'
|
||
- ELSE 'Aktiv'
|
||
- END as status
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- """
|
||
-
|
||
- # Build WHERE clause
|
||
- where_conditions = []
|
||
- params = []
|
||
-
|
||
- if not include_test:
|
||
- where_conditions.append("l.is_test = FALSE")
|
||
-
|
||
- if customer_id:
|
||
- where_conditions.append("l.customer_id = %s")
|
||
- params.append(customer_id)
|
||
-
|
||
- if where_conditions:
|
||
- query += " WHERE " + " AND ".join(where_conditions)
|
||
-
|
||
- query += " ORDER BY l.id"
|
||
-
|
||
- cur.execute(query, params)
|
||
-
|
||
- # Spaltennamen
|
||
- columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ',
|
||
- 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status']
|
||
-
|
||
- # Daten in DataFrame
|
||
- data = cur.fetchall()
|
||
- df = pd.DataFrame(data, columns=columns)
|
||
-
|
||
- # Datumsformatierung
|
||
- df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y')
|
||
- df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y')
|
||
-
|
||
- # Typ und Aktiv Status anpassen
|
||
- df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'})
|
||
- df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'})
|
||
- df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'})
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Export Format
|
||
- export_format = request.args.get('format', 'excel')
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('EXPORT', 'license',
|
||
- additional_info=f"Export aller Lizenzen als {export_format.upper()}")
|
||
- filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}'
|
||
-
|
||
- if export_format == 'csv':
|
||
- # CSV Export
|
||
- output = io.StringIO()
|
||
- df.to_csv(output, index=False, encoding='utf-8-sig', sep=';')
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
- mimetype='text/csv',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv'
|
||
- )
|
||
- else:
|
||
- # Excel Export
|
||
- output = io.BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, sheet_name='Lizenzen', index=False)
|
||
-
|
||
- # Formatierung
|
||
- worksheet = writer.sheets['Lizenzen']
|
||
- for column in worksheet.columns:
|
||
- max_length = 0
|
||
- column_letter = column[0].column_letter
|
||
- for cell in column:
|
||
- try:
|
||
- if len(str(cell.value)) > max_length:
|
||
- max_length = len(str(cell.value))
|
||
- except:
|
||
- pass
|
||
- adjusted_width = min(max_length + 2, 50)
|
||
- worksheet.column_dimensions[column_letter].width = adjusted_width
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx'
|
||
- )
|
||
-
|
||
-@app.route("/export/audit")
|
||
-@login_required
|
||
-def export_audit():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Holen der Filter-Parameter
|
||
- filter_user = request.args.get('user', '')
|
||
- filter_action = request.args.get('action', '')
|
||
- filter_entity = request.args.get('entity', '')
|
||
- export_format = request.args.get('format', 'excel')
|
||
-
|
||
- # SQL Query mit Filtern
|
||
- query = """
|
||
- SELECT id, timestamp, username, action, entity_type, entity_id,
|
||
- old_values, new_values, ip_address, user_agent, additional_info
|
||
- FROM audit_log
|
||
- WHERE 1=1
|
||
- """
|
||
- params = []
|
||
-
|
||
- if filter_user:
|
||
- query += " AND username ILIKE %s"
|
||
- params.append(f'%{filter_user}%')
|
||
-
|
||
- if filter_action:
|
||
- query += " AND action = %s"
|
||
- params.append(filter_action)
|
||
-
|
||
- if filter_entity:
|
||
- query += " AND entity_type = %s"
|
||
- params.append(filter_entity)
|
||
-
|
||
- query += " ORDER BY timestamp DESC"
|
||
-
|
||
- cur.execute(query, params)
|
||
- audit_logs = cur.fetchall()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Daten für Export vorbereiten
|
||
- data = []
|
||
- for log in audit_logs:
|
||
- action_text = {
|
||
- 'CREATE': 'Erstellt',
|
||
- 'UPDATE': 'Bearbeitet',
|
||
- 'DELETE': 'Gelöscht',
|
||
- 'LOGIN': 'Anmeldung',
|
||
- 'LOGOUT': 'Abmeldung',
|
||
- 'AUTO_LOGOUT': 'Auto-Logout',
|
||
- 'EXPORT': 'Export',
|
||
- 'GENERATE_KEY': 'Key generiert',
|
||
- 'CREATE_BATCH': 'Batch erstellt',
|
||
- 'BACKUP': 'Backup erstellt',
|
||
- 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung',
|
||
- 'LOGIN_2FA_BACKUP': '2FA-Backup-Code',
|
||
- 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen',
|
||
- 'LOGIN_BLOCKED': 'Login-Blockiert',
|
||
- 'RESTORE': 'Wiederhergestellt',
|
||
- 'PASSWORD_CHANGE': 'Passwort geändert',
|
||
- '2FA_ENABLED': '2FA aktiviert',
|
||
- '2FA_DISABLED': '2FA deaktiviert'
|
||
- }.get(log[3], log[3])
|
||
-
|
||
- data.append({
|
||
- 'ID': log[0],
|
||
- 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'),
|
||
- 'Benutzer': log[2],
|
||
- 'Aktion': action_text,
|
||
- 'Entität': log[4],
|
||
- 'Entität-ID': log[5] or '',
|
||
- 'IP-Adresse': log[8] or '',
|
||
- 'Zusatzinfo': log[10] or ''
|
||
- })
|
||
-
|
||
- # DataFrame erstellen
|
||
- df = pd.DataFrame(data)
|
||
-
|
||
- # Timestamp für Dateiname
|
||
- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
- filename = f'audit_log_export_{timestamp}'
|
||
-
|
||
- # Audit Log für Export
|
||
- log_audit('EXPORT', 'audit_log',
|
||
- additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen")
|
||
-
|
||
- if export_format == 'csv':
|
||
- # CSV Export
|
||
- output = io.StringIO()
|
||
- # UTF-8 BOM für Excel
|
||
- output.write('\ufeff')
|
||
- df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8')),
|
||
- mimetype='text/csv;charset=utf-8',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv'
|
||
- )
|
||
- else:
|
||
- # Excel Export
|
||
- output = BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, index=False, sheet_name='Audit Log')
|
||
-
|
||
- # Spaltenbreiten anpassen
|
||
- worksheet = writer.sheets['Audit Log']
|
||
- for idx, col in enumerate(df.columns):
|
||
- max_length = max(
|
||
- df[col].astype(str).map(len).max(),
|
||
- len(col)
|
||
- ) + 2
|
||
- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx'
|
||
- )
|
||
-
|
||
-@app.route("/export/customers")
|
||
-@login_required
|
||
-def export_customers():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Check if test data should be included
|
||
- include_test = request.args.get('include_test', 'false').lower() == 'true'
|
||
-
|
||
- # Build query based on test data filter
|
||
- if include_test:
|
||
- # Include all customers
|
||
- query = """
|
||
- SELECT c.id, c.name, c.email, c.created_at, c.is_test,
|
||
- COUNT(l.id) as total_licenses,
|
||
- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
- COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- GROUP BY c.id, c.name, c.email, c.created_at, c.is_test
|
||
- ORDER BY c.id
|
||
- """
|
||
- else:
|
||
- # Exclude test customers and test licenses
|
||
- query = """
|
||
- SELECT c.id, c.name, c.email, c.created_at, c.is_test,
|
||
- COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses,
|
||
- COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses,
|
||
- COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses
|
||
- FROM customers c
|
||
- LEFT JOIN licenses l ON c.id = l.customer_id
|
||
- WHERE c.is_test = FALSE
|
||
- GROUP BY c.id, c.name, c.email, c.created_at, c.is_test
|
||
- ORDER BY c.id
|
||
- """
|
||
-
|
||
- cur.execute(query)
|
||
-
|
||
- # Spaltennamen
|
||
- columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten',
|
||
- 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen']
|
||
-
|
||
- # Daten in DataFrame
|
||
- data = cur.fetchall()
|
||
- df = pd.DataFrame(data, columns=columns)
|
||
-
|
||
- # Datumsformatierung
|
||
- df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M')
|
||
-
|
||
- # Testdaten formatting
|
||
- df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'})
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Export Format
|
||
- export_format = request.args.get('format', 'excel')
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('EXPORT', 'customer',
|
||
- additional_info=f"Export aller Kunden als {export_format.upper()}")
|
||
- filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}'
|
||
-
|
||
- if export_format == 'csv':
|
||
- # CSV Export
|
||
- output = io.StringIO()
|
||
- df.to_csv(output, index=False, encoding='utf-8-sig', sep=';')
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
- mimetype='text/csv',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv'
|
||
- )
|
||
- else:
|
||
- # Excel Export
|
||
- output = io.BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, sheet_name='Kunden', index=False)
|
||
-
|
||
- # Formatierung
|
||
- worksheet = writer.sheets['Kunden']
|
||
- for column in worksheet.columns:
|
||
- max_length = 0
|
||
- column_letter = column[0].column_letter
|
||
- for cell in column:
|
||
- try:
|
||
- if len(str(cell.value)) > max_length:
|
||
- max_length = len(str(cell.value))
|
||
- except:
|
||
- pass
|
||
- adjusted_width = min(max_length + 2, 50)
|
||
- worksheet.column_dimensions[column_letter].width = adjusted_width
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx'
|
||
- )
|
||
-
|
||
-@app.route("/export/sessions")
|
||
-@login_required
|
||
-def export_sessions():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Holen des Session-Typs (active oder ended)
|
||
- session_type = request.args.get('type', 'active')
|
||
- export_format = request.args.get('format', 'excel')
|
||
-
|
||
- # Daten je nach Typ abrufen
|
||
- if session_type == 'active':
|
||
- # Aktive Lizenz-Sessions
|
||
- cur.execute("""
|
||
- SELECT s.id, l.license_key, c.name as customer_name, s.session_id,
|
||
- s.started_at, s.last_heartbeat,
|
||
- EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds,
|
||
- s.ip_address, s.user_agent
|
||
- FROM sessions s
|
||
- JOIN licenses l ON s.license_id = l.id
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE s.is_active = true
|
||
- ORDER BY s.last_heartbeat DESC
|
||
- """)
|
||
- sessions = cur.fetchall()
|
||
-
|
||
- # Daten für Export vorbereiten
|
||
- data = []
|
||
- for sess in sessions:
|
||
- duration = sess[6]
|
||
- hours = duration // 3600
|
||
- minutes = (duration % 3600) // 60
|
||
- seconds = duration % 60
|
||
-
|
||
- data.append({
|
||
- 'Session-ID': sess[0],
|
||
- 'Lizenzschlüssel': sess[1],
|
||
- 'Kunde': sess[2],
|
||
- 'Session-ID (Tech)': sess[3],
|
||
- 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'),
|
||
- 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'),
|
||
- 'Dauer': f"{hours}h {minutes}m {seconds}s",
|
||
- 'IP-Adresse': sess[7],
|
||
- 'Browser': sess[8]
|
||
- })
|
||
-
|
||
- sheet_name = 'Aktive Sessions'
|
||
- filename_prefix = 'aktive_sessions'
|
||
- else:
|
||
- # Beendete Lizenz-Sessions
|
||
- cur.execute("""
|
||
- SELECT s.id, l.license_key, c.name as customer_name, s.session_id,
|
||
- s.started_at, s.ended_at,
|
||
- EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds,
|
||
- s.ip_address, s.user_agent
|
||
- FROM sessions s
|
||
- JOIN licenses l ON s.license_id = l.id
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE s.is_active = false AND s.ended_at IS NOT NULL
|
||
- ORDER BY s.ended_at DESC
|
||
- LIMIT 1000
|
||
- """)
|
||
- sessions = cur.fetchall()
|
||
-
|
||
- # Daten für Export vorbereiten
|
||
- data = []
|
||
- for sess in sessions:
|
||
- duration = sess[6] if sess[6] else 0
|
||
- hours = duration // 3600
|
||
- minutes = (duration % 3600) // 60
|
||
- seconds = duration % 60
|
||
-
|
||
- data.append({
|
||
- 'Session-ID': sess[0],
|
||
- 'Lizenzschlüssel': sess[1],
|
||
- 'Kunde': sess[2],
|
||
- 'Session-ID (Tech)': sess[3],
|
||
- 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'),
|
||
- 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '',
|
||
- 'Dauer': f"{hours}h {minutes}m {seconds}s",
|
||
- 'IP-Adresse': sess[7],
|
||
- 'Browser': sess[8]
|
||
- })
|
||
-
|
||
- sheet_name = 'Beendete Sessions'
|
||
- filename_prefix = 'beendete_sessions'
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # DataFrame erstellen
|
||
- df = pd.DataFrame(data)
|
||
-
|
||
- # Timestamp für Dateiname
|
||
- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
- filename = f'{filename_prefix}_export_{timestamp}'
|
||
-
|
||
- # Audit Log für Export
|
||
- log_audit('EXPORT', 'sessions',
|
||
- additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen")
|
||
-
|
||
- if export_format == 'csv':
|
||
- # CSV Export
|
||
- output = io.StringIO()
|
||
- # UTF-8 BOM für Excel
|
||
- output.write('\ufeff')
|
||
- df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8')),
|
||
- mimetype='text/csv;charset=utf-8',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv'
|
||
- )
|
||
- else:
|
||
- # Excel Export
|
||
- output = BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, index=False, sheet_name=sheet_name)
|
||
-
|
||
- # Spaltenbreiten anpassen
|
||
- worksheet = writer.sheets[sheet_name]
|
||
- for idx, col in enumerate(df.columns):
|
||
- max_length = max(
|
||
- df[col].astype(str).map(len).max(),
|
||
- len(col)
|
||
- ) + 2
|
||
- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx'
|
||
- )
|
||
-
|
||
-@app.route("/export/resources")
|
||
-@login_required
|
||
-def export_resources():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Holen der Filter-Parameter
|
||
- filter_type = request.args.get('type', '')
|
||
- filter_status = request.args.get('status', '')
|
||
- search_query = request.args.get('search', '')
|
||
- show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
- export_format = request.args.get('format', 'excel')
|
||
-
|
||
- # SQL Query mit Filtern
|
||
- query = """
|
||
- SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license,
|
||
- r.created_at, r.status_changed_at,
|
||
- l.license_key, c.name as customer_name, c.email as customer_email,
|
||
- l.license_type
|
||
- FROM resource_pools r
|
||
- LEFT JOIN licenses l ON r.allocated_to_license = l.id
|
||
- LEFT JOIN customers c ON l.customer_id = c.id
|
||
- WHERE 1=1
|
||
- """
|
||
- params = []
|
||
-
|
||
- # Filter für Testdaten
|
||
- if not show_test:
|
||
- query += " AND (r.is_test = false OR r.is_test IS NULL)"
|
||
-
|
||
- # Filter für Ressourcentyp
|
||
- if filter_type:
|
||
- query += " AND r.resource_type = %s"
|
||
- params.append(filter_type)
|
||
-
|
||
- # Filter für Status
|
||
- if filter_status:
|
||
- query += " AND r.status = %s"
|
||
- params.append(filter_status)
|
||
-
|
||
- # Suchfilter
|
||
- if search_query:
|
||
- query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)"
|
||
- params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%'])
|
||
-
|
||
- query += " ORDER BY r.id DESC"
|
||
-
|
||
- cur.execute(query, params)
|
||
- resources = cur.fetchall()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Daten für Export vorbereiten
|
||
- data = []
|
||
- for res in resources:
|
||
- status_text = {
|
||
- 'available': 'Verfügbar',
|
||
- 'allocated': 'Zugewiesen',
|
||
- 'quarantine': 'Quarantäne'
|
||
- }.get(res[3], res[3])
|
||
-
|
||
- type_text = {
|
||
- 'domain': 'Domain',
|
||
- 'ipv4': 'IPv4',
|
||
- 'phone': 'Telefon'
|
||
- }.get(res[1], res[1])
|
||
-
|
||
- data.append({
|
||
- 'ID': res[0],
|
||
- 'Typ': type_text,
|
||
- 'Ressource': res[2],
|
||
- 'Status': status_text,
|
||
- 'Lizenzschlüssel': res[7] or '',
|
||
- 'Kunde': res[8] or '',
|
||
- 'Kunden-Email': res[9] or '',
|
||
- 'Lizenztyp': res[10] or '',
|
||
- 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '',
|
||
- 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else ''
|
||
- })
|
||
-
|
||
- # DataFrame erstellen
|
||
- df = pd.DataFrame(data)
|
||
-
|
||
- # Timestamp für Dateiname
|
||
- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
- filename = f'resources_export_{timestamp}'
|
||
-
|
||
- # Audit Log für Export
|
||
- log_audit('EXPORT', 'resources',
|
||
- additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen")
|
||
-
|
||
- if export_format == 'csv':
|
||
- # CSV Export
|
||
- output = io.StringIO()
|
||
- # UTF-8 BOM für Excel
|
||
- output.write('\ufeff')
|
||
- df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- io.BytesIO(output.getvalue().encode('utf-8')),
|
||
- mimetype='text/csv;charset=utf-8',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv'
|
||
- )
|
||
- else:
|
||
- # Excel Export
|
||
- output = BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, index=False, sheet_name='Resources')
|
||
-
|
||
- # Spaltenbreiten anpassen
|
||
- worksheet = writer.sheets['Resources']
|
||
- for idx, col in enumerate(df.columns):
|
||
- max_length = max(
|
||
- df[col].astype(str).map(len).max(),
|
||
- len(col)
|
||
- ) + 2
|
||
- worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- return send_file(
|
||
- output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx'
|
||
- )
|
||
-
|
||
-@app.route("/audit")
|
||
-@login_required
|
||
-def audit_log():
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Parameter
|
||
- filter_user = request.args.get('user', '').strip()
|
||
- filter_action = request.args.get('action', '').strip()
|
||
- filter_entity = request.args.get('entity', '').strip()
|
||
- page = request.args.get('page', 1, type=int)
|
||
- sort = request.args.get('sort', 'timestamp')
|
||
- order = request.args.get('order', 'desc')
|
||
- per_page = 50
|
||
-
|
||
- # Whitelist für erlaubte Sortierfelder
|
||
- allowed_sort_fields = {
|
||
- 'timestamp': 'timestamp',
|
||
- 'username': 'username',
|
||
- 'action': 'action',
|
||
- 'entity': 'entity_type',
|
||
- 'ip': 'ip_address'
|
||
- }
|
||
-
|
||
- # Validierung
|
||
- if sort not in allowed_sort_fields:
|
||
- sort = 'timestamp'
|
||
- if order not in ['asc', 'desc']:
|
||
- order = 'desc'
|
||
-
|
||
- sort_field = allowed_sort_fields[sort]
|
||
-
|
||
- # SQL Query mit optionalen Filtern
|
||
- query = """
|
||
- SELECT id, timestamp, username, action, entity_type, entity_id,
|
||
- old_values, new_values, ip_address, user_agent, additional_info
|
||
- FROM audit_log
|
||
- WHERE 1=1
|
||
- """
|
||
-
|
||
- params = []
|
||
-
|
||
- # Filter
|
||
- if filter_user:
|
||
- query += " AND LOWER(username) LIKE LOWER(%s)"
|
||
- params.append(f'%{filter_user}%')
|
||
-
|
||
- if filter_action:
|
||
- query += " AND action = %s"
|
||
- params.append(filter_action)
|
||
-
|
||
- if filter_entity:
|
||
- query += " AND entity_type = %s"
|
||
- params.append(filter_entity)
|
||
-
|
||
- # Gesamtanzahl für Pagination
|
||
- count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table"
|
||
- cur.execute(count_query, params)
|
||
- total = cur.fetchone()[0]
|
||
-
|
||
- # Pagination
|
||
- offset = (page - 1) * per_page
|
||
- query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s"
|
||
- params.extend([per_page, offset])
|
||
-
|
||
- cur.execute(query, params)
|
||
- logs = cur.fetchall()
|
||
-
|
||
- # JSON-Werte parsen
|
||
- parsed_logs = []
|
||
- for log in logs:
|
||
- parsed_log = list(log)
|
||
- # old_values und new_values sind bereits Dictionaries (JSONB)
|
||
- # Keine Konvertierung nötig
|
||
- parsed_logs.append(parsed_log)
|
||
-
|
||
- # Pagination Info
|
||
- total_pages = (total + per_page - 1) // per_page
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template("audit_log.html",
|
||
- logs=parsed_logs,
|
||
- filter_user=filter_user,
|
||
- filter_action=filter_action,
|
||
- filter_entity=filter_entity,
|
||
- page=page,
|
||
- total_pages=total_pages,
|
||
- total=total,
|
||
- sort=sort,
|
||
- order=order,
|
||
- username=session.get('username'))
|
||
-
|
||
-@app.route("/backups")
|
||
-@login_required
|
||
-def backups():
|
||
- """Zeigt die Backup-Historie an"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Letztes erfolgreiches Backup für Dashboard
|
||
- cur.execute("""
|
||
- SELECT created_at, filesize, duration_seconds
|
||
- FROM backup_history
|
||
- WHERE status = 'success'
|
||
- ORDER BY created_at DESC
|
||
- LIMIT 1
|
||
- """)
|
||
- last_backup = cur.fetchone()
|
||
-
|
||
- # Alle Backups abrufen
|
||
- cur.execute("""
|
||
- SELECT id, filename, filesize, backup_type, status, error_message,
|
||
- created_at, created_by, tables_count, records_count,
|
||
- duration_seconds, is_encrypted
|
||
- FROM backup_history
|
||
- ORDER BY created_at DESC
|
||
- """)
|
||
- backups = cur.fetchall()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template("backups.html",
|
||
- backups=backups,
|
||
- last_backup=last_backup,
|
||
- username=session.get('username'))
|
||
-
|
||
-@app.route("/backup/create", methods=["POST"])
|
||
-@login_required
|
||
-def create_backup_route():
|
||
- """Erstellt ein manuelles Backup"""
|
||
- username = session.get('username')
|
||
- success, result = create_backup(backup_type="manual", created_by=username)
|
||
-
|
||
- if success:
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'message': f'Backup erfolgreich erstellt: {result}'
|
||
- })
|
||
- else:
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'message': f'Backup fehlgeschlagen: {result}'
|
||
- }), 500
|
||
-
|
||
-@app.route("/backup/restore/<int:backup_id>", methods=["POST"])
|
||
-@login_required
|
||
-def restore_backup_route(backup_id):
|
||
- """Stellt ein Backup wieder her"""
|
||
- encryption_key = request.form.get('encryption_key')
|
||
-
|
||
- success, message = restore_backup(backup_id, encryption_key)
|
||
-
|
||
- if success:
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'message': message
|
||
- })
|
||
- else:
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'message': message
|
||
- }), 500
|
||
-
|
||
-@app.route("/backup/download/<int:backup_id>")
|
||
-@login_required
|
||
-def download_backup(backup_id):
|
||
- """Lädt eine Backup-Datei herunter"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- cur.execute("""
|
||
- SELECT filename, filepath
|
||
- FROM backup_history
|
||
- WHERE id = %s
|
||
- """, (backup_id,))
|
||
- backup_info = cur.fetchone()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- if not backup_info:
|
||
- return "Backup nicht gefunden", 404
|
||
-
|
||
- filename, filepath = backup_info
|
||
- filepath = Path(filepath)
|
||
-
|
||
- if not filepath.exists():
|
||
- return "Backup-Datei nicht gefunden", 404
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('DOWNLOAD', 'backup', backup_id,
|
||
- additional_info=f"Backup heruntergeladen: {filename}")
|
||
-
|
||
- return send_file(filepath, as_attachment=True, download_name=filename)
|
||
-
|
||
-@app.route("/backup/delete/<int:backup_id>", methods=["DELETE"])
|
||
-@login_required
|
||
-def delete_backup(backup_id):
|
||
- """Löscht ein Backup"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- # Backup-Informationen abrufen
|
||
- cur.execute("""
|
||
- SELECT filename, filepath
|
||
- FROM backup_history
|
||
- WHERE id = %s
|
||
- """, (backup_id,))
|
||
- backup_info = cur.fetchone()
|
||
-
|
||
- if not backup_info:
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'message': 'Backup nicht gefunden'
|
||
- }), 404
|
||
-
|
||
- filename, filepath = backup_info
|
||
- filepath = Path(filepath)
|
||
-
|
||
- # Datei löschen, wenn sie existiert
|
||
- if filepath.exists():
|
||
- filepath.unlink()
|
||
-
|
||
- # Aus Datenbank löschen
|
||
- cur.execute("""
|
||
- DELETE FROM backup_history
|
||
- WHERE id = %s
|
||
- """, (backup_id,))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('DELETE', 'backup', backup_id,
|
||
- additional_info=f"Backup gelöscht: {filename}")
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'message': f'Backup "{filename}" wurde erfolgreich gelöscht'
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'message': f'Fehler beim Löschen des Backups: {str(e)}'
|
||
- }), 500
|
||
- finally:
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
-@app.route("/security/blocked-ips")
|
||
-@login_required
|
||
-def blocked_ips():
|
||
- """Zeigt alle gesperrten IPs an"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- cur.execute("""
|
||
- SELECT
|
||
- ip_address,
|
||
- attempt_count,
|
||
- first_attempt,
|
||
- last_attempt,
|
||
- blocked_until,
|
||
- last_username_tried,
|
||
- last_error_message
|
||
- FROM login_attempts
|
||
- WHERE blocked_until IS NOT NULL
|
||
- ORDER BY blocked_until DESC
|
||
- """)
|
||
-
|
||
- blocked_ips_list = []
|
||
- for ip in cur.fetchall():
|
||
- blocked_ips_list.append({
|
||
- 'ip_address': ip[0],
|
||
- 'attempt_count': ip[1],
|
||
- 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'),
|
||
- 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'),
|
||
- 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'),
|
||
- 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None),
|
||
- 'last_username': ip[5],
|
||
- 'last_error': ip[6]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template("blocked_ips.html",
|
||
- blocked_ips=blocked_ips_list,
|
||
- username=session.get('username'))
|
||
-
|
||
-@app.route("/security/unblock-ip", methods=["POST"])
|
||
-@login_required
|
||
-def unblock_ip():
|
||
- """Entsperrt eine IP-Adresse"""
|
||
- ip_address = request.form.get('ip_address')
|
||
-
|
||
- if ip_address:
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- cur.execute("""
|
||
- UPDATE login_attempts
|
||
- SET blocked_until = NULL
|
||
- WHERE ip_address = %s
|
||
- """, (ip_address,))
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('UNBLOCK_IP', 'security',
|
||
- additional_info=f"IP {ip_address} manuell entsperrt")
|
||
-
|
||
- return redirect(url_for('blocked_ips'))
|
||
-
|
||
-@app.route("/security/clear-attempts", methods=["POST"])
|
||
-@login_required
|
||
-def clear_attempts():
|
||
- """Löscht alle Login-Versuche für eine IP"""
|
||
- ip_address = request.form.get('ip_address')
|
||
-
|
||
- if ip_address:
|
||
- reset_login_attempts(ip_address)
|
||
-
|
||
- # Audit-Log
|
||
- log_audit('CLEAR_ATTEMPTS', 'security',
|
||
- additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt")
|
||
-
|
||
- return redirect(url_for('blocked_ips'))
|
||
-
|
||
-# API Endpoints for License Management
|
||
-@app.route("/api/license/<int:license_id>/toggle", methods=["POST"])
|
||
-@login_required
|
||
-def toggle_license_api(license_id):
|
||
- """Toggle license active status via API"""
|
||
- try:
|
||
- data = request.get_json()
|
||
- is_active = data.get('is_active', False)
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Update license status
|
||
- cur.execute("""
|
||
- UPDATE licenses
|
||
- SET is_active = %s
|
||
- WHERE id = %s
|
||
- """, (is_active, license_id))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Log the action
|
||
- log_audit('UPDATE', 'license', license_id,
|
||
- new_values={'is_active': is_active},
|
||
- additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle")
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': 'Status erfolgreich geändert'})
|
||
- except Exception as e:
|
||
- return jsonify({'success': False, 'message': str(e)}), 500
|
||
-
|
||
-@app.route("/api/licenses/bulk-activate", methods=["POST"])
|
||
-@login_required
|
||
-def bulk_activate_licenses():
|
||
- """Activate multiple licenses at once"""
|
||
- try:
|
||
- data = request.get_json()
|
||
- license_ids = data.get('ids', [])
|
||
-
|
||
- if not license_ids:
|
||
- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Update all selected licenses (nur Live-Daten)
|
||
- cur.execute("""
|
||
- UPDATE licenses
|
||
- SET is_active = TRUE
|
||
- WHERE id = ANY(%s) AND is_test = FALSE
|
||
- """, (license_ids,))
|
||
-
|
||
- affected_rows = cur.rowcount
|
||
- conn.commit()
|
||
-
|
||
- # Log the bulk action
|
||
- log_audit('BULK_UPDATE', 'licenses', None,
|
||
- new_values={'is_active': True, 'count': affected_rows},
|
||
- additional_info=f"{affected_rows} Lizenzen aktiviert")
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'})
|
||
- except Exception as e:
|
||
- return jsonify({'success': False, 'message': str(e)}), 500
|
||
-
|
||
-@app.route("/api/licenses/bulk-deactivate", methods=["POST"])
|
||
-@login_required
|
||
-def bulk_deactivate_licenses():
|
||
- """Deactivate multiple licenses at once"""
|
||
- try:
|
||
- data = request.get_json()
|
||
- license_ids = data.get('ids', [])
|
||
-
|
||
- if not license_ids:
|
||
- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Update all selected licenses (nur Live-Daten)
|
||
- cur.execute("""
|
||
- UPDATE licenses
|
||
- SET is_active = FALSE
|
||
- WHERE id = ANY(%s) AND is_test = FALSE
|
||
- """, (license_ids,))
|
||
-
|
||
- affected_rows = cur.rowcount
|
||
- conn.commit()
|
||
-
|
||
- # Log the bulk action
|
||
- log_audit('BULK_UPDATE', 'licenses', None,
|
||
- new_values={'is_active': False, 'count': affected_rows},
|
||
- additional_info=f"{affected_rows} Lizenzen deaktiviert")
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'})
|
||
- except Exception as e:
|
||
- return jsonify({'success': False, 'message': str(e)}), 500
|
||
-
|
||
-@app.route("/api/license/<int:license_id>/devices")
|
||
-@login_required
|
||
-def get_license_devices(license_id):
|
||
- """Hole alle registrierten Geräte einer Lizenz"""
|
||
- try:
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Prüfe ob Lizenz existiert und hole device_limit
|
||
- cur.execute("""
|
||
- SELECT device_limit FROM licenses WHERE id = %s
|
||
- """, (license_id,))
|
||
- license_data = cur.fetchone()
|
||
-
|
||
- if not license_data:
|
||
- return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404
|
||
-
|
||
- device_limit = license_data[0]
|
||
-
|
||
- # Hole alle Geräte für diese Lizenz
|
||
- cur.execute("""
|
||
- SELECT id, hardware_id, device_name, operating_system,
|
||
- first_seen, last_seen, is_active, ip_address
|
||
- FROM device_registrations
|
||
- WHERE license_id = %s
|
||
- ORDER BY is_active DESC, last_seen DESC
|
||
- """, (license_id,))
|
||
-
|
||
- devices = []
|
||
- for row in cur.fetchall():
|
||
- devices.append({
|
||
- 'id': row[0],
|
||
- 'hardware_id': row[1],
|
||
- 'device_name': row[2] or 'Unbekanntes Gerät',
|
||
- 'operating_system': row[3] or 'Unbekannt',
|
||
- 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '',
|
||
- 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '',
|
||
- 'is_active': row[6],
|
||
- 'ip_address': row[7] or '-'
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'devices': devices,
|
||
- 'device_limit': device_limit,
|
||
- 'active_count': sum(1 for d in devices if d['is_active'])
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}")
|
||
- return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500
|
||
-
|
||
-@app.route("/api/license/<int:license_id>/register-device", methods=["POST"])
|
||
-def register_device(license_id):
|
||
- """Registriere ein neues Gerät für eine Lizenz"""
|
||
- try:
|
||
- data = request.get_json()
|
||
- hardware_id = data.get('hardware_id')
|
||
- device_name = data.get('device_name', '')
|
||
- operating_system = data.get('operating_system', '')
|
||
-
|
||
- if not hardware_id:
|
||
- return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Prüfe ob Lizenz existiert und aktiv ist
|
||
- cur.execute("""
|
||
- SELECT device_limit, is_active, valid_until
|
||
- FROM licenses
|
||
- WHERE id = %s
|
||
- """, (license_id,))
|
||
- license_data = cur.fetchone()
|
||
-
|
||
- if not license_data:
|
||
- return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404
|
||
-
|
||
- device_limit, is_active, valid_until = license_data
|
||
-
|
||
- # Prüfe ob Lizenz aktiv und gültig ist
|
||
- if not is_active:
|
||
- return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403
|
||
-
|
||
- if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date():
|
||
- return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403
|
||
-
|
||
- # Prüfe ob Gerät bereits registriert ist
|
||
- cur.execute("""
|
||
- SELECT id, is_active FROM device_registrations
|
||
- WHERE license_id = %s AND hardware_id = %s
|
||
- """, (license_id, hardware_id))
|
||
- existing_device = cur.fetchone()
|
||
-
|
||
- if existing_device:
|
||
- device_id, is_device_active = existing_device
|
||
- if is_device_active:
|
||
- # Gerät ist bereits aktiv, update last_seen
|
||
- cur.execute("""
|
||
- UPDATE device_registrations
|
||
- SET last_seen = CURRENT_TIMESTAMP,
|
||
- ip_address = %s,
|
||
- user_agent = %s
|
||
- WHERE id = %s
|
||
- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
- conn.commit()
|
||
- return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id})
|
||
- else:
|
||
- # Gerät war deaktiviert, prüfe ob wir es reaktivieren können
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM device_registrations
|
||
- WHERE license_id = %s AND is_active = TRUE
|
||
- """, (license_id,))
|
||
- active_count = cur.fetchone()[0]
|
||
-
|
||
- if active_count >= device_limit:
|
||
- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
-
|
||
- # Reaktiviere das Gerät
|
||
- cur.execute("""
|
||
- UPDATE device_registrations
|
||
- SET is_active = TRUE,
|
||
- last_seen = CURRENT_TIMESTAMP,
|
||
- deactivated_at = NULL,
|
||
- deactivated_by = NULL,
|
||
- ip_address = %s,
|
||
- user_agent = %s
|
||
- WHERE id = %s
|
||
- """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
- conn.commit()
|
||
- return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id})
|
||
-
|
||
- # Neues Gerät - prüfe Gerätelimit
|
||
- cur.execute("""
|
||
- SELECT COUNT(*) FROM device_registrations
|
||
- WHERE license_id = %s AND is_active = TRUE
|
||
- """, (license_id,))
|
||
- active_count = cur.fetchone()[0]
|
||
-
|
||
- if active_count >= device_limit:
|
||
- return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
-
|
||
- # Registriere neues Gerät
|
||
- cur.execute("""
|
||
- INSERT INTO device_registrations
|
||
- (license_id, hardware_id, device_name, operating_system, ip_address, user_agent)
|
||
- VALUES (%s, %s, %s, %s, %s, %s)
|
||
- RETURNING id
|
||
- """, (license_id, hardware_id, device_name, operating_system,
|
||
- get_client_ip(), request.headers.get('User-Agent', '')))
|
||
- device_id = cur.fetchone()[0]
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit Log
|
||
- log_audit('DEVICE_REGISTER', 'device', device_id,
|
||
- new_values={'license_id': license_id, 'hardware_id': hardware_id})
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id})
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}")
|
||
- return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500
|
||
-
|
||
-@app.route("/api/license/<int:license_id>/deactivate-device/<int:device_id>", methods=["POST"])
|
||
-@login_required
|
||
-def deactivate_device(license_id, device_id):
|
||
- """Deaktiviere ein registriertes Gerät"""
|
||
- try:
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Prüfe ob das Gerät zu dieser Lizenz gehört
|
||
- cur.execute("""
|
||
- SELECT id FROM device_registrations
|
||
- WHERE id = %s AND license_id = %s AND is_active = TRUE
|
||
- """, (device_id, license_id))
|
||
-
|
||
- if not cur.fetchone():
|
||
- return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404
|
||
-
|
||
- # Deaktiviere das Gerät
|
||
- cur.execute("""
|
||
- UPDATE device_registrations
|
||
- SET is_active = FALSE,
|
||
- deactivated_at = CURRENT_TIMESTAMP,
|
||
- deactivated_by = %s
|
||
- WHERE id = %s
|
||
- """, (session['username'], device_id))
|
||
-
|
||
- conn.commit()
|
||
-
|
||
- # Audit Log
|
||
- log_audit('DEVICE_DEACTIVATE', 'device', device_id,
|
||
- old_values={'is_active': True},
|
||
- new_values={'is_active': False})
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'})
|
||
-
|
||
- except Exception as e:
|
||
- logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}")
|
||
- return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500
|
||
-
|
||
-@app.route("/api/licenses/bulk-delete", methods=["POST"])
|
||
-@login_required
|
||
-def bulk_delete_licenses():
|
||
- """Delete multiple licenses at once"""
|
||
- try:
|
||
- data = request.get_json()
|
||
- license_ids = data.get('ids', [])
|
||
-
|
||
- if not license_ids:
|
||
- return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Get license info for audit log (nur Live-Daten)
|
||
- cur.execute("""
|
||
- SELECT license_key
|
||
- FROM licenses
|
||
- WHERE id = ANY(%s) AND is_test = FALSE
|
||
- """, (license_ids,))
|
||
- license_keys = [row[0] for row in cur.fetchall()]
|
||
-
|
||
- # Delete all selected licenses (nur Live-Daten)
|
||
- cur.execute("""
|
||
- DELETE FROM licenses
|
||
- WHERE id = ANY(%s) AND is_test = FALSE
|
||
- """, (license_ids,))
|
||
-
|
||
- affected_rows = cur.rowcount
|
||
- conn.commit()
|
||
-
|
||
- # Log the bulk action
|
||
- log_audit('BULK_DELETE', 'licenses', None,
|
||
- old_values={'license_keys': license_keys, 'count': affected_rows},
|
||
- additional_info=f"{affected_rows} Lizenzen gelöscht")
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'})
|
||
- except Exception as e:
|
||
- return jsonify({'success': False, 'message': str(e)}), 500
|
||
-
|
||
-# ===================== RESOURCE POOL MANAGEMENT =====================
|
||
-
|
||
-@app.route('/resources')
|
||
-@login_required
|
||
-def resources():
|
||
- """Resource Pool Hauptübersicht"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden)
|
||
- show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
-
|
||
- # Statistiken abrufen
|
||
- cur.execute("""
|
||
- SELECT
|
||
- resource_type,
|
||
- COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
- COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
- COUNT(*) as total
|
||
- FROM resource_pools
|
||
- WHERE is_test = %s
|
||
- GROUP BY resource_type
|
||
- """, (show_test,))
|
||
-
|
||
- stats = {}
|
||
- for row in cur.fetchall():
|
||
- stats[row[0]] = {
|
||
- 'available': row[1],
|
||
- 'allocated': row[2],
|
||
- 'quarantine': row[3],
|
||
- 'total': row[4],
|
||
- 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1)
|
||
- }
|
||
-
|
||
- # Letzte Aktivitäten (gefiltert nach Test/Live)
|
||
- cur.execute("""
|
||
- SELECT
|
||
- rh.action,
|
||
- rh.action_by,
|
||
- rh.action_at,
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- rh.details
|
||
- FROM resource_history rh
|
||
- JOIN resource_pools rp ON rh.resource_id = rp.id
|
||
- WHERE rp.is_test = %s
|
||
- ORDER BY rh.action_at DESC
|
||
- LIMIT 10
|
||
- """, (show_test,))
|
||
- recent_activities = cur.fetchall()
|
||
-
|
||
- # Ressourcen-Liste mit Pagination
|
||
- page = request.args.get('page', 1, type=int)
|
||
- per_page = 50
|
||
- offset = (page - 1) * per_page
|
||
-
|
||
- resource_type = request.args.get('type', '')
|
||
- status_filter = request.args.get('status', '')
|
||
- search = request.args.get('search', '')
|
||
-
|
||
- # Sortierung
|
||
- sort_by = request.args.get('sort', 'id')
|
||
- sort_order = request.args.get('order', 'desc')
|
||
-
|
||
- # Base Query
|
||
- query = """
|
||
- SELECT
|
||
- rp.id,
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- rp.status,
|
||
- rp.allocated_to_license,
|
||
- l.license_key,
|
||
- c.name as customer_name,
|
||
- rp.status_changed_at,
|
||
- rp.quarantine_reason,
|
||
- rp.quarantine_until,
|
||
- c.id as customer_id
|
||
- FROM resource_pools rp
|
||
- LEFT JOIN licenses l ON rp.allocated_to_license = l.id
|
||
- LEFT JOIN customers c ON l.customer_id = c.id
|
||
- WHERE rp.is_test = %s
|
||
- """
|
||
- params = [show_test]
|
||
-
|
||
- if resource_type:
|
||
- query += " AND rp.resource_type = %s"
|
||
- params.append(resource_type)
|
||
-
|
||
- if status_filter:
|
||
- query += " AND rp.status = %s"
|
||
- params.append(status_filter)
|
||
-
|
||
- if search:
|
||
- query += " AND rp.resource_value ILIKE %s"
|
||
- params.append(f'%{search}%')
|
||
-
|
||
- # Count total
|
||
- count_query = f"SELECT COUNT(*) FROM ({query}) as cnt"
|
||
- cur.execute(count_query, params)
|
||
- total = cur.fetchone()[0]
|
||
- total_pages = (total + per_page - 1) // per_page
|
||
-
|
||
- # Get paginated results with dynamic sorting
|
||
- sort_column_map = {
|
||
- 'id': 'rp.id',
|
||
- 'type': 'rp.resource_type',
|
||
- 'resource': 'rp.resource_value',
|
||
- 'status': 'rp.status',
|
||
- 'assigned': 'c.name',
|
||
- 'changed': 'rp.status_changed_at'
|
||
- }
|
||
-
|
||
- sort_column = sort_column_map.get(sort_by, 'rp.id')
|
||
- sort_direction = 'ASC' if sort_order == 'asc' else 'DESC'
|
||
-
|
||
- query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s"
|
||
- params.extend([per_page, offset])
|
||
-
|
||
- cur.execute(query, params)
|
||
- resources = cur.fetchall()
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template('resources.html',
|
||
- stats=stats,
|
||
- resources=resources,
|
||
- recent_activities=recent_activities,
|
||
- page=page,
|
||
- total_pages=total_pages,
|
||
- total=total,
|
||
- resource_type=resource_type,
|
||
- status_filter=status_filter,
|
||
- search=search,
|
||
- show_test=show_test,
|
||
- sort_by=sort_by,
|
||
- sort_order=sort_order,
|
||
- datetime=datetime,
|
||
- timedelta=timedelta)
|
||
-
|
||
-@app.route('/resources/add', methods=['GET', 'POST'])
|
||
-@login_required
|
||
-def add_resources():
|
||
- """Ressourcen zum Pool hinzufügen"""
|
||
- # Hole show_test Parameter für die Anzeige
|
||
- show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
-
|
||
- if request.method == 'POST':
|
||
- resource_type = request.form.get('resource_type')
|
||
- resources_text = request.form.get('resources_text', '')
|
||
- is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten
|
||
-
|
||
- # Parse resources (one per line)
|
||
- resources = [r.strip() for r in resources_text.split('\n') if r.strip()]
|
||
-
|
||
- if not resources:
|
||
- flash('Keine Ressourcen angegeben', 'error')
|
||
- return redirect(url_for('add_resources', show_test=show_test))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- added = 0
|
||
- duplicates = 0
|
||
-
|
||
- for resource_value in resources:
|
||
- try:
|
||
- cur.execute("""
|
||
- INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test)
|
||
- VALUES (%s, %s, %s, %s)
|
||
- ON CONFLICT (resource_type, resource_value) DO NOTHING
|
||
- """, (resource_type, resource_value, session['username'], is_test))
|
||
-
|
||
- if cur.rowcount > 0:
|
||
- added += 1
|
||
- # Get the inserted ID
|
||
- cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s",
|
||
- (resource_type, resource_value))
|
||
- resource_id = cur.fetchone()[0]
|
||
-
|
||
- # Log in history
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, action, action_by, ip_address)
|
||
- VALUES (%s, 'created', %s, %s)
|
||
- """, (resource_id, session['username'], get_client_ip()))
|
||
- else:
|
||
- duplicates += 1
|
||
-
|
||
- except Exception as e:
|
||
- app.logger.error(f"Error adding resource {resource_value}: {e}")
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- log_audit('CREATE', 'resource_pool', None,
|
||
- new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test},
|
||
- additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen")
|
||
-
|
||
- flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success')
|
||
- return redirect(url_for('resources', show_test=show_test))
|
||
-
|
||
- return render_template('add_resources.html', show_test=show_test)
|
||
-
|
||
-@app.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
|
||
-@login_required
|
||
-def quarantine_resource(resource_id):
|
||
- """Ressource in Quarantäne setzen"""
|
||
- reason = request.form.get('reason', 'review')
|
||
- until_date = request.form.get('until_date')
|
||
- notes = request.form.get('notes', '')
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Get current resource info
|
||
- cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,))
|
||
- resource = cur.fetchone()
|
||
-
|
||
- if not resource:
|
||
- flash('Ressource nicht gefunden', 'error')
|
||
- return redirect(url_for('resources'))
|
||
-
|
||
- old_status = resource[2]
|
||
-
|
||
- # Update resource
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'quarantine',
|
||
- quarantine_reason = %s,
|
||
- quarantine_until = %s,
|
||
- notes = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP,
|
||
- status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (reason, until_date if until_date else None, notes, session['username'], resource_id))
|
||
-
|
||
- # Log in history
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, action, action_by, ip_address, details)
|
||
- VALUES (%s, 'quarantined', %s, %s, %s)
|
||
- """, (resource_id, session['username'], get_client_ip(),
|
||
- Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status})))
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- log_audit('UPDATE', 'resource', resource_id,
|
||
- old_values={'status': old_status},
|
||
- new_values={'status': 'quarantine', 'reason': reason},
|
||
- additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne")
|
||
-
|
||
- flash('Ressource in Quarantäne gesetzt', 'success')
|
||
-
|
||
- # Redirect mit allen aktuellen Filtern
|
||
- return redirect(url_for('resources',
|
||
- show_test=request.args.get('show_test', request.form.get('show_test', 'false')),
|
||
- type=request.args.get('type', request.form.get('type', '')),
|
||
- status=request.args.get('status', request.form.get('status', '')),
|
||
- search=request.args.get('search', request.form.get('search', ''))))
|
||
-
|
||
-@app.route('/resources/release', methods=['POST'])
|
||
-@login_required
|
||
-def release_resources():
|
||
- """Ressourcen aus Quarantäne freigeben"""
|
||
- resource_ids = request.form.getlist('resource_ids')
|
||
-
|
||
- if not resource_ids:
|
||
- flash('Keine Ressourcen ausgewählt', 'error')
|
||
- return redirect(url_for('resources'))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- released = 0
|
||
- for resource_id in resource_ids:
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'available',
|
||
- quarantine_reason = NULL,
|
||
- quarantine_until = NULL,
|
||
- allocated_to_license = NULL,
|
||
- status_changed_at = CURRENT_TIMESTAMP,
|
||
- status_changed_by = %s
|
||
- WHERE id = %s AND status = 'quarantine'
|
||
- """, (session['username'], resource_id))
|
||
-
|
||
- if cur.rowcount > 0:
|
||
- released += 1
|
||
- # Log in history
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, action, action_by, ip_address)
|
||
- VALUES (%s, 'released', %s, %s)
|
||
- """, (resource_id, session['username'], get_client_ip()))
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- log_audit('UPDATE', 'resource_pool', None,
|
||
- new_values={'released': released},
|
||
- additional_info=f"{released} Ressourcen aus Quarantäne freigegeben")
|
||
-
|
||
- flash(f'{released} Ressourcen freigegeben', 'success')
|
||
-
|
||
- # Redirect mit allen aktuellen Filtern
|
||
- return redirect(url_for('resources',
|
||
- show_test=request.args.get('show_test', request.form.get('show_test', 'false')),
|
||
- type=request.args.get('type', request.form.get('type', '')),
|
||
- status=request.args.get('status', request.form.get('status', '')),
|
||
- search=request.args.get('search', request.form.get('search', ''))))
|
||
-
|
||
-@app.route('/api/resources/allocate', methods=['POST'])
|
||
-@login_required
|
||
-def allocate_resources_api():
|
||
- """API für Ressourcen-Zuweisung bei Lizenzerstellung"""
|
||
- data = request.json
|
||
- license_id = data.get('license_id')
|
||
- domain_count = data.get('domain_count', 1)
|
||
- ipv4_count = data.get('ipv4_count', 1)
|
||
- phone_count = data.get('phone_count', 1)
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- try:
|
||
- allocated = {'domains': [], 'ipv4s': [], 'phones': []}
|
||
-
|
||
- # Allocate domains
|
||
- if domain_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id, resource_value FROM resource_pools
|
||
- WHERE resource_type = 'domain' AND status = 'available'
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (domain_count,))
|
||
- domains = cur.fetchall()
|
||
-
|
||
- if len(domains) < domain_count:
|
||
- raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})")
|
||
-
|
||
- for domain_id, domain_value in domains:
|
||
- # Update resource status
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated',
|
||
- allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP,
|
||
- status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], domain_id))
|
||
-
|
||
- # Create assignment
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, domain_id, session['username']))
|
||
-
|
||
- # Log history
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (domain_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- allocated['domains'].append(domain_value)
|
||
-
|
||
- # Allocate IPv4s (similar logic)
|
||
- if ipv4_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id, resource_value FROM resource_pools
|
||
- WHERE resource_type = 'ipv4' AND status = 'available'
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (ipv4_count,))
|
||
- ipv4s = cur.fetchall()
|
||
-
|
||
- if len(ipv4s) < ipv4_count:
|
||
- raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar")
|
||
-
|
||
- for ipv4_id, ipv4_value in ipv4s:
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated',
|
||
- allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP,
|
||
- status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], ipv4_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, ipv4_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (ipv4_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- allocated['ipv4s'].append(ipv4_value)
|
||
-
|
||
- # Allocate phones (similar logic)
|
||
- if phone_count > 0:
|
||
- cur.execute("""
|
||
- SELECT id, resource_value FROM resource_pools
|
||
- WHERE resource_type = 'phone' AND status = 'available'
|
||
- LIMIT %s FOR UPDATE
|
||
- """, (phone_count,))
|
||
- phones = cur.fetchall()
|
||
-
|
||
- if len(phones) < phone_count:
|
||
- raise ValueError(f"Nicht genügend Telefonnummern verfügbar")
|
||
-
|
||
- for phone_id, phone_value in phones:
|
||
- cur.execute("""
|
||
- UPDATE resource_pools
|
||
- SET status = 'allocated',
|
||
- allocated_to_license = %s,
|
||
- status_changed_at = CURRENT_TIMESTAMP,
|
||
- status_changed_by = %s
|
||
- WHERE id = %s
|
||
- """, (license_id, session['username'], phone_id))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
- VALUES (%s, %s, %s)
|
||
- """, (license_id, phone_id, session['username']))
|
||
-
|
||
- cur.execute("""
|
||
- INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
- VALUES (%s, %s, 'allocated', %s, %s)
|
||
- """, (phone_id, license_id, session['username'], get_client_ip()))
|
||
-
|
||
- allocated['phones'].append(phone_value)
|
||
-
|
||
- # Update license resource counts
|
||
- cur.execute("""
|
||
- UPDATE licenses
|
||
- SET domain_count = %s,
|
||
- ipv4_count = %s,
|
||
- phone_count = %s
|
||
- WHERE id = %s
|
||
- """, (domain_count, ipv4_count, phone_count, license_id))
|
||
-
|
||
- conn.commit()
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'success': True,
|
||
- 'allocated': allocated
|
||
- })
|
||
-
|
||
- except Exception as e:
|
||
- conn.rollback()
|
||
- cur.close()
|
||
- conn.close()
|
||
- return jsonify({
|
||
- 'success': False,
|
||
- 'error': str(e)
|
||
- }), 400
|
||
-
|
||
-@app.route('/api/resources/check-availability', methods=['GET'])
|
||
-@login_required
|
||
-def check_resource_availability():
|
||
- """Prüft verfügbare Ressourcen"""
|
||
- resource_type = request.args.get('type', '')
|
||
- count = request.args.get('count', 10, type=int)
|
||
- show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- if resource_type:
|
||
- # Spezifische Ressourcen für einen Typ
|
||
- cur.execute("""
|
||
- SELECT id, resource_value
|
||
- FROM resource_pools
|
||
- WHERE status = 'available'
|
||
- AND resource_type = %s
|
||
- AND is_test = %s
|
||
- ORDER BY resource_value
|
||
- LIMIT %s
|
||
- """, (resource_type, show_test, count))
|
||
-
|
||
- resources = []
|
||
- for row in cur.fetchall():
|
||
- resources.append({
|
||
- 'id': row[0],
|
||
- 'value': row[1]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'available': resources,
|
||
- 'type': resource_type,
|
||
- 'count': len(resources)
|
||
- })
|
||
- else:
|
||
- # Zusammenfassung aller Typen
|
||
- cur.execute("""
|
||
- SELECT
|
||
- resource_type,
|
||
- COUNT(*) as available
|
||
- FROM resource_pools
|
||
- WHERE status = 'available'
|
||
- AND is_test = %s
|
||
- GROUP BY resource_type
|
||
- """, (show_test,))
|
||
-
|
||
- availability = {}
|
||
- for row in cur.fetchall():
|
||
- availability[row[0]] = row[1]
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify(availability)
|
||
-
|
||
-@app.route('/api/global-search', methods=['GET'])
|
||
-@login_required
|
||
-def global_search():
|
||
- """Global search API endpoint for searching customers and licenses"""
|
||
- query = request.args.get('q', '').strip()
|
||
-
|
||
- if not query or len(query) < 2:
|
||
- return jsonify({'customers': [], 'licenses': []})
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Search pattern with wildcards
|
||
- search_pattern = f'%{query}%'
|
||
-
|
||
- # Search customers
|
||
- cur.execute("""
|
||
- SELECT id, name, email, company_name
|
||
- FROM customers
|
||
- WHERE (LOWER(name) LIKE LOWER(%s)
|
||
- OR LOWER(email) LIKE LOWER(%s)
|
||
- OR LOWER(company_name) LIKE LOWER(%s))
|
||
- AND is_test = FALSE
|
||
- ORDER BY name
|
||
- LIMIT 5
|
||
- """, (search_pattern, search_pattern, search_pattern))
|
||
-
|
||
- customers = []
|
||
- for row in cur.fetchall():
|
||
- customers.append({
|
||
- 'id': row[0],
|
||
- 'name': row[1],
|
||
- 'email': row[2],
|
||
- 'company_name': row[3]
|
||
- })
|
||
-
|
||
- # Search licenses
|
||
- cur.execute("""
|
||
- SELECT l.id, l.license_key, c.name as customer_name
|
||
- FROM licenses l
|
||
- JOIN customers c ON l.customer_id = c.id
|
||
- WHERE LOWER(l.license_key) LIKE LOWER(%s)
|
||
- AND l.is_test = FALSE
|
||
- ORDER BY l.created_at DESC
|
||
- LIMIT 5
|
||
- """, (search_pattern,))
|
||
-
|
||
- licenses = []
|
||
- for row in cur.fetchall():
|
||
- licenses.append({
|
||
- 'id': row[0],
|
||
- 'license_key': row[1],
|
||
- 'customer_name': row[2]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return jsonify({
|
||
- 'customers': customers,
|
||
- 'licenses': licenses
|
||
- })
|
||
-
|
||
-@app.route('/resources/history/<int:resource_id>')
|
||
-@login_required
|
||
-def resource_history(resource_id):
|
||
- """Zeigt die komplette Historie einer Ressource"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Get complete resource info using named columns
|
||
- cur.execute("""
|
||
- SELECT id, resource_type, resource_value, status, allocated_to_license,
|
||
- status_changed_at, status_changed_by, quarantine_reason,
|
||
- quarantine_until, created_at, notes
|
||
- FROM resource_pools
|
||
- WHERE id = %s
|
||
- """, (resource_id,))
|
||
- row = cur.fetchone()
|
||
-
|
||
- if not row:
|
||
- flash('Ressource nicht gefunden', 'error')
|
||
- return redirect(url_for('resources'))
|
||
-
|
||
- # Create resource object with named attributes
|
||
- resource = {
|
||
- 'id': row[0],
|
||
- 'resource_type': row[1],
|
||
- 'resource_value': row[2],
|
||
- 'status': row[3],
|
||
- 'allocated_to_license': row[4],
|
||
- 'status_changed_at': row[5],
|
||
- 'status_changed_by': row[6],
|
||
- 'quarantine_reason': row[7],
|
||
- 'quarantine_until': row[8],
|
||
- 'created_at': row[9],
|
||
- 'notes': row[10]
|
||
- }
|
||
-
|
||
- # Get license info if allocated
|
||
- license_info = None
|
||
- if resource['allocated_to_license']:
|
||
- cur.execute("SELECT license_key FROM licenses WHERE id = %s",
|
||
- (resource['allocated_to_license'],))
|
||
- lic = cur.fetchone()
|
||
- if lic:
|
||
- license_info = {'license_key': lic[0]}
|
||
-
|
||
- # Get history with named columns
|
||
- cur.execute("""
|
||
- SELECT
|
||
- rh.action,
|
||
- rh.action_by,
|
||
- rh.action_at,
|
||
- rh.details,
|
||
- rh.license_id,
|
||
- rh.ip_address
|
||
- FROM resource_history rh
|
||
- WHERE rh.resource_id = %s
|
||
- ORDER BY rh.action_at DESC
|
||
- """, (resource_id,))
|
||
-
|
||
- history = []
|
||
- for row in cur.fetchall():
|
||
- history.append({
|
||
- 'action': row[0],
|
||
- 'action_by': row[1],
|
||
- 'action_at': row[2],
|
||
- 'details': row[3],
|
||
- 'license_id': row[4],
|
||
- 'ip_address': row[5]
|
||
- })
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Convert to object-like for template
|
||
- class ResourceObj:
|
||
- def __init__(self, data):
|
||
- for key, value in data.items():
|
||
- setattr(self, key, value)
|
||
-
|
||
- resource_obj = ResourceObj(resource)
|
||
- history_objs = [ResourceObj(h) for h in history]
|
||
-
|
||
- return render_template('resource_history.html',
|
||
- resource=resource_obj,
|
||
- license_info=license_info,
|
||
- history=history_objs)
|
||
-
|
||
-@app.route('/resources/metrics')
|
||
-@login_required
|
||
-def resources_metrics():
|
||
- """Dashboard für Resource Metrics und Reports"""
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- # Overall stats with fallback values
|
||
- cur.execute("""
|
||
- SELECT
|
||
- COUNT(DISTINCT resource_id) as total_resources,
|
||
- COALESCE(AVG(performance_score), 0) as avg_performance,
|
||
- COALESCE(SUM(cost), 0) as total_cost,
|
||
- COALESCE(SUM(revenue), 0) as total_revenue,
|
||
- COALESCE(SUM(issues_count), 0) as total_issues
|
||
- FROM resource_metrics
|
||
- WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
- """)
|
||
- row = cur.fetchone()
|
||
-
|
||
- # Calculate ROI
|
||
- roi = 0
|
||
- if row[2] > 0: # if total_cost > 0
|
||
- roi = row[3] / row[2] # revenue / cost
|
||
-
|
||
- stats = {
|
||
- 'total_resources': row[0] or 0,
|
||
- 'avg_performance': row[1] or 0,
|
||
- 'total_cost': row[2] or 0,
|
||
- 'total_revenue': row[3] or 0,
|
||
- 'total_issues': row[4] or 0,
|
||
- 'roi': roi
|
||
- }
|
||
-
|
||
- # Performance by type
|
||
- cur.execute("""
|
||
- SELECT
|
||
- rp.resource_type,
|
||
- COALESCE(AVG(rm.performance_score), 0) as avg_score,
|
||
- COUNT(DISTINCT rp.id) as resource_count
|
||
- FROM resource_pools rp
|
||
- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
- GROUP BY rp.resource_type
|
||
- ORDER BY rp.resource_type
|
||
- """)
|
||
- performance_by_type = cur.fetchall()
|
||
-
|
||
- # Utilization data
|
||
- cur.execute("""
|
||
- SELECT
|
||
- resource_type,
|
||
- COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
- COUNT(*) as total,
|
||
- ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent
|
||
- FROM resource_pools
|
||
- GROUP BY resource_type
|
||
- """)
|
||
- utilization_rows = cur.fetchall()
|
||
- utilization_data = [
|
||
- {
|
||
- 'type': row[0].upper(),
|
||
- 'allocated': row[1],
|
||
- 'total': row[2],
|
||
- 'allocated_percent': row[3]
|
||
- }
|
||
- for row in utilization_rows
|
||
- ]
|
||
-
|
||
- # Top performing resources
|
||
- cur.execute("""
|
||
- SELECT
|
||
- rp.id,
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- COALESCE(AVG(rm.performance_score), 0) as avg_score,
|
||
- COALESCE(SUM(rm.revenue), 0) as total_revenue,
|
||
- COALESCE(SUM(rm.cost), 1) as total_cost,
|
||
- CASE
|
||
- WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0
|
||
- ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1)
|
||
- END as roi
|
||
- FROM resource_pools rp
|
||
- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
- WHERE rp.status != 'quarantine'
|
||
- GROUP BY rp.id, rp.resource_type, rp.resource_value
|
||
- HAVING AVG(rm.performance_score) IS NOT NULL
|
||
- ORDER BY avg_score DESC
|
||
- LIMIT 10
|
||
- """)
|
||
- top_rows = cur.fetchall()
|
||
- top_performers = [
|
||
- {
|
||
- 'id': row[0],
|
||
- 'resource_type': row[1],
|
||
- 'resource_value': row[2],
|
||
- 'avg_score': row[3],
|
||
- 'roi': row[6]
|
||
- }
|
||
- for row in top_rows
|
||
- ]
|
||
-
|
||
- # Resources with issues
|
||
- cur.execute("""
|
||
- SELECT
|
||
- rp.id,
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- rp.status,
|
||
- COALESCE(SUM(rm.issues_count), 0) as total_issues
|
||
- FROM resource_pools rp
|
||
- LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
- AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
- WHERE rm.issues_count > 0 OR rp.status = 'quarantine'
|
||
- GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status
|
||
- HAVING SUM(rm.issues_count) > 0
|
||
- ORDER BY total_issues DESC
|
||
- LIMIT 10
|
||
- """)
|
||
- problem_rows = cur.fetchall()
|
||
- problem_resources = [
|
||
- {
|
||
- 'id': row[0],
|
||
- 'resource_type': row[1],
|
||
- 'resource_value': row[2],
|
||
- 'status': row[3],
|
||
- 'total_issues': row[4]
|
||
- }
|
||
- for row in problem_rows
|
||
- ]
|
||
-
|
||
- # Daily metrics for trend chart (last 30 days)
|
||
- cur.execute("""
|
||
- SELECT
|
||
- metric_date,
|
||
- COALESCE(AVG(performance_score), 0) as avg_performance,
|
||
- COALESCE(SUM(issues_count), 0) as total_issues
|
||
- FROM resource_metrics
|
||
- WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
- GROUP BY metric_date
|
||
- ORDER BY metric_date
|
||
- """)
|
||
- daily_rows = cur.fetchall()
|
||
- daily_metrics = [
|
||
- {
|
||
- 'date': row[0].strftime('%d.%m'),
|
||
- 'performance': float(row[1]),
|
||
- 'issues': int(row[2])
|
||
- }
|
||
- for row in daily_rows
|
||
- ]
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- return render_template('resource_metrics.html',
|
||
- stats=stats,
|
||
- performance_by_type=performance_by_type,
|
||
- utilization_data=utilization_data,
|
||
- top_performers=top_performers,
|
||
- problem_resources=problem_resources,
|
||
- daily_metrics=daily_metrics)
|
||
-
|
||
-@app.route('/resources/report', methods=['GET'])
|
||
-@login_required
|
||
-def resources_report():
|
||
- """Generiert Ressourcen-Reports oder zeigt Report-Formular"""
|
||
- # Prüfe ob Download angefordert wurde
|
||
- if request.args.get('download') == 'true':
|
||
- report_type = request.args.get('type', 'usage')
|
||
- format_type = request.args.get('format', 'excel')
|
||
- date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d'))
|
||
- date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d'))
|
||
-
|
||
- conn = get_connection()
|
||
- cur = conn.cursor()
|
||
-
|
||
- if report_type == 'usage':
|
||
- # Auslastungsreport
|
||
- query = """
|
||
- SELECT
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- rp.status,
|
||
- COUNT(DISTINCT rh.license_id) as unique_licenses,
|
||
- COUNT(rh.id) as total_allocations,
|
||
- MIN(rh.action_at) as first_used,
|
||
- MAX(rh.action_at) as last_used
|
||
- FROM resource_pools rp
|
||
- LEFT JOIN resource_history rh ON rp.id = rh.resource_id
|
||
- AND rh.action = 'allocated'
|
||
- AND rh.action_at BETWEEN %s AND %s
|
||
- GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status
|
||
- ORDER BY rp.resource_type, total_allocations DESC
|
||
- """
|
||
- cur.execute(query, (date_from, date_to))
|
||
- columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung']
|
||
-
|
||
- elif report_type == 'performance':
|
||
- # Performance-Report
|
||
- query = """
|
||
- SELECT
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- AVG(rm.performance_score) as avg_performance,
|
||
- SUM(rm.usage_count) as total_usage,
|
||
- SUM(rm.revenue) as total_revenue,
|
||
- SUM(rm.cost) as total_cost,
|
||
- SUM(rm.revenue - rm.cost) as profit,
|
||
- SUM(rm.issues_count) as total_issues
|
||
- FROM resource_pools rp
|
||
- JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
- WHERE rm.metric_date BETWEEN %s AND %s
|
||
- GROUP BY rp.id, rp.resource_type, rp.resource_value
|
||
- ORDER BY profit DESC
|
||
- """
|
||
- cur.execute(query, (date_from, date_to))
|
||
- columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues']
|
||
-
|
||
- elif report_type == 'compliance':
|
||
- # Compliance-Report
|
||
- query = """
|
||
- SELECT
|
||
- rh.action_at,
|
||
- rh.action,
|
||
- rh.action_by,
|
||
- rp.resource_type,
|
||
- rp.resource_value,
|
||
- l.license_key,
|
||
- c.name as customer_name,
|
||
- rh.ip_address
|
||
- FROM resource_history rh
|
||
- JOIN resource_pools rp ON rh.resource_id = rp.id
|
||
- LEFT JOIN licenses l ON rh.license_id = l.id
|
||
- LEFT JOIN customers c ON l.customer_id = c.id
|
||
- WHERE rh.action_at BETWEEN %s AND %s
|
||
- ORDER BY rh.action_at DESC
|
||
- """
|
||
- cur.execute(query, (date_from, date_to))
|
||
- columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse']
|
||
-
|
||
- else: # inventory report
|
||
- # Inventar-Report
|
||
- query = """
|
||
- SELECT
|
||
- resource_type,
|
||
- COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
- COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
- COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
- COUNT(*) as total
|
||
- FROM resource_pools
|
||
- GROUP BY resource_type
|
||
- ORDER BY resource_type
|
||
- """
|
||
- cur.execute(query)
|
||
- columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt']
|
||
-
|
||
- # Convert to DataFrame
|
||
- data = cur.fetchall()
|
||
- df = pd.DataFrame(data, columns=columns)
|
||
-
|
||
- cur.close()
|
||
- conn.close()
|
||
-
|
||
- # Generate file
|
||
- timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
- filename = f"resource_report_{report_type}_{timestamp}"
|
||
-
|
||
- if format_type == 'excel':
|
||
- output = io.BytesIO()
|
||
- with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
- df.to_excel(writer, sheet_name='Report', index=False)
|
||
-
|
||
- # Auto-adjust columns width
|
||
- worksheet = writer.sheets['Report']
|
||
- for column in worksheet.columns:
|
||
- max_length = 0
|
||
- column = [cell for cell in column]
|
||
- for cell in column:
|
||
- try:
|
||
- if len(str(cell.value)) > max_length:
|
||
- max_length = len(str(cell.value))
|
||
- except:
|
||
- pass
|
||
- adjusted_width = (max_length + 2)
|
||
- worksheet.column_dimensions[column[0].column_letter].width = adjusted_width
|
||
-
|
||
- output.seek(0)
|
||
-
|
||
- log_audit('EXPORT', 'resource_report', None,
|
||
- new_values={'type': report_type, 'format': 'excel', 'rows': len(df)},
|
||
- additional_info=f"Resource Report {report_type} exportiert")
|
||
-
|
||
- return send_file(output,
|
||
- mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.xlsx')
|
||
-
|
||
- else: # CSV
|
||
- output = io.StringIO()
|
||
- df.to_csv(output, index=False, sep=';', encoding='utf-8-sig')
|
||
- output.seek(0)
|
||
-
|
||
- log_audit('EXPORT', 'resource_report', None,
|
||
- new_values={'type': report_type, 'format': 'csv', 'rows': len(df)},
|
||
- additional_info=f"Resource Report {report_type} exportiert")
|
||
-
|
||
- return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
- mimetype='text/csv',
|
||
- as_attachment=True,
|
||
- download_name=f'{filename}.csv')
|
||
-
|
||
- # Wenn kein Download, zeige Report-Formular
|
||
- return render_template('resource_report.html',
|
||
- datetime=datetime,
|
||
- timedelta=timedelta,
|
||
- username=session.get('username'))
|
||
-
|
||
-if __name__ == "__main__":
|
||
- app.run(host="0.0.0.0", port=5000)
|
||
+import os
|
||
+import time
|
||
+import json
|
||
+import logging
|
||
+import requests
|
||
+from io import BytesIO
|
||
+from datetime import datetime, timedelta
|
||
+from zoneinfo import ZoneInfo
|
||
+from pathlib import Path
|
||
+
|
||
+from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash
|
||
+from flask_session import Session
|
||
+from werkzeug.middleware.proxy_fix import ProxyFix
|
||
+from apscheduler.schedulers.background import BackgroundScheduler
|
||
+import pandas as pd
|
||
+from psycopg2.extras import Json
|
||
+
|
||
+# Import our new modules
|
||
+import config
|
||
+from db import get_connection, get_db_connection, get_db_cursor, execute_query
|
||
+from auth.decorators import login_required
|
||
+from auth.password import hash_password, verify_password
|
||
+from auth.two_factor import (
|
||
+ generate_totp_secret, generate_qr_code, verify_totp,
|
||
+ generate_backup_codes, hash_backup_code, verify_backup_code
|
||
+)
|
||
+from auth.rate_limiting import (
|
||
+ get_client_ip, check_ip_blocked, record_failed_attempt,
|
||
+ reset_login_attempts, get_login_attempts
|
||
+)
|
||
+from utils.audit import log_audit
|
||
+from utils.license import generate_license_key, validate_license_key
|
||
+from utils.backup import create_backup, restore_backup, get_or_create_encryption_key
|
||
+from utils.export import (
|
||
+ create_excel_export, format_datetime_for_export,
|
||
+ prepare_license_export_data, prepare_customer_export_data,
|
||
+ prepare_session_export_data, prepare_audit_export_data
|
||
+)
|
||
+
|
||
+app = Flask(__name__)
|
||
+# Load configuration from config module
|
||
+app.config['SECRET_KEY'] = config.SECRET_KEY
|
||
+app.config['SESSION_TYPE'] = config.SESSION_TYPE
|
||
+app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII
|
||
+app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE
|
||
+app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME
|
||
+app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY
|
||
+app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE
|
||
+app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE
|
||
+app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME
|
||
+app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST
|
||
+Session(app)
|
||
+
|
||
+# ProxyFix für korrekte IP-Adressen hinter Nginx
|
||
+app.wsgi_app = ProxyFix(
|
||
+ app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
|
||
+)
|
||
+
|
||
+# Configuration is now loaded from config module
|
||
+
|
||
+# Scheduler für automatische Backups
|
||
+scheduler = BackgroundScheduler()
|
||
+scheduler.start()
|
||
+
|
||
+# Logging konfigurieren
|
||
+logging.basicConfig(level=logging.INFO)
|
||
+
|
||
+
|
||
+# Login decorator
|
||
+def login_required(f):
|
||
+ @wraps(f)
|
||
+ def decorated_function(*args, **kwargs):
|
||
+ if 'logged_in' not in session:
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+ # Prüfe ob Session abgelaufen ist
|
||
+ if 'last_activity' in session:
|
||
+ last_activity = datetime.fromisoformat(session['last_activity'])
|
||
+ time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity
|
||
+
|
||
+ # Debug-Logging
|
||
+ app.logger.info(f"Session check for {session.get('username', 'unknown')}: "
|
||
+ f"Last activity: {last_activity}, "
|
||
+ f"Time since: {time_since_activity.total_seconds()} seconds")
|
||
+
|
||
+ if time_since_activity > timedelta(minutes=5):
|
||
+ # Session abgelaufen - Logout
|
||
+ username = session.get('username', 'unbekannt')
|
||
+ app.logger.info(f"Session timeout for user {username} - auto logout")
|
||
+ # Audit-Log für automatischen Logout (vor session.clear()!)
|
||
+ try:
|
||
+ log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username})
|
||
+ except:
|
||
+ pass
|
||
+ session.clear()
|
||
+ flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning')
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+ # Aktivität NICHT automatisch aktualisieren
|
||
+ # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht)
|
||
+ return f(*args, **kwargs)
|
||
+ return decorated_function
|
||
+
|
||
+# DB-Verbindung mit UTF-8 Encoding
|
||
+def get_connection():
|
||
+ conn = psycopg2.connect(
|
||
+ host=os.getenv("POSTGRES_HOST", "postgres"),
|
||
+ port=os.getenv("POSTGRES_PORT", "5432"),
|
||
+ dbname=os.getenv("POSTGRES_DB"),
|
||
+ user=os.getenv("POSTGRES_USER"),
|
||
+ password=os.getenv("POSTGRES_PASSWORD"),
|
||
+ options='-c client_encoding=UTF8'
|
||
+ )
|
||
+ conn.set_client_encoding('UTF8')
|
||
+ return conn
|
||
+
|
||
+# User Authentication Helper Functions
|
||
+def hash_password(password):
|
||
+ """Hash a password using bcrypt"""
|
||
+ return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||
+
|
||
+def verify_password(password, hashed):
|
||
+ """Verify a password against its hash"""
|
||
+ return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
||
+
|
||
+def get_user_by_username(username):
|
||
+ """Get user from database by username"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ try:
|
||
+ cur.execute("""
|
||
+ SELECT id, username, password_hash, email, totp_secret, totp_enabled,
|
||
+ backup_codes, last_password_change, failed_2fa_attempts
|
||
+ FROM users WHERE username = %s
|
||
+ """, (username,))
|
||
+ user = cur.fetchone()
|
||
+ if user:
|
||
+ return {
|
||
+ 'id': user[0],
|
||
+ 'username': user[1],
|
||
+ 'password_hash': user[2],
|
||
+ 'email': user[3],
|
||
+ 'totp_secret': user[4],
|
||
+ 'totp_enabled': user[5],
|
||
+ 'backup_codes': user[6],
|
||
+ 'last_password_change': user[7],
|
||
+ 'failed_2fa_attempts': user[8]
|
||
+ }
|
||
+ return None
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+def generate_totp_secret():
|
||
+ """Generate a new TOTP secret"""
|
||
+ return pyotp.random_base32()
|
||
+
|
||
+def generate_qr_code(username, totp_secret):
|
||
+ """Generate QR code for TOTP setup"""
|
||
+ totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
|
||
+ name=username,
|
||
+ issuer_name='V2 Admin Panel'
|
||
+ )
|
||
+
|
||
+ qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||
+ qr.add_data(totp_uri)
|
||
+ qr.make(fit=True)
|
||
+
|
||
+ img = qr.make_image(fill_color="black", back_color="white")
|
||
+ buf = BytesIO()
|
||
+ img.save(buf, format='PNG')
|
||
+ buf.seek(0)
|
||
+
|
||
+ return base64.b64encode(buf.getvalue()).decode()
|
||
+
|
||
+def verify_totp(totp_secret, token):
|
||
+ """Verify a TOTP token"""
|
||
+ totp = pyotp.TOTP(totp_secret)
|
||
+ return totp.verify(token, valid_window=1)
|
||
+
|
||
+def generate_backup_codes(count=8):
|
||
+ """Generate backup codes for 2FA recovery"""
|
||
+ codes = []
|
||
+ for _ in range(count):
|
||
+ code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||
+ codes.append(code)
|
||
+ return codes
|
||
+
|
||
+def hash_backup_code(code):
|
||
+ """Hash a backup code for storage"""
|
||
+ return hashlib.sha256(code.encode()).hexdigest()
|
||
+
|
||
+def verify_backup_code(code, hashed_codes):
|
||
+ """Verify a backup code against stored hashes"""
|
||
+ code_hash = hashlib.sha256(code.encode()).hexdigest()
|
||
+ return code_hash in hashed_codes
|
||
+
|
||
+# Audit-Log-Funktion
|
||
+def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None):
|
||
+ """Protokolliert Änderungen im Audit-Log"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ username = session.get('username', 'system')
|
||
+ ip_address = get_client_ip() if request else None
|
||
+ user_agent = request.headers.get('User-Agent') if request else None
|
||
+
|
||
+ # Debug logging
|
||
+ app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}")
|
||
+
|
||
+ # Konvertiere Dictionaries zu JSONB
|
||
+ old_json = Json(old_values) if old_values else None
|
||
+ new_json = Json(new_values) if new_values else None
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO audit_log
|
||
+ (username, action, entity_type, entity_id, old_values, new_values,
|
||
+ ip_address, user_agent, additional_info)
|
||
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
+ """, (username, action, entity_type, entity_id, old_json, new_json,
|
||
+ ip_address, user_agent, additional_info))
|
||
+
|
||
+ conn.commit()
|
||
+ except Exception as e:
|
||
+ print(f"Audit log error: {e}")
|
||
+ conn.rollback()
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+# Verschlüsselungs-Funktionen
|
||
+def get_or_create_encryption_key():
|
||
+ """Holt oder erstellt einen Verschlüsselungsschlüssel"""
|
||
+ key_file = BACKUP_DIR / ".backup_key"
|
||
+
|
||
+ # Versuche Key aus Umgebungsvariable zu lesen
|
||
+ env_key = os.getenv("BACKUP_ENCRYPTION_KEY")
|
||
+ if env_key:
|
||
+ try:
|
||
+ # Validiere den Key
|
||
+ Fernet(env_key.encode())
|
||
+ return env_key.encode()
|
||
+ except:
|
||
+ pass
|
||
+
|
||
+ # Wenn kein gültiger Key in ENV, prüfe Datei
|
||
+ if key_file.exists():
|
||
+ return key_file.read_bytes()
|
||
+
|
||
+ # Erstelle neuen Key
|
||
+ key = Fernet.generate_key()
|
||
+ key_file.write_bytes(key)
|
||
+ logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt")
|
||
+ return key
|
||
+
|
||
+# Backup-Funktionen
|
||
+def create_backup(backup_type="manual", created_by=None):
|
||
+ """Erstellt ein verschlüsseltes Backup der Datenbank"""
|
||
+ start_time = time.time()
|
||
+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")
|
||
+ filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc"
|
||
+ filepath = BACKUP_DIR / filename
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Backup-Eintrag erstellen
|
||
+ cur.execute("""
|
||
+ INSERT INTO backup_history
|
||
+ (filename, filepath, backup_type, status, created_by, is_encrypted)
|
||
+ VALUES (%s, %s, %s, %s, %s, %s)
|
||
+ RETURNING id
|
||
+ """, (filename, str(filepath), backup_type, 'in_progress',
|
||
+ created_by or 'system', True))
|
||
+ backup_id = cur.fetchone()[0]
|
||
+ conn.commit()
|
||
+
|
||
+ try:
|
||
+ # PostgreSQL Dump erstellen
|
||
+ dump_command = [
|
||
+ 'pg_dump',
|
||
+ '-h', os.getenv("POSTGRES_HOST", "postgres"),
|
||
+ '-p', os.getenv("POSTGRES_PORT", "5432"),
|
||
+ '-U', os.getenv("POSTGRES_USER"),
|
||
+ '-d', os.getenv("POSTGRES_DB"),
|
||
+ '--no-password',
|
||
+ '--verbose'
|
||
+ ]
|
||
+
|
||
+ # PGPASSWORD setzen
|
||
+ env = os.environ.copy()
|
||
+ env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
|
||
+
|
||
+ # Dump ausführen
|
||
+ result = subprocess.run(dump_command, capture_output=True, text=True, env=env)
|
||
+
|
||
+ if result.returncode != 0:
|
||
+ raise Exception(f"pg_dump failed: {result.stderr}")
|
||
+
|
||
+ dump_data = result.stdout.encode('utf-8')
|
||
+
|
||
+ # Komprimieren
|
||
+ compressed_data = gzip.compress(dump_data)
|
||
+
|
||
+ # Verschlüsseln
|
||
+ key = get_or_create_encryption_key()
|
||
+ f = Fernet(key)
|
||
+ encrypted_data = f.encrypt(compressed_data)
|
||
+
|
||
+ # Speichern
|
||
+ filepath.write_bytes(encrypted_data)
|
||
+
|
||
+ # Statistiken sammeln
|
||
+ cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'")
|
||
+ tables_count = cur.fetchone()[0]
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT SUM(n_live_tup)
|
||
+ FROM pg_stat_user_tables
|
||
+ """)
|
||
+ records_count = cur.fetchone()[0] or 0
|
||
+
|
||
+ duration = time.time() - start_time
|
||
+ filesize = filepath.stat().st_size
|
||
+
|
||
+ # Backup-Eintrag aktualisieren
|
||
+ cur.execute("""
|
||
+ UPDATE backup_history
|
||
+ SET status = %s, filesize = %s, tables_count = %s,
|
||
+ records_count = %s, duration_seconds = %s
|
||
+ WHERE id = %s
|
||
+ """, ('success', filesize, tables_count, records_count, duration, backup_id))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('BACKUP', 'database', backup_id,
|
||
+ additional_info=f"Backup erstellt: {filename} ({filesize} bytes)")
|
||
+
|
||
+ # E-Mail-Benachrichtigung (wenn konfiguriert)
|
||
+ send_backup_notification(True, filename, filesize, duration)
|
||
+
|
||
+ logging.info(f"Backup erfolgreich erstellt: {filename}")
|
||
+ return True, filename
|
||
+
|
||
+ except Exception as e:
|
||
+ # Fehler protokollieren
|
||
+ cur.execute("""
|
||
+ UPDATE backup_history
|
||
+ SET status = %s, error_message = %s, duration_seconds = %s
|
||
+ WHERE id = %s
|
||
+ """, ('failed', str(e), time.time() - start_time, backup_id))
|
||
+ conn.commit()
|
||
+
|
||
+ logging.error(f"Backup fehlgeschlagen: {e}")
|
||
+ send_backup_notification(False, filename, error=str(e))
|
||
+
|
||
+ return False, str(e)
|
||
+
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+def restore_backup(backup_id, encryption_key=None):
|
||
+ """Stellt ein Backup wieder her"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Backup-Info abrufen
|
||
+ cur.execute("""
|
||
+ SELECT filename, filepath, is_encrypted
|
||
+ FROM backup_history
|
||
+ WHERE id = %s
|
||
+ """, (backup_id,))
|
||
+ backup_info = cur.fetchone()
|
||
+
|
||
+ if not backup_info:
|
||
+ raise Exception("Backup nicht gefunden")
|
||
+
|
||
+ filename, filepath, is_encrypted = backup_info
|
||
+ filepath = Path(filepath)
|
||
+
|
||
+ if not filepath.exists():
|
||
+ raise Exception("Backup-Datei nicht gefunden")
|
||
+
|
||
+ # Datei lesen
|
||
+ encrypted_data = filepath.read_bytes()
|
||
+
|
||
+ # Entschlüsseln
|
||
+ if is_encrypted:
|
||
+ key = encryption_key.encode() if encryption_key else get_or_create_encryption_key()
|
||
+ try:
|
||
+ f = Fernet(key)
|
||
+ compressed_data = f.decrypt(encrypted_data)
|
||
+ except:
|
||
+ raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?")
|
||
+ else:
|
||
+ compressed_data = encrypted_data
|
||
+
|
||
+ # Dekomprimieren
|
||
+ dump_data = gzip.decompress(compressed_data)
|
||
+ sql_commands = dump_data.decode('utf-8')
|
||
+
|
||
+ # Bestehende Verbindungen schließen
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Datenbank wiederherstellen
|
||
+ restore_command = [
|
||
+ 'psql',
|
||
+ '-h', os.getenv("POSTGRES_HOST", "postgres"),
|
||
+ '-p', os.getenv("POSTGRES_PORT", "5432"),
|
||
+ '-U', os.getenv("POSTGRES_USER"),
|
||
+ '-d', os.getenv("POSTGRES_DB"),
|
||
+ '--no-password'
|
||
+ ]
|
||
+
|
||
+ env = os.environ.copy()
|
||
+ env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD")
|
||
+
|
||
+ result = subprocess.run(restore_command, input=sql_commands,
|
||
+ capture_output=True, text=True, env=env)
|
||
+
|
||
+ if result.returncode != 0:
|
||
+ raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}")
|
||
+
|
||
+ # Audit-Log (neue Verbindung)
|
||
+ log_audit('RESTORE', 'database', backup_id,
|
||
+ additional_info=f"Backup wiederhergestellt: {filename}")
|
||
+
|
||
+ return True, "Backup erfolgreich wiederhergestellt"
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Wiederherstellung fehlgeschlagen: {e}")
|
||
+ return False, str(e)
|
||
+
|
||
+def send_backup_notification(success, filename, filesize=None, duration=None, error=None):
|
||
+ """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)"""
|
||
+ if not os.getenv("EMAIL_ENABLED", "false").lower() == "true":
|
||
+ return
|
||
+
|
||
+ # E-Mail-Funktion vorbereitet aber deaktiviert
|
||
+ # TODO: Implementieren wenn E-Mail-Server konfiguriert ist
|
||
+ logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}")
|
||
+
|
||
+# Scheduled Backup Job
|
||
+def scheduled_backup():
|
||
+ """Führt ein geplantes Backup aus"""
|
||
+ logging.info("Starte geplantes Backup...")
|
||
+ create_backup(backup_type="scheduled", created_by="scheduler")
|
||
+
|
||
+# Scheduler konfigurieren - täglich um 3:00 Uhr
|
||
+scheduler.add_job(
|
||
+ scheduled_backup,
|
||
+ 'cron',
|
||
+ hour=3,
|
||
+ minute=0,
|
||
+ id='daily_backup',
|
||
+ replace_existing=True
|
||
+)
|
||
+
|
||
+# Rate-Limiting Funktionen
|
||
+def get_client_ip():
|
||
+ """Ermittelt die echte IP-Adresse des Clients"""
|
||
+ # Debug logging
|
||
+ app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}")
|
||
+
|
||
+ # Try X-Real-IP first (set by nginx)
|
||
+ if request.headers.get('X-Real-IP'):
|
||
+ return request.headers.get('X-Real-IP')
|
||
+ # Then X-Forwarded-For
|
||
+ elif request.headers.get('X-Forwarded-For'):
|
||
+ # X-Forwarded-For can contain multiple IPs, take the first one
|
||
+ return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||
+ # Fallback to remote_addr
|
||
+ else:
|
||
+ return request.remote_addr
|
||
+
|
||
+def check_ip_blocked(ip_address):
|
||
+ """Prüft ob eine IP-Adresse gesperrt ist"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT blocked_until FROM login_attempts
|
||
+ WHERE ip_address = %s AND blocked_until IS NOT NULL
|
||
+ """, (ip_address,))
|
||
+
|
||
+ result = cur.fetchone()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ if result and result[0]:
|
||
+ if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None):
|
||
+ return True, result[0]
|
||
+ return False, None
|
||
+
|
||
+def record_failed_attempt(ip_address, username):
|
||
+ """Zeichnet einen fehlgeschlagenen Login-Versuch auf"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Random Fehlermeldung
|
||
+ error_message = random.choice(FAIL_MESSAGES)
|
||
+
|
||
+ try:
|
||
+ # Prüfen ob IP bereits existiert
|
||
+ cur.execute("""
|
||
+ SELECT attempt_count FROM login_attempts
|
||
+ WHERE ip_address = %s
|
||
+ """, (ip_address,))
|
||
+
|
||
+ result = cur.fetchone()
|
||
+
|
||
+ if result:
|
||
+ # Update bestehenden Eintrag
|
||
+ new_count = result[0] + 1
|
||
+ blocked_until = None
|
||
+
|
||
+ if new_count >= MAX_LOGIN_ATTEMPTS:
|
||
+ blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS)
|
||
+ # E-Mail-Benachrichtigung (wenn aktiviert)
|
||
+ if os.getenv("EMAIL_ENABLED", "false").lower() == "true":
|
||
+ send_security_alert_email(ip_address, username, new_count)
|
||
+
|
||
+ cur.execute("""
|
||
+ UPDATE login_attempts
|
||
+ SET attempt_count = %s,
|
||
+ last_attempt = CURRENT_TIMESTAMP,
|
||
+ blocked_until = %s,
|
||
+ last_username_tried = %s,
|
||
+ last_error_message = %s
|
||
+ WHERE ip_address = %s
|
||
+ """, (new_count, blocked_until, username, error_message, ip_address))
|
||
+ else:
|
||
+ # Neuen Eintrag erstellen
|
||
+ cur.execute("""
|
||
+ INSERT INTO login_attempts
|
||
+ (ip_address, attempt_count, last_username_tried, last_error_message)
|
||
+ VALUES (%s, 1, %s, %s)
|
||
+ """, (ip_address, username, error_message))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('LOGIN_FAILED', 'user',
|
||
+ additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}")
|
||
+
|
||
+ except Exception as e:
|
||
+ print(f"Rate limiting error: {e}")
|
||
+ conn.rollback()
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return error_message
|
||
+
|
||
+def reset_login_attempts(ip_address):
|
||
+ """Setzt die Login-Versuche für eine IP zurück"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ cur.execute("""
|
||
+ DELETE FROM login_attempts
|
||
+ WHERE ip_address = %s
|
||
+ """, (ip_address,))
|
||
+ conn.commit()
|
||
+ except Exception as e:
|
||
+ print(f"Reset attempts error: {e}")
|
||
+ conn.rollback()
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+def get_login_attempts(ip_address):
|
||
+ """Gibt die Anzahl der Login-Versuche für eine IP zurück"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT attempt_count FROM login_attempts
|
||
+ WHERE ip_address = %s
|
||
+ """, (ip_address,))
|
||
+
|
||
+ result = cur.fetchone()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return result[0] if result else 0
|
||
+
|
||
+def send_security_alert_email(ip_address, username, attempt_count):
|
||
+ """Sendet eine Sicherheitswarnung per E-Mail"""
|
||
+ subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche"
|
||
+ body = f"""
|
||
+ WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt!
|
||
+
|
||
+ IP-Adresse: {ip_address}
|
||
+ Versuchter Benutzername: {username}
|
||
+ Anzahl Versuche: {attempt_count}
|
||
+ Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')}
|
||
+
|
||
+ Die IP-Adresse wurde für 24 Stunden gesperrt.
|
||
+
|
||
+ Dies ist eine automatische Nachricht vom v2-Docker Admin Panel.
|
||
+ """
|
||
+
|
||
+ # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert
|
||
+ logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}")
|
||
+ print(f"E-Mail würde gesendet: {subject}")
|
||
+
|
||
+def verify_recaptcha(response):
|
||
+ """Verifiziert die reCAPTCHA v2 Response mit Google"""
|
||
+ secret_key = os.getenv('RECAPTCHA_SECRET_KEY')
|
||
+
|
||
+ # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC)
|
||
+ if not secret_key:
|
||
+ logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen")
|
||
+ return True
|
||
+
|
||
+ # Verifizierung bei Google
|
||
+ try:
|
||
+ verify_url = 'https://www.google.com/recaptcha/api/siteverify'
|
||
+ data = {
|
||
+ 'secret': secret_key,
|
||
+ 'response': response
|
||
+ }
|
||
+
|
||
+ # Timeout für Request setzen
|
||
+ r = requests.post(verify_url, data=data, timeout=5)
|
||
+ result = r.json()
|
||
+
|
||
+ # Log für Debugging
|
||
+ if not result.get('success'):
|
||
+ logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}")
|
||
+
|
||
+ return result.get('success', False)
|
||
+
|
||
+ except requests.exceptions.RequestException as e:
|
||
+ logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}")
|
||
+ # Bei Netzwerkfehlern CAPTCHA als bestanden werten
|
||
+ return True
|
||
+ except Exception as e:
|
||
+ logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}")
|
||
+ return False
|
||
+
|
||
+def generate_license_key(license_type='full'):
|
||
+ """
|
||
+ Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ
|
||
+
|
||
+ AF = Account Factory (Produktkennung)
|
||
+ F/T = F für Fullversion, T für Testversion
|
||
+ YYYY = Jahr
|
||
+ MM = Monat
|
||
+ XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen
|
||
+ """
|
||
+ # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l)
|
||
+ chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||
+
|
||
+ # Datum-Teil
|
||
+ now = datetime.now(ZoneInfo("Europe/Berlin"))
|
||
+ date_part = now.strftime('%Y%m')
|
||
+ type_char = 'F' if license_type == 'full' else 'T'
|
||
+
|
||
+ # Zufällige Teile generieren (3 Blöcke à 4 Zeichen)
|
||
+ parts = []
|
||
+ for _ in range(3):
|
||
+ part = ''.join(secrets.choice(chars) for _ in range(4))
|
||
+ parts.append(part)
|
||
+
|
||
+ # Key zusammensetzen
|
||
+ key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}"
|
||
+
|
||
+ return key
|
||
+
|
||
+def validate_license_key(key):
|
||
+ """
|
||
+ Validiert das License Key Format
|
||
+ Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ
|
||
+ """
|
||
+ if not key:
|
||
+ return False
|
||
+
|
||
+ # Pattern für das neue Format
|
||
+ # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen
|
||
+ pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||
+
|
||
+ # Großbuchstaben für Vergleich
|
||
+ return bool(re.match(pattern, key.upper()))
|
||
+
|
||
+@app.route("/login", methods=["GET", "POST"])
|
||
+def login():
|
||
+ # Timing-Attack Schutz - Start Zeit merken
|
||
+ start_time = time.time()
|
||
+
|
||
+ # IP-Adresse ermitteln
|
||
+ ip_address = get_client_ip()
|
||
+
|
||
+ # Prüfen ob IP gesperrt ist
|
||
+ is_blocked, blocked_until = check_ip_blocked(ip_address)
|
||
+ if is_blocked:
|
||
+ time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600
|
||
+ error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten."
|
||
+ return render_template("login.html", error=error_msg, error_type="blocked")
|
||
+
|
||
+ # Anzahl bisheriger Versuche
|
||
+ attempt_count = get_login_attempts(ip_address)
|
||
+
|
||
+ if request.method == "POST":
|
||
+ username = request.form.get("username")
|
||
+ password = request.form.get("password")
|
||
+ captcha_response = request.form.get("g-recaptcha-response")
|
||
+
|
||
+ # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind
|
||
+ recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY')
|
||
+ if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key:
|
||
+ if not captcha_response:
|
||
+ # Timing-Attack Schutz
|
||
+ elapsed = time.time() - start_time
|
||
+ if elapsed < 1.0:
|
||
+ time.sleep(1.0 - elapsed)
|
||
+ return render_template("login.html",
|
||
+ error="CAPTCHA ERFORDERLICH!",
|
||
+ show_captcha=True,
|
||
+ error_type="captcha",
|
||
+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
+ recaptcha_site_key=recaptcha_site_key)
|
||
+
|
||
+ # CAPTCHA validieren
|
||
+ if not verify_recaptcha(captcha_response):
|
||
+ # Timing-Attack Schutz
|
||
+ elapsed = time.time() - start_time
|
||
+ if elapsed < 1.0:
|
||
+ time.sleep(1.0 - elapsed)
|
||
+ return render_template("login.html",
|
||
+ error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.",
|
||
+ show_captcha=True,
|
||
+ error_type="captcha",
|
||
+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
+ recaptcha_site_key=recaptcha_site_key)
|
||
+
|
||
+ # Check user in database first, fallback to env vars
|
||
+ user = get_user_by_username(username)
|
||
+ login_success = False
|
||
+ needs_2fa = False
|
||
+
|
||
+ if user:
|
||
+ # Database user authentication
|
||
+ if verify_password(password, user['password_hash']):
|
||
+ login_success = True
|
||
+ needs_2fa = user['totp_enabled']
|
||
+ else:
|
||
+ # Fallback to environment variables for backward compatibility
|
||
+ admin1_user = os.getenv("ADMIN1_USERNAME")
|
||
+ admin1_pass = os.getenv("ADMIN1_PASSWORD")
|
||
+ admin2_user = os.getenv("ADMIN2_USERNAME")
|
||
+ admin2_pass = os.getenv("ADMIN2_PASSWORD")
|
||
+
|
||
+ if ((username == admin1_user and password == admin1_pass) or
|
||
+ (username == admin2_user and password == admin2_pass)):
|
||
+ login_success = True
|
||
+
|
||
+ # Timing-Attack Schutz - Mindestens 1 Sekunde warten
|
||
+ elapsed = time.time() - start_time
|
||
+ if elapsed < 1.0:
|
||
+ time.sleep(1.0 - elapsed)
|
||
+
|
||
+ if login_success:
|
||
+ # Erfolgreicher Login
|
||
+ if needs_2fa:
|
||
+ # Store temporary session for 2FA verification
|
||
+ session['temp_username'] = username
|
||
+ session['temp_user_id'] = user['id']
|
||
+ session['awaiting_2fa'] = True
|
||
+ return redirect(url_for('verify_2fa'))
|
||
+ else:
|
||
+ # Complete login without 2FA
|
||
+ session.permanent = True # Aktiviert das Timeout
|
||
+ session['logged_in'] = True
|
||
+ session['username'] = username
|
||
+ session['user_id'] = user['id'] if user else None
|
||
+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ reset_login_attempts(ip_address)
|
||
+ log_audit('LOGIN_SUCCESS', 'user',
|
||
+ additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}")
|
||
+ return redirect(url_for('dashboard'))
|
||
+ else:
|
||
+ # Fehlgeschlagener Login
|
||
+ error_message = record_failed_attempt(ip_address, username)
|
||
+ new_attempt_count = get_login_attempts(ip_address)
|
||
+
|
||
+ # Prüfen ob jetzt gesperrt
|
||
+ is_now_blocked, _ = check_ip_blocked(ip_address)
|
||
+ if is_now_blocked:
|
||
+ log_audit('LOGIN_BLOCKED', 'security',
|
||
+ additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt")
|
||
+
|
||
+ return render_template("login.html",
|
||
+ error=error_message,
|
||
+ show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')),
|
||
+ error_type="failed",
|
||
+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count),
|
||
+ recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY'))
|
||
+
|
||
+ # GET Request
|
||
+ return render_template("login.html",
|
||
+ show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')),
|
||
+ attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count),
|
||
+ recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY'))
|
||
+
|
||
+@app.route("/logout")
|
||
+def logout():
|
||
+ username = session.get('username', 'unknown')
|
||
+ log_audit('LOGOUT', 'user', additional_info=f"Abmeldung")
|
||
+ session.pop('logged_in', None)
|
||
+ session.pop('username', None)
|
||
+ session.pop('user_id', None)
|
||
+ session.pop('temp_username', None)
|
||
+ session.pop('temp_user_id', None)
|
||
+ session.pop('awaiting_2fa', None)
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+@app.route("/verify-2fa", methods=["GET", "POST"])
|
||
+def verify_2fa():
|
||
+ if not session.get('awaiting_2fa'):
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+ if request.method == "POST":
|
||
+ token = request.form.get('token', '').replace(' ', '')
|
||
+ username = session.get('temp_username')
|
||
+ user_id = session.get('temp_user_id')
|
||
+
|
||
+ if not username or not user_id:
|
||
+ flash('Session expired. Please login again.', 'error')
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+ user = get_user_by_username(username)
|
||
+ if not user:
|
||
+ flash('User not found.', 'error')
|
||
+ return redirect(url_for('login'))
|
||
+
|
||
+ # Check if it's a backup code
|
||
+ if len(token) == 8 and token.isupper():
|
||
+ # Try backup code
|
||
+ backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else []
|
||
+ if verify_backup_code(token, backup_codes):
|
||
+ # Remove used backup code
|
||
+ code_hash = hash_backup_code(token)
|
||
+ backup_codes.remove(code_hash)
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s",
|
||
+ (json.dumps(backup_codes), user_id))
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Complete login
|
||
+ session.permanent = True
|
||
+ session['logged_in'] = True
|
||
+ session['username'] = username
|
||
+ session['user_id'] = user_id
|
||
+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ session.pop('temp_username', None)
|
||
+ session.pop('temp_user_id', None)
|
||
+ session.pop('awaiting_2fa', None)
|
||
+
|
||
+ flash('Login successful using backup code. Please generate new backup codes.', 'warning')
|
||
+ log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code")
|
||
+ return redirect(url_for('dashboard'))
|
||
+ else:
|
||
+ # Try TOTP token
|
||
+ if verify_totp(user['totp_secret'], token):
|
||
+ # Complete login
|
||
+ session.permanent = True
|
||
+ session['logged_in'] = True
|
||
+ session['username'] = username
|
||
+ session['user_id'] = user_id
|
||
+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ session.pop('temp_username', None)
|
||
+ session.pop('temp_user_id', None)
|
||
+ session.pop('awaiting_2fa', None)
|
||
+
|
||
+ log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful")
|
||
+ return redirect(url_for('dashboard'))
|
||
+
|
||
+ # Failed verification
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s",
|
||
+ (datetime.now(), user_id))
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ flash('Invalid authentication code. Please try again.', 'error')
|
||
+ log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt")
|
||
+
|
||
+ return render_template('verify_2fa.html')
|
||
+
|
||
+@app.route("/profile")
|
||
+@login_required
|
||
+def profile():
|
||
+ user = get_user_by_username(session['username'])
|
||
+ if not user:
|
||
+ # For environment-based users, redirect with message
|
||
+ flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info')
|
||
+ return redirect(url_for('dashboard'))
|
||
+ return render_template('profile.html', user=user)
|
||
+
|
||
+@app.route("/profile/change-password", methods=["POST"])
|
||
+@login_required
|
||
+def change_password():
|
||
+ current_password = request.form.get('current_password')
|
||
+ new_password = request.form.get('new_password')
|
||
+ confirm_password = request.form.get('confirm_password')
|
||
+
|
||
+ user = get_user_by_username(session['username'])
|
||
+
|
||
+ # Verify current password
|
||
+ if not verify_password(current_password, user['password_hash']):
|
||
+ flash('Current password is incorrect.', 'error')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+ # Check new password
|
||
+ if new_password != confirm_password:
|
||
+ flash('New passwords do not match.', 'error')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+ if len(new_password) < 8:
|
||
+ flash('Password must be at least 8 characters long.', 'error')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+ # Update password
|
||
+ new_hash = hash_password(new_password)
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s",
|
||
+ (new_hash, datetime.now(), user['id']))
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'],
|
||
+ additional_info="Password changed successfully")
|
||
+ flash('Password changed successfully.', 'success')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+@app.route("/profile/setup-2fa")
|
||
+@login_required
|
||
+def setup_2fa():
|
||
+ user = get_user_by_username(session['username'])
|
||
+
|
||
+ if user['totp_enabled']:
|
||
+ flash('2FA is already enabled for your account.', 'info')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+ # Generate new TOTP secret
|
||
+ totp_secret = generate_totp_secret()
|
||
+ session['temp_totp_secret'] = totp_secret
|
||
+
|
||
+ # Generate QR code
|
||
+ qr_code = generate_qr_code(user['username'], totp_secret)
|
||
+
|
||
+ return render_template('setup_2fa.html',
|
||
+ totp_secret=totp_secret,
|
||
+ qr_code=qr_code)
|
||
+
|
||
+@app.route("/profile/enable-2fa", methods=["POST"])
|
||
+@login_required
|
||
+def enable_2fa():
|
||
+ token = request.form.get('token', '').replace(' ', '')
|
||
+ totp_secret = session.get('temp_totp_secret')
|
||
+
|
||
+ if not totp_secret:
|
||
+ flash('2FA setup session expired. Please try again.', 'error')
|
||
+ return redirect(url_for('setup_2fa'))
|
||
+
|
||
+ # Verify the token
|
||
+ if not verify_totp(totp_secret, token):
|
||
+ flash('Invalid authentication code. Please try again.', 'error')
|
||
+ return redirect(url_for('setup_2fa'))
|
||
+
|
||
+ # Generate backup codes
|
||
+ backup_codes = generate_backup_codes()
|
||
+ hashed_codes = [hash_backup_code(code) for code in backup_codes]
|
||
+
|
||
+ # Enable 2FA
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ cur.execute("""
|
||
+ UPDATE users
|
||
+ SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s
|
||
+ WHERE username = %s
|
||
+ """, (totp_secret, json.dumps(hashed_codes), session['username']))
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ session.pop('temp_totp_secret', None)
|
||
+
|
||
+ log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully")
|
||
+
|
||
+ # Show backup codes
|
||
+ return render_template('backup_codes.html', backup_codes=backup_codes)
|
||
+
|
||
+@app.route("/profile/disable-2fa", methods=["POST"])
|
||
+@login_required
|
||
+def disable_2fa():
|
||
+ password = request.form.get('password')
|
||
+ user = get_user_by_username(session['username'])
|
||
+
|
||
+ # Verify password
|
||
+ if not verify_password(password, user['password_hash']):
|
||
+ flash('Incorrect password.', 'error')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+ # Disable 2FA
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+ cur.execute("""
|
||
+ UPDATE users
|
||
+ SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL
|
||
+ WHERE username = %s
|
||
+ """, (session['username'],))
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled")
|
||
+ flash('2FA has been disabled for your account.', 'success')
|
||
+ return redirect(url_for('profile'))
|
||
+
|
||
+@app.route("/heartbeat", methods=['POST'])
|
||
+@login_required
|
||
+def heartbeat():
|
||
+ """Endpoint für Session Keep-Alive - aktualisiert last_activity"""
|
||
+ # Aktualisiere last_activity nur wenn explizit angefordert
|
||
+ session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ # Force session save
|
||
+ session.modified = True
|
||
+
|
||
+ return jsonify({
|
||
+ 'status': 'ok',
|
||
+ 'last_activity': session['last_activity'],
|
||
+ 'username': session.get('username')
|
||
+ })
|
||
+
|
||
+@app.route("/api/generate-license-key", methods=['POST'])
|
||
+@login_required
|
||
+def api_generate_key():
|
||
+ """API Endpoint zur Generierung eines neuen Lizenzschlüssels"""
|
||
+ try:
|
||
+ # Lizenztyp aus Request holen (default: full)
|
||
+ data = request.get_json() or {}
|
||
+ license_type = data.get('type', 'full')
|
||
+
|
||
+ # Key generieren
|
||
+ key = generate_license_key(license_type)
|
||
+
|
||
+ # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher)
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Wiederhole bis eindeutiger Key gefunden
|
||
+ attempts = 0
|
||
+ while attempts < 10: # Max 10 Versuche
|
||
+ cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,))
|
||
+ if not cur.fetchone():
|
||
+ break # Key ist eindeutig
|
||
+ key = generate_license_key(license_type)
|
||
+ attempts += 1
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Log für Audit
|
||
+ log_audit('GENERATE_KEY', 'license',
|
||
+ additional_info={'type': license_type, 'key': key})
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'key': key,
|
||
+ 'type': license_type
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Fehler bei Key-Generierung: {str(e)}")
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'error': 'Fehler bei der Key-Generierung'
|
||
+ }), 500
|
||
+
|
||
+@app.route("/api/customers", methods=['GET'])
|
||
+@login_required
|
||
+def api_customers():
|
||
+ """API Endpoint für die Kundensuche mit Select2"""
|
||
+ try:
|
||
+ # Suchparameter
|
||
+ search = request.args.get('q', '').strip()
|
||
+ page = request.args.get('page', 1, type=int)
|
||
+ per_page = 20
|
||
+ customer_id = request.args.get('id', type=int)
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Einzelnen Kunden per ID abrufen
|
||
+ if customer_id:
|
||
+ cur.execute("""
|
||
+ SELECT c.id, c.name, c.email,
|
||
+ COUNT(l.id) as license_count
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ WHERE c.id = %s
|
||
+ GROUP BY c.id, c.name, c.email
|
||
+ """, (customer_id,))
|
||
+
|
||
+ customer = cur.fetchone()
|
||
+ results = []
|
||
+ if customer:
|
||
+ results.append({
|
||
+ 'id': customer[0],
|
||
+ 'text': f"{customer[1]} ({customer[2]})",
|
||
+ 'name': customer[1],
|
||
+ 'email': customer[2],
|
||
+ 'license_count': customer[3]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'results': results,
|
||
+ 'pagination': {'more': False}
|
||
+ })
|
||
+
|
||
+ # SQL Query mit optionaler Suche
|
||
+ elif search:
|
||
+ cur.execute("""
|
||
+ SELECT c.id, c.name, c.email,
|
||
+ COUNT(l.id) as license_count
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ WHERE LOWER(c.name) LIKE LOWER(%s)
|
||
+ OR LOWER(c.email) LIKE LOWER(%s)
|
||
+ GROUP BY c.id, c.name, c.email
|
||
+ ORDER BY c.name
|
||
+ LIMIT %s OFFSET %s
|
||
+ """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page))
|
||
+ else:
|
||
+ cur.execute("""
|
||
+ SELECT c.id, c.name, c.email,
|
||
+ COUNT(l.id) as license_count
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ GROUP BY c.id, c.name, c.email
|
||
+ ORDER BY c.name
|
||
+ LIMIT %s OFFSET %s
|
||
+ """, (per_page, (page - 1) * per_page))
|
||
+
|
||
+ customers = cur.fetchall()
|
||
+
|
||
+ # Format für Select2
|
||
+ results = []
|
||
+ for customer in customers:
|
||
+ results.append({
|
||
+ 'id': customer[0],
|
||
+ 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)",
|
||
+ 'name': customer[1],
|
||
+ 'email': customer[2],
|
||
+ 'license_count': customer[3]
|
||
+ })
|
||
+
|
||
+ # Gesamtanzahl für Pagination
|
||
+ if search:
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM customers
|
||
+ WHERE LOWER(name) LIKE LOWER(%s)
|
||
+ OR LOWER(email) LIKE LOWER(%s)
|
||
+ """, (f'%{search}%', f'%{search}%'))
|
||
+ else:
|
||
+ cur.execute("SELECT COUNT(*) FROM customers")
|
||
+
|
||
+ total_count = cur.fetchone()[0]
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Select2 Response Format
|
||
+ return jsonify({
|
||
+ 'results': results,
|
||
+ 'pagination': {
|
||
+ 'more': (page * per_page) < total_count
|
||
+ }
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Fehler bei Kundensuche: {str(e)}")
|
||
+ return jsonify({
|
||
+ 'results': [],
|
||
+ 'error': 'Fehler bei der Kundensuche'
|
||
+ }), 500
|
||
+
|
||
+@app.route("/")
|
||
+@login_required
|
||
+def dashboard():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Statistiken abrufen
|
||
+ # Gesamtanzahl Kunden (ohne Testdaten)
|
||
+ cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE")
|
||
+ total_customers = cur.fetchone()[0]
|
||
+
|
||
+ # Gesamtanzahl Lizenzen (ohne Testdaten)
|
||
+ cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE")
|
||
+ total_licenses = cur.fetchone()[0]
|
||
+
|
||
+ # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM licenses
|
||
+ WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE
|
||
+ """)
|
||
+ active_licenses = cur.fetchone()[0]
|
||
+
|
||
+ # Aktive Sessions
|
||
+ cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE")
|
||
+ active_sessions_count = cur.fetchone()[0]
|
||
+
|
||
+ # Abgelaufene Lizenzen (ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM licenses
|
||
+ WHERE valid_until < CURRENT_DATE AND is_test = FALSE
|
||
+ """)
|
||
+ expired_licenses = cur.fetchone()[0]
|
||
+
|
||
+ # Deaktivierte Lizenzen (ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM licenses
|
||
+ WHERE is_active = FALSE AND is_test = FALSE
|
||
+ """)
|
||
+ inactive_licenses = cur.fetchone()[0]
|
||
+
|
||
+ # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM licenses
|
||
+ WHERE valid_until >= CURRENT_DATE
|
||
+ AND valid_until < CURRENT_DATE + INTERVAL '30 days'
|
||
+ AND is_active = TRUE
|
||
+ AND is_test = FALSE
|
||
+ """)
|
||
+ expiring_soon = cur.fetchone()[0]
|
||
+
|
||
+ # Testlizenzen vs Vollversionen (ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT license_type, COUNT(*)
|
||
+ FROM licenses
|
||
+ WHERE is_test = FALSE
|
||
+ GROUP BY license_type
|
||
+ """)
|
||
+ license_types = dict(cur.fetchall())
|
||
+
|
||
+ # Anzahl Testdaten
|
||
+ cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE")
|
||
+ test_data_count = cur.fetchone()[0]
|
||
+
|
||
+ # Anzahl Test-Kunden
|
||
+ cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE")
|
||
+ test_customers_count = cur.fetchone()[0]
|
||
+
|
||
+ # Anzahl Test-Ressourcen
|
||
+ cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE")
|
||
+ test_resources_count = cur.fetchone()[0]
|
||
+
|
||
+ # Letzte 5 erstellten Lizenzen (ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT l.id, l.license_key, c.name, l.valid_until,
|
||
+ CASE
|
||
+ WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
+ ELSE 'aktiv'
|
||
+ END as status
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE l.is_test = FALSE
|
||
+ ORDER BY l.id DESC
|
||
+ LIMIT 5
|
||
+ """)
|
||
+ recent_licenses = cur.fetchall()
|
||
+
|
||
+ # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT l.id, l.license_key, c.name, l.valid_until,
|
||
+ l.valid_until - CURRENT_DATE as days_left
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE l.valid_until >= CURRENT_DATE
|
||
+ AND l.valid_until < CURRENT_DATE + INTERVAL '30 days'
|
||
+ AND l.is_active = TRUE
|
||
+ AND l.is_test = FALSE
|
||
+ ORDER BY l.valid_until
|
||
+ LIMIT 10
|
||
+ """)
|
||
+ expiring_licenses = cur.fetchall()
|
||
+
|
||
+ # Letztes Backup
|
||
+ cur.execute("""
|
||
+ SELECT created_at, filesize, duration_seconds, backup_type, status
|
||
+ FROM backup_history
|
||
+ ORDER BY created_at DESC
|
||
+ LIMIT 1
|
||
+ """)
|
||
+ last_backup_info = cur.fetchone()
|
||
+
|
||
+ # Sicherheitsstatistiken
|
||
+ # Gesperrte IPs
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM login_attempts
|
||
+ WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP
|
||
+ """)
|
||
+ blocked_ips_count = cur.fetchone()[0]
|
||
+
|
||
+ # Fehlversuche heute
|
||
+ cur.execute("""
|
||
+ SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts
|
||
+ WHERE last_attempt::date = CURRENT_DATE
|
||
+ """)
|
||
+ failed_attempts_today = cur.fetchone()[0]
|
||
+
|
||
+ # Letzte 5 Sicherheitsereignisse
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ la.ip_address,
|
||
+ la.attempt_count,
|
||
+ la.last_attempt,
|
||
+ la.blocked_until,
|
||
+ la.last_username_tried,
|
||
+ la.last_error_message
|
||
+ FROM login_attempts la
|
||
+ ORDER BY la.last_attempt DESC
|
||
+ LIMIT 5
|
||
+ """)
|
||
+ recent_security_events = []
|
||
+ for event in cur.fetchall():
|
||
+ recent_security_events.append({
|
||
+ 'ip_address': event[0],
|
||
+ 'attempt_count': event[1],
|
||
+ 'last_attempt': event[2].strftime('%d.%m %H:%M'),
|
||
+ 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None,
|
||
+ 'username_tried': event[4],
|
||
+ 'error_message': event[5]
|
||
+ })
|
||
+
|
||
+ # Sicherheitslevel berechnen
|
||
+ if blocked_ips_count > 5 or failed_attempts_today > 50:
|
||
+ security_level = 'danger'
|
||
+ security_level_text = 'KRITISCH'
|
||
+ elif blocked_ips_count > 2 or failed_attempts_today > 20:
|
||
+ security_level = 'warning'
|
||
+ security_level_text = 'ERHÖHT'
|
||
+ else:
|
||
+ security_level = 'success'
|
||
+ security_level_text = 'NORMAL'
|
||
+
|
||
+ # Resource Pool Statistiken (nur Live-Daten, keine Testdaten)
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ resource_type,
|
||
+ COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
+ COUNT(*) as total
|
||
+ FROM resource_pools
|
||
+ WHERE is_test = FALSE
|
||
+ GROUP BY resource_type
|
||
+ """)
|
||
+
|
||
+ resource_stats = {}
|
||
+ resource_warning = None
|
||
+
|
||
+ for row in cur.fetchall():
|
||
+ available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1)
|
||
+ resource_stats[row[0]] = {
|
||
+ 'available': row[1],
|
||
+ 'allocated': row[2],
|
||
+ 'quarantine': row[3],
|
||
+ 'total': row[4],
|
||
+ 'available_percent': available_percent,
|
||
+ 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success'
|
||
+ }
|
||
+
|
||
+ # Warnung bei niedrigem Bestand
|
||
+ if row[1] < 50:
|
||
+ if not resource_warning:
|
||
+ resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!"
|
||
+ else:
|
||
+ resource_warning += f" | {row[0].upper()}: {row[1]}"
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ stats = {
|
||
+ 'total_customers': total_customers,
|
||
+ 'total_licenses': total_licenses,
|
||
+ 'active_licenses': active_licenses,
|
||
+ 'expired_licenses': expired_licenses,
|
||
+ 'inactive_licenses': inactive_licenses,
|
||
+ 'expiring_soon': expiring_soon,
|
||
+ 'full_licenses': license_types.get('full', 0),
|
||
+ 'test_licenses': license_types.get('test', 0),
|
||
+ 'test_data_count': test_data_count,
|
||
+ 'test_customers_count': test_customers_count,
|
||
+ 'test_resources_count': test_resources_count,
|
||
+ 'recent_licenses': recent_licenses,
|
||
+ 'expiring_licenses': expiring_licenses,
|
||
+ 'active_sessions': active_sessions_count,
|
||
+ 'last_backup': last_backup_info,
|
||
+ # Sicherheitsstatistiken
|
||
+ 'blocked_ips_count': blocked_ips_count,
|
||
+ 'failed_attempts_today': failed_attempts_today,
|
||
+ 'recent_security_events': recent_security_events,
|
||
+ 'security_level': security_level,
|
||
+ 'security_level_text': security_level_text,
|
||
+ 'resource_stats': resource_stats
|
||
+ }
|
||
+
|
||
+ return render_template("dashboard.html",
|
||
+ stats=stats,
|
||
+ resource_stats=resource_stats,
|
||
+ resource_warning=resource_warning,
|
||
+ username=session.get('username'))
|
||
+
|
||
+@app.route("/create", methods=["GET", "POST"])
|
||
+@login_required
|
||
+def create_license():
|
||
+ if request.method == "POST":
|
||
+ customer_id = request.form.get("customer_id")
|
||
+ license_key = request.form["license_key"].upper() # Immer Großbuchstaben
|
||
+ license_type = request.form["license_type"]
|
||
+ valid_from = request.form["valid_from"]
|
||
+ is_test = request.form.get("is_test") == "on" # Checkbox value
|
||
+
|
||
+ # Berechne valid_until basierend auf Laufzeit
|
||
+ duration = int(request.form.get("duration", 1))
|
||
+ duration_type = request.form.get("duration_type", "years")
|
||
+
|
||
+ from datetime import datetime, timedelta
|
||
+ from dateutil.relativedelta import relativedelta
|
||
+
|
||
+ start_date = datetime.strptime(valid_from, "%Y-%m-%d")
|
||
+
|
||
+ if duration_type == "days":
|
||
+ end_date = start_date + timedelta(days=duration)
|
||
+ elif duration_type == "months":
|
||
+ end_date = start_date + relativedelta(months=duration)
|
||
+ else: # years
|
||
+ end_date = start_date + relativedelta(years=duration)
|
||
+
|
||
+ # Ein Tag abziehen, da der Starttag mitgezählt wird
|
||
+ end_date = end_date - timedelta(days=1)
|
||
+ valid_until = end_date.strftime("%Y-%m-%d")
|
||
+
|
||
+ # Validiere License Key Format
|
||
+ if not validate_license_key(license_key):
|
||
+ flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error')
|
||
+ return redirect(url_for('create_license'))
|
||
+
|
||
+ # Resource counts
|
||
+ domain_count = int(request.form.get("domain_count", 1))
|
||
+ ipv4_count = int(request.form.get("ipv4_count", 1))
|
||
+ phone_count = int(request.form.get("phone_count", 1))
|
||
+ device_limit = int(request.form.get("device_limit", 3))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Prüfe ob neuer Kunde oder bestehender
|
||
+ if customer_id == "new":
|
||
+ # Neuer Kunde
|
||
+ name = request.form.get("customer_name")
|
||
+ email = request.form.get("email")
|
||
+
|
||
+ if not name:
|
||
+ flash('Kundenname ist erforderlich!', 'error')
|
||
+ return redirect(url_for('create_license'))
|
||
+
|
||
+ # Prüfe ob E-Mail bereits existiert
|
||
+ if email:
|
||
+ cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||
+ existing = cur.fetchone()
|
||
+ if existing:
|
||
+ flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||
+ return redirect(url_for('create_license'))
|
||
+
|
||
+ # Kunde einfügen (erbt Test-Status von Lizenz)
|
||
+ cur.execute("""
|
||
+ INSERT INTO customers (name, email, is_test, created_at)
|
||
+ VALUES (%s, %s, %s, NOW())
|
||
+ RETURNING id
|
||
+ """, (name, email, is_test))
|
||
+ customer_id = cur.fetchone()[0]
|
||
+ customer_info = {'name': name, 'email': email, 'is_test': is_test}
|
||
+
|
||
+ # Audit-Log für neuen Kunden
|
||
+ log_audit('CREATE', 'customer', customer_id,
|
||
+ new_values={'name': name, 'email': email, 'is_test': is_test})
|
||
+ else:
|
||
+ # Bestehender Kunde - hole Infos für Audit-Log
|
||
+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
+ customer_data = cur.fetchone()
|
||
+ if not customer_data:
|
||
+ flash('Kunde nicht gefunden!', 'error')
|
||
+ return redirect(url_for('create_license'))
|
||
+ customer_info = {'name': customer_data[0], 'email': customer_data[1]}
|
||
+
|
||
+ # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren
|
||
+ if customer_data[2]: # is_test des Kunden
|
||
+ is_test = True
|
||
+
|
||
+ # Lizenz hinzufügen
|
||
+ cur.execute("""
|
||
+ INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active,
|
||
+ domain_count, ipv4_count, phone_count, device_limit, is_test)
|
||
+ VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s)
|
||
+ RETURNING id
|
||
+ """, (license_key, customer_id, license_type, valid_from, valid_until,
|
||
+ domain_count, ipv4_count, phone_count, device_limit, is_test))
|
||
+ license_id = cur.fetchone()[0]
|
||
+
|
||
+ # Ressourcen zuweisen
|
||
+ try:
|
||
+ # Prüfe Verfügbarkeit
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains,
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s,
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones
|
||
+ """, (is_test, is_test, is_test))
|
||
+ available = cur.fetchone()
|
||
+
|
||
+ if available[0] < domain_count:
|
||
+ raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})")
|
||
+ if available[1] < ipv4_count:
|
||
+ raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})")
|
||
+ if available[2] < phone_count:
|
||
+ raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})")
|
||
+
|
||
+ # Domains zuweisen
|
||
+ if domain_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, domain_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ # IPv4s zuweisen
|
||
+ if ipv4_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, ipv4_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ # Telefonnummern zuweisen
|
||
+ if phone_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, phone_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ except ValueError as e:
|
||
+ conn.rollback()
|
||
+ flash(str(e), 'error')
|
||
+ return redirect(url_for('create_license'))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('CREATE', 'license', license_id,
|
||
+ new_values={
|
||
+ 'license_key': license_key,
|
||
+ 'customer_name': customer_info['name'],
|
||
+ 'customer_email': customer_info['email'],
|
||
+ 'license_type': license_type,
|
||
+ 'valid_from': valid_from,
|
||
+ 'valid_until': valid_until,
|
||
+ 'device_limit': device_limit,
|
||
+ 'is_test': is_test
|
||
+ })
|
||
+
|
||
+ flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success')
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}")
|
||
+ flash('Fehler beim Erstellen der Lizenz!', 'error')
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Preserve show_test parameter if present
|
||
+ redirect_url = "/create"
|
||
+ if request.args.get('show_test') == 'true':
|
||
+ redirect_url += "?show_test=true"
|
||
+ return redirect(redirect_url)
|
||
+
|
||
+ # Unterstützung für vorausgewählten Kunden
|
||
+ preselected_customer_id = request.args.get('customer_id', type=int)
|
||
+ return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id)
|
||
+
|
||
+@app.route("/batch", methods=["GET", "POST"])
|
||
+@login_required
|
||
+def batch_licenses():
|
||
+ """Batch-Generierung mehrerer Lizenzen für einen Kunden"""
|
||
+ if request.method == "POST":
|
||
+ # Formulardaten
|
||
+ customer_id = request.form.get("customer_id")
|
||
+ license_type = request.form["license_type"]
|
||
+ quantity = int(request.form["quantity"])
|
||
+ valid_from = request.form["valid_from"]
|
||
+ is_test = request.form.get("is_test") == "on" # Checkbox value
|
||
+
|
||
+ # Berechne valid_until basierend auf Laufzeit
|
||
+ duration = int(request.form.get("duration", 1))
|
||
+ duration_type = request.form.get("duration_type", "years")
|
||
+
|
||
+ from datetime import datetime, timedelta
|
||
+ from dateutil.relativedelta import relativedelta
|
||
+
|
||
+ start_date = datetime.strptime(valid_from, "%Y-%m-%d")
|
||
+
|
||
+ if duration_type == "days":
|
||
+ end_date = start_date + timedelta(days=duration)
|
||
+ elif duration_type == "months":
|
||
+ end_date = start_date + relativedelta(months=duration)
|
||
+ else: # years
|
||
+ end_date = start_date + relativedelta(years=duration)
|
||
+
|
||
+ # Ein Tag abziehen, da der Starttag mitgezählt wird
|
||
+ end_date = end_date - timedelta(days=1)
|
||
+ valid_until = end_date.strftime("%Y-%m-%d")
|
||
+
|
||
+ # Resource counts
|
||
+ domain_count = int(request.form.get("domain_count", 1))
|
||
+ ipv4_count = int(request.form.get("ipv4_count", 1))
|
||
+ phone_count = int(request.form.get("phone_count", 1))
|
||
+ device_limit = int(request.form.get("device_limit", 3))
|
||
+
|
||
+ # Sicherheitslimit
|
||
+ if quantity < 1 or quantity > 100:
|
||
+ flash('Anzahl muss zwischen 1 und 100 liegen!', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Prüfe ob neuer Kunde oder bestehender
|
||
+ if customer_id == "new":
|
||
+ # Neuer Kunde
|
||
+ name = request.form.get("customer_name")
|
||
+ email = request.form.get("email")
|
||
+
|
||
+ if not name:
|
||
+ flash('Kundenname ist erforderlich!', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+
|
||
+ # Prüfe ob E-Mail bereits existiert
|
||
+ if email:
|
||
+ cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||
+ existing = cur.fetchone()
|
||
+ if existing:
|
||
+ flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+
|
||
+ # Kunde einfügen (erbt Test-Status von Lizenz)
|
||
+ cur.execute("""
|
||
+ INSERT INTO customers (name, email, is_test, created_at)
|
||
+ VALUES (%s, %s, %s, NOW())
|
||
+ RETURNING id
|
||
+ """, (name, email, is_test))
|
||
+ customer_id = cur.fetchone()[0]
|
||
+
|
||
+ # Audit-Log für neuen Kunden
|
||
+ log_audit('CREATE', 'customer', customer_id,
|
||
+ new_values={'name': name, 'email': email, 'is_test': is_test})
|
||
+ else:
|
||
+ # Bestehender Kunde - hole Infos
|
||
+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
+ customer_data = cur.fetchone()
|
||
+ if not customer_data:
|
||
+ flash('Kunde nicht gefunden!', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+ name = customer_data[0]
|
||
+ email = customer_data[1]
|
||
+
|
||
+ # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren
|
||
+ if customer_data[2]: # is_test des Kunden
|
||
+ is_test = True
|
||
+
|
||
+ # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch
|
||
+ total_domains_needed = domain_count * quantity
|
||
+ total_ipv4s_needed = ipv4_count * quantity
|
||
+ total_phones_needed = phone_count * quantity
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains,
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s,
|
||
+ (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones
|
||
+ """, (is_test, is_test, is_test))
|
||
+ available = cur.fetchone()
|
||
+
|
||
+ if available[0] < total_domains_needed:
|
||
+ flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+ if available[1] < total_ipv4s_needed:
|
||
+ flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+ if available[2] < total_phones_needed:
|
||
+ flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+
|
||
+ # Lizenzen generieren und speichern
|
||
+ generated_licenses = []
|
||
+ for i in range(quantity):
|
||
+ # Eindeutigen Key generieren
|
||
+ attempts = 0
|
||
+ while attempts < 10:
|
||
+ license_key = generate_license_key(license_type)
|
||
+ cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,))
|
||
+ if not cur.fetchone():
|
||
+ break
|
||
+ attempts += 1
|
||
+
|
||
+ # Lizenz einfügen
|
||
+ cur.execute("""
|
||
+ INSERT INTO licenses (license_key, customer_id, license_type, is_test,
|
||
+ valid_from, valid_until, is_active,
|
||
+ domain_count, ipv4_count, phone_count, device_limit)
|
||
+ VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s)
|
||
+ RETURNING id
|
||
+ """, (license_key, customer_id, license_type, is_test, valid_from, valid_until,
|
||
+ domain_count, ipv4_count, phone_count, device_limit))
|
||
+ license_id = cur.fetchone()[0]
|
||
+
|
||
+ # Ressourcen für diese Lizenz zuweisen
|
||
+ # Domains
|
||
+ if domain_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, domain_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ # IPv4s
|
||
+ if ipv4_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, ipv4_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ # Telefonnummern
|
||
+ if phone_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id FROM resource_pools
|
||
+ WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (is_test, phone_count))
|
||
+ for (resource_id,) in cur.fetchall():
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated', allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], resource_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, resource_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (resource_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ generated_licenses.append({
|
||
+ 'id': license_id,
|
||
+ 'key': license_key,
|
||
+ 'type': license_type
|
||
+ })
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('CREATE_BATCH', 'license',
|
||
+ new_values={'customer': name, 'quantity': quantity, 'type': license_type},
|
||
+ additional_info=f"Batch-Generierung von {quantity} Lizenzen")
|
||
+
|
||
+ # Session für Export speichern
|
||
+ session['batch_export'] = {
|
||
+ 'customer': name,
|
||
+ 'email': email,
|
||
+ 'licenses': generated_licenses,
|
||
+ 'valid_from': valid_from,
|
||
+ 'valid_until': valid_until,
|
||
+ 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||
+ }
|
||
+
|
||
+ flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success')
|
||
+ return render_template("batch_result.html",
|
||
+ customer=name,
|
||
+ email=email,
|
||
+ licenses=generated_licenses,
|
||
+ valid_from=valid_from,
|
||
+ valid_until=valid_until)
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ logging.error(f"Fehler bei Batch-Generierung: {str(e)}")
|
||
+ flash('Fehler bei der Batch-Generierung!', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # GET Request
|
||
+ return render_template("batch_form.html")
|
||
+
|
||
+@app.route("/batch/export")
|
||
+@login_required
|
||
+def export_batch():
|
||
+ """Exportiert die zuletzt generierten Batch-Lizenzen"""
|
||
+ batch_data = session.get('batch_export')
|
||
+ if not batch_data:
|
||
+ flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error')
|
||
+ return redirect(url_for('batch_licenses'))
|
||
+
|
||
+ # CSV generieren
|
||
+ output = io.StringIO()
|
||
+ output.write('\ufeff') # UTF-8 BOM für Excel
|
||
+
|
||
+ # Header
|
||
+ output.write(f"Kunde: {batch_data['customer']}\n")
|
||
+ output.write(f"E-Mail: {batch_data['email']}\n")
|
||
+ output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n")
|
||
+ output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n")
|
||
+ output.write("\n")
|
||
+ output.write("Nr;Lizenzschlüssel;Typ\n")
|
||
+
|
||
+ # Lizenzen
|
||
+ for i, license in enumerate(batch_data['licenses'], 1):
|
||
+ typ_text = "Vollversion" if license['type'] == 'full' else "Testversion"
|
||
+ output.write(f"{i};{license['key']};{typ_text}\n")
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('EXPORT', 'batch_licenses',
|
||
+ additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen")
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
+ mimetype='text/csv',
|
||
+ as_attachment=True,
|
||
+ download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv"
|
||
+ )
|
||
+
|
||
+@app.route("/licenses")
|
||
+@login_required
|
||
+def licenses():
|
||
+ # Redirect zur kombinierten Ansicht
|
||
+ return redirect("/customers-licenses")
|
||
+
|
||
+@app.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
|
||
+@login_required
|
||
+def edit_license(license_id):
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ if request.method == "POST":
|
||
+ # Alte Werte für Audit-Log abrufen
|
||
+ cur.execute("""
|
||
+ SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit
|
||
+ FROM licenses WHERE id = %s
|
||
+ """, (license_id,))
|
||
+ old_license = cur.fetchone()
|
||
+
|
||
+ # Update license
|
||
+ license_key = request.form["license_key"]
|
||
+ license_type = request.form["license_type"]
|
||
+ valid_from = request.form["valid_from"]
|
||
+ valid_until = request.form["valid_until"]
|
||
+ is_active = request.form.get("is_active") == "on"
|
||
+ is_test = request.form.get("is_test") == "on"
|
||
+ device_limit = int(request.form.get("device_limit", 3))
|
||
+
|
||
+ cur.execute("""
|
||
+ UPDATE licenses
|
||
+ SET license_key = %s, license_type = %s, valid_from = %s,
|
||
+ valid_until = %s, is_active = %s, is_test = %s, device_limit = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('UPDATE', 'license', license_id,
|
||
+ old_values={
|
||
+ 'license_key': old_license[0],
|
||
+ 'license_type': old_license[1],
|
||
+ 'valid_from': str(old_license[2]),
|
||
+ 'valid_until': str(old_license[3]),
|
||
+ 'is_active': old_license[4],
|
||
+ 'is_test': old_license[5],
|
||
+ 'device_limit': old_license[6]
|
||
+ },
|
||
+ new_values={
|
||
+ 'license_key': license_key,
|
||
+ 'license_type': license_type,
|
||
+ 'valid_from': valid_from,
|
||
+ 'valid_until': valid_until,
|
||
+ 'is_active': is_active,
|
||
+ 'is_test': is_test,
|
||
+ 'device_limit': device_limit
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Redirect zurück zu customers-licenses mit beibehaltenen Parametern
|
||
+ redirect_url = "/customers-licenses"
|
||
+
|
||
+ # Behalte show_test Parameter bei (aus Form oder GET-Parameter)
|
||
+ show_test = request.form.get('show_test') or request.args.get('show_test')
|
||
+ if show_test == 'true':
|
||
+ redirect_url += "?show_test=true"
|
||
+
|
||
+ # Behalte customer_id bei wenn vorhanden
|
||
+ if request.referrer and 'customer_id=' in request.referrer:
|
||
+ import re
|
||
+ match = re.search(r'customer_id=(\d+)', request.referrer)
|
||
+ if match:
|
||
+ connector = "&" if "?" in redirect_url else "?"
|
||
+ redirect_url += f"{connector}customer_id={match.group(1)}"
|
||
+
|
||
+ return redirect(redirect_url)
|
||
+
|
||
+ # Get license data
|
||
+ cur.execute("""
|
||
+ SELECT l.id, l.license_key, c.name, c.email, l.license_type,
|
||
+ l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE l.id = %s
|
||
+ """, (license_id,))
|
||
+
|
||
+ license = cur.fetchone()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ if not license:
|
||
+ return redirect("/licenses")
|
||
+
|
||
+ return render_template("edit_license.html", license=license, username=session.get('username'))
|
||
+
|
||
+@app.route("/license/delete/<int:license_id>", methods=["POST"])
|
||
+@login_required
|
||
+def delete_license(license_id):
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Lizenzdetails für Audit-Log abrufen
|
||
+ cur.execute("""
|
||
+ SELECT l.license_key, c.name, l.license_type
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE l.id = %s
|
||
+ """, (license_id,))
|
||
+ license_info = cur.fetchone()
|
||
+
|
||
+ cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ if license_info:
|
||
+ log_audit('DELETE', 'license', license_id,
|
||
+ old_values={
|
||
+ 'license_key': license_info[0],
|
||
+ 'customer_name': license_info[1],
|
||
+ 'license_type': license_info[2]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return redirect("/licenses")
|
||
+
|
||
+@app.route("/customers")
|
||
+@login_required
|
||
+def customers():
|
||
+ # Redirect zur kombinierten Ansicht
|
||
+ return redirect("/customers-licenses")
|
||
+
|
||
+@app.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
|
||
+@login_required
|
||
+def edit_customer(customer_id):
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ if request.method == "POST":
|
||
+ # Alte Werte für Audit-Log abrufen
|
||
+ cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||
+ old_customer = cur.fetchone()
|
||
+
|
||
+ # Update customer
|
||
+ name = request.form["name"]
|
||
+ email = request.form["email"]
|
||
+ is_test = request.form.get("is_test") == "on"
|
||
+
|
||
+ cur.execute("""
|
||
+ UPDATE customers
|
||
+ SET name = %s, email = %s, is_test = %s
|
||
+ WHERE id = %s
|
||
+ """, (name, email, is_test, customer_id))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('UPDATE', 'customer', customer_id,
|
||
+ old_values={
|
||
+ 'name': old_customer[0],
|
||
+ 'email': old_customer[1],
|
||
+ 'is_test': old_customer[2]
|
||
+ },
|
||
+ new_values={
|
||
+ 'name': name,
|
||
+ 'email': email,
|
||
+ 'is_test': is_test
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Redirect zurück zu customers-licenses mit beibehaltenen Parametern
|
||
+ redirect_url = "/customers-licenses"
|
||
+
|
||
+ # Behalte show_test Parameter bei (aus Form oder GET-Parameter)
|
||
+ show_test = request.form.get('show_test') or request.args.get('show_test')
|
||
+ if show_test == 'true':
|
||
+ redirect_url += "?show_test=true"
|
||
+
|
||
+ # Behalte customer_id bei (immer der aktuelle Kunde)
|
||
+ connector = "&" if "?" in redirect_url else "?"
|
||
+ redirect_url += f"{connector}customer_id={customer_id}"
|
||
+
|
||
+ return redirect(redirect_url)
|
||
+
|
||
+ # Get customer data with licenses
|
||
+ cur.execute("""
|
||
+ SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s
|
||
+ """, (customer_id,))
|
||
+
|
||
+ customer = cur.fetchone()
|
||
+ if not customer:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+ return "Kunde nicht gefunden", 404
|
||
+
|
||
+
|
||
+ # Get customer's licenses
|
||
+ cur.execute("""
|
||
+ SELECT id, license_key, license_type, valid_from, valid_until, is_active
|
||
+ FROM licenses
|
||
+ WHERE customer_id = %s
|
||
+ ORDER BY valid_until DESC
|
||
+ """, (customer_id,))
|
||
+
|
||
+ licenses = cur.fetchall()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ if not customer:
|
||
+ return redirect("/customers-licenses")
|
||
+
|
||
+ return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username'))
|
||
+
|
||
+@app.route("/customer/create", methods=["GET", "POST"])
|
||
+@login_required
|
||
+def create_customer():
|
||
+ """Erstellt einen neuen Kunden ohne Lizenz"""
|
||
+ if request.method == "POST":
|
||
+ name = request.form.get('name')
|
||
+ email = request.form.get('email')
|
||
+ is_test = request.form.get('is_test') == 'on'
|
||
+
|
||
+ if not name or not email:
|
||
+ flash("Name und E-Mail sind Pflichtfelder!", "error")
|
||
+ return render_template("create_customer.html", username=session.get('username'))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Prüfen ob E-Mail bereits existiert
|
||
+ cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,))
|
||
+ existing = cur.fetchone()
|
||
+ if existing:
|
||
+ flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error")
|
||
+ return render_template("create_customer.html", username=session.get('username'))
|
||
+
|
||
+ # Kunde erstellen
|
||
+ cur.execute("""
|
||
+ INSERT INTO customers (name, email, created_at, is_test)
|
||
+ VALUES (%s, %s, %s, %s) RETURNING id
|
||
+ """, (name, email, datetime.now(), is_test))
|
||
+
|
||
+ customer_id = cur.fetchone()[0]
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('CREATE', 'customer', customer_id,
|
||
+ new_values={
|
||
+ 'name': name,
|
||
+ 'email': email,
|
||
+ 'is_test': is_test
|
||
+ })
|
||
+
|
||
+ flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success")
|
||
+ return redirect(f"/customer/edit/{customer_id}")
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error")
|
||
+ return render_template("create_customer.html", username=session.get('username'))
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # GET Request - Formular anzeigen
|
||
+ return render_template("create_customer.html", username=session.get('username'))
|
||
+
|
||
+@app.route("/customer/delete/<int:customer_id>", methods=["POST"])
|
||
+@login_required
|
||
+def delete_customer(customer_id):
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Prüfen ob Kunde Lizenzen hat
|
||
+ cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,))
|
||
+ license_count = cur.fetchone()[0]
|
||
+
|
||
+ if license_count > 0:
|
||
+ # Kunde hat Lizenzen - nicht löschen
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+ return redirect("/customers")
|
||
+
|
||
+ # Kundendetails für Audit-Log abrufen
|
||
+ cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,))
|
||
+ customer_info = cur.fetchone()
|
||
+
|
||
+ # Kunde löschen wenn keine Lizenzen vorhanden
|
||
+ cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ if customer_info:
|
||
+ log_audit('DELETE', 'customer', customer_id,
|
||
+ old_values={
|
||
+ 'name': customer_info[0],
|
||
+ 'email': customer_info[1]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return redirect("/customers")
|
||
+
|
||
+@app.route("/customers-licenses")
|
||
+@login_required
|
||
+def customers_licenses():
|
||
+ """Kombinierte Ansicht für Kunden und deren Lizenzen"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht)
|
||
+ show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
+
|
||
+ query = """
|
||
+ SELECT
|
||
+ c.id,
|
||
+ c.name,
|
||
+ c.email,
|
||
+ c.created_at,
|
||
+ COUNT(l.id) as total_licenses,
|
||
+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ """
|
||
+
|
||
+ if not show_test:
|
||
+ query += " WHERE c.is_test = FALSE"
|
||
+
|
||
+ query += """
|
||
+ GROUP BY c.id, c.name, c.email, c.created_at
|
||
+ ORDER BY c.name
|
||
+ """
|
||
+
|
||
+ cur.execute(query)
|
||
+ customers = cur.fetchall()
|
||
+
|
||
+ # Hole ausgewählten Kunden nur wenn explizit in URL angegeben
|
||
+ selected_customer_id = request.args.get('customer_id', type=int)
|
||
+ licenses = []
|
||
+ selected_customer = None
|
||
+
|
||
+ if customers and selected_customer_id:
|
||
+ # Hole Daten des ausgewählten Kunden
|
||
+ for customer in customers:
|
||
+ if customer[0] == selected_customer_id:
|
||
+ selected_customer = customer
|
||
+ break
|
||
+
|
||
+ # Hole Lizenzen des ausgewählten Kunden
|
||
+ if selected_customer:
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ l.id,
|
||
+ l.license_key,
|
||
+ l.license_type,
|
||
+ l.valid_from,
|
||
+ l.valid_until,
|
||
+ l.is_active,
|
||
+ CASE
|
||
+ WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
+ ELSE 'aktiv'
|
||
+ END as status,
|
||
+ l.domain_count,
|
||
+ l.ipv4_count,
|
||
+ l.phone_count,
|
||
+ l.device_limit,
|
||
+ (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices,
|
||
+ -- Actual resource counts
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count,
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count,
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count
|
||
+ FROM licenses l
|
||
+ WHERE l.customer_id = %s
|
||
+ ORDER BY l.created_at DESC, l.id DESC
|
||
+ """, (selected_customer_id,))
|
||
+ licenses = cur.fetchall()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template("customers_licenses.html",
|
||
+ customers=customers,
|
||
+ selected_customer=selected_customer,
|
||
+ selected_customer_id=selected_customer_id,
|
||
+ licenses=licenses,
|
||
+ show_test=show_test)
|
||
+
|
||
+@app.route("/api/customer/<int:customer_id>/licenses")
|
||
+@login_required
|
||
+def api_customer_licenses(customer_id):
|
||
+ """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Hole Lizenzen des Kunden
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ l.id,
|
||
+ l.license_key,
|
||
+ l.license_type,
|
||
+ l.valid_from,
|
||
+ l.valid_until,
|
||
+ l.is_active,
|
||
+ CASE
|
||
+ WHEN l.is_active = FALSE THEN 'deaktiviert'
|
||
+ WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen'
|
||
+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab'
|
||
+ ELSE 'aktiv'
|
||
+ END as status,
|
||
+ l.domain_count,
|
||
+ l.ipv4_count,
|
||
+ l.phone_count,
|
||
+ l.device_limit,
|
||
+ (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices,
|
||
+ -- Actual resource counts
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count,
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count,
|
||
+ (SELECT COUNT(*) FROM license_resources lr
|
||
+ JOIN resource_pools rp ON lr.resource_id = rp.id
|
||
+ WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count
|
||
+ FROM licenses l
|
||
+ WHERE l.customer_id = %s
|
||
+ ORDER BY l.created_at DESC, l.id DESC
|
||
+ """, (customer_id,))
|
||
+
|
||
+ licenses = []
|
||
+ for row in cur.fetchall():
|
||
+ license_id = row[0]
|
||
+
|
||
+ # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz
|
||
+ cur.execute("""
|
||
+ SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at
|
||
+ FROM resource_pools rp
|
||
+ JOIN license_resources lr ON rp.id = lr.resource_id
|
||
+ WHERE lr.license_id = %s AND lr.is_active = true
|
||
+ ORDER BY rp.resource_type, rp.resource_value
|
||
+ """, (license_id,))
|
||
+
|
||
+ resources = {
|
||
+ 'domains': [],
|
||
+ 'ipv4s': [],
|
||
+ 'phones': []
|
||
+ }
|
||
+
|
||
+ for res_row in cur.fetchall():
|
||
+ resource_info = {
|
||
+ 'id': res_row[0],
|
||
+ 'value': res_row[2],
|
||
+ 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else ''
|
||
+ }
|
||
+
|
||
+ if res_row[1] == 'domain':
|
||
+ resources['domains'].append(resource_info)
|
||
+ elif res_row[1] == 'ipv4':
|
||
+ resources['ipv4s'].append(resource_info)
|
||
+ elif res_row[1] == 'phone':
|
||
+ resources['phones'].append(resource_info)
|
||
+
|
||
+ licenses.append({
|
||
+ 'id': row[0],
|
||
+ 'license_key': row[1],
|
||
+ 'license_type': row[2],
|
||
+ 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '',
|
||
+ 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '',
|
||
+ 'is_active': row[5],
|
||
+ 'status': row[6],
|
||
+ 'domain_count': row[7], # limit
|
||
+ 'ipv4_count': row[8], # limit
|
||
+ 'phone_count': row[9], # limit
|
||
+ 'device_limit': row[10],
|
||
+ 'active_devices': row[11],
|
||
+ 'actual_domain_count': row[12], # actual count
|
||
+ 'actual_ipv4_count': row[13], # actual count
|
||
+ 'actual_phone_count': row[14], # actual count
|
||
+ 'resources': resources
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'licenses': licenses,
|
||
+ 'count': len(licenses)
|
||
+ })
|
||
+
|
||
+@app.route("/api/customer/<int:customer_id>/quick-stats")
|
||
+@login_required
|
||
+def api_customer_quick_stats(customer_id):
|
||
+ """API-Endpoint für Schnellstatistiken eines Kunden"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Hole Kundenstatistiken
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ COUNT(l.id) as total_licenses,
|
||
+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses,
|
||
+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon
|
||
+ FROM licenses l
|
||
+ WHERE l.customer_id = %s
|
||
+ """, (customer_id,))
|
||
+
|
||
+ stats = cur.fetchone()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'stats': {
|
||
+ 'total': stats[0],
|
||
+ 'active': stats[1],
|
||
+ 'expired': stats[2],
|
||
+ 'expiring_soon': stats[3]
|
||
+ }
|
||
+ })
|
||
+
|
||
+@app.route("/api/license/<int:license_id>/quick-edit", methods=['POST'])
|
||
+@login_required
|
||
+def api_license_quick_edit(license_id):
|
||
+ """API-Endpoint für schnelle Lizenz-Bearbeitung"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+
|
||
+ # Hole alte Werte für Audit-Log
|
||
+ cur.execute("""
|
||
+ SELECT is_active, valid_until, license_type
|
||
+ FROM licenses WHERE id = %s
|
||
+ """, (license_id,))
|
||
+ old_values = cur.fetchone()
|
||
+
|
||
+ if not old_values:
|
||
+ return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404
|
||
+
|
||
+ # Update-Felder vorbereiten
|
||
+ updates = []
|
||
+ params = []
|
||
+ new_values = {}
|
||
+
|
||
+ if 'is_active' in data:
|
||
+ updates.append("is_active = %s")
|
||
+ params.append(data['is_active'])
|
||
+ new_values['is_active'] = data['is_active']
|
||
+
|
||
+ if 'valid_until' in data:
|
||
+ updates.append("valid_until = %s")
|
||
+ params.append(data['valid_until'])
|
||
+ new_values['valid_until'] = data['valid_until']
|
||
+
|
||
+ if 'license_type' in data:
|
||
+ updates.append("license_type = %s")
|
||
+ params.append(data['license_type'])
|
||
+ new_values['license_type'] = data['license_type']
|
||
+
|
||
+ if updates:
|
||
+ params.append(license_id)
|
||
+ cur.execute(f"""
|
||
+ UPDATE licenses
|
||
+ SET {', '.join(updates)}
|
||
+ WHERE id = %s
|
||
+ """, params)
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('UPDATE', 'license', license_id,
|
||
+ old_values={
|
||
+ 'is_active': old_values[0],
|
||
+ 'valid_until': old_values[1].isoformat() if old_values[1] else None,
|
||
+ 'license_type': old_values[2]
|
||
+ },
|
||
+ new_values=new_values)
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True})
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+ return jsonify({'success': False, 'error': str(e)}), 500
|
||
+
|
||
+@app.route("/api/license/<int:license_id>/resources")
|
||
+@login_required
|
||
+def api_license_resources(license_id):
|
||
+ """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz
|
||
+ cur.execute("""
|
||
+ SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at
|
||
+ FROM resource_pools rp
|
||
+ JOIN license_resources lr ON rp.id = lr.resource_id
|
||
+ WHERE lr.license_id = %s AND lr.is_active = true
|
||
+ ORDER BY rp.resource_type, rp.resource_value
|
||
+ """, (license_id,))
|
||
+
|
||
+ resources = {
|
||
+ 'domains': [],
|
||
+ 'ipv4s': [],
|
||
+ 'phones': []
|
||
+ }
|
||
+
|
||
+ for row in cur.fetchall():
|
||
+ resource_info = {
|
||
+ 'id': row[0],
|
||
+ 'value': row[2],
|
||
+ 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else ''
|
||
+ }
|
||
+
|
||
+ if row[1] == 'domain':
|
||
+ resources['domains'].append(resource_info)
|
||
+ elif row[1] == 'ipv4':
|
||
+ resources['ipv4s'].append(resource_info)
|
||
+ elif row[1] == 'phone':
|
||
+ resources['phones'].append(resource_info)
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'resources': resources
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+ return jsonify({'success': False, 'error': str(e)}), 500
|
||
+
|
||
+@app.route("/sessions")
|
||
+@login_required
|
||
+def sessions():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Sortierparameter
|
||
+ active_sort = request.args.get('active_sort', 'last_heartbeat')
|
||
+ active_order = request.args.get('active_order', 'desc')
|
||
+ ended_sort = request.args.get('ended_sort', 'ended_at')
|
||
+ ended_order = request.args.get('ended_order', 'desc')
|
||
+
|
||
+ # Whitelist für erlaubte Sortierfelder - Aktive Sessions
|
||
+ active_sort_fields = {
|
||
+ 'customer': 'c.name',
|
||
+ 'license': 'l.license_key',
|
||
+ 'ip': 's.ip_address',
|
||
+ 'started': 's.started_at',
|
||
+ 'last_heartbeat': 's.last_heartbeat',
|
||
+ 'inactive': 'minutes_inactive'
|
||
+ }
|
||
+
|
||
+ # Whitelist für erlaubte Sortierfelder - Beendete Sessions
|
||
+ ended_sort_fields = {
|
||
+ 'customer': 'c.name',
|
||
+ 'license': 'l.license_key',
|
||
+ 'ip': 's.ip_address',
|
||
+ 'started': 's.started_at',
|
||
+ 'ended_at': 's.ended_at',
|
||
+ 'duration': 'duration_minutes'
|
||
+ }
|
||
+
|
||
+ # Validierung
|
||
+ if active_sort not in active_sort_fields:
|
||
+ active_sort = 'last_heartbeat'
|
||
+ if ended_sort not in ended_sort_fields:
|
||
+ ended_sort = 'ended_at'
|
||
+ if active_order not in ['asc', 'desc']:
|
||
+ active_order = 'desc'
|
||
+ if ended_order not in ['asc', 'desc']:
|
||
+ ended_order = 'desc'
|
||
+
|
||
+ # Aktive Sessions abrufen
|
||
+ cur.execute(f"""
|
||
+ SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address,
|
||
+ s.user_agent, s.started_at, s.last_heartbeat,
|
||
+ EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive
|
||
+ FROM sessions s
|
||
+ JOIN licenses l ON s.license_id = l.id
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE s.is_active = TRUE
|
||
+ ORDER BY {active_sort_fields[active_sort]} {active_order.upper()}
|
||
+ """)
|
||
+ active_sessions = cur.fetchall()
|
||
+
|
||
+ # Inaktive Sessions der letzten 24 Stunden
|
||
+ cur.execute(f"""
|
||
+ SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address,
|
||
+ s.started_at, s.ended_at,
|
||
+ EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes
|
||
+ FROM sessions s
|
||
+ JOIN licenses l ON s.license_id = l.id
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE s.is_active = FALSE
|
||
+ AND s.ended_at > NOW() - INTERVAL '24 hours'
|
||
+ ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()}
|
||
+ LIMIT 50
|
||
+ """)
|
||
+ recent_sessions = cur.fetchall()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template("sessions.html",
|
||
+ active_sessions=active_sessions,
|
||
+ recent_sessions=recent_sessions,
|
||
+ active_sort=active_sort,
|
||
+ active_order=active_order,
|
||
+ ended_sort=ended_sort,
|
||
+ ended_order=ended_order,
|
||
+ username=session.get('username'))
|
||
+
|
||
+@app.route("/session/end/<int:session_id>", methods=["POST"])
|
||
+@login_required
|
||
+def end_session(session_id):
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Session beenden
|
||
+ cur.execute("""
|
||
+ UPDATE sessions
|
||
+ SET is_active = FALSE, ended_at = NOW()
|
||
+ WHERE id = %s AND is_active = TRUE
|
||
+ """, (session_id,))
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return redirect("/sessions")
|
||
+
|
||
+@app.route("/export/licenses")
|
||
+@login_required
|
||
+def export_licenses():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht)
|
||
+ include_test = request.args.get('include_test', 'false').lower() == 'true'
|
||
+ customer_id = request.args.get('customer_id', type=int)
|
||
+
|
||
+ query = """
|
||
+ SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email,
|
||
+ l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test,
|
||
+ CASE
|
||
+ WHEN l.is_active = FALSE THEN 'Deaktiviert'
|
||
+ WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen'
|
||
+ WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab'
|
||
+ ELSE 'Aktiv'
|
||
+ END as status
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ """
|
||
+
|
||
+ # Build WHERE clause
|
||
+ where_conditions = []
|
||
+ params = []
|
||
+
|
||
+ if not include_test:
|
||
+ where_conditions.append("l.is_test = FALSE")
|
||
+
|
||
+ if customer_id:
|
||
+ where_conditions.append("l.customer_id = %s")
|
||
+ params.append(customer_id)
|
||
+
|
||
+ if where_conditions:
|
||
+ query += " WHERE " + " AND ".join(where_conditions)
|
||
+
|
||
+ query += " ORDER BY l.id"
|
||
+
|
||
+ cur.execute(query, params)
|
||
+
|
||
+ # Spaltennamen
|
||
+ columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ',
|
||
+ 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status']
|
||
+
|
||
+ # Daten in DataFrame
|
||
+ data = cur.fetchall()
|
||
+ df = pd.DataFrame(data, columns=columns)
|
||
+
|
||
+ # Datumsformatierung
|
||
+ df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y')
|
||
+ df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y')
|
||
+
|
||
+ # Typ und Aktiv Status anpassen
|
||
+ df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'})
|
||
+ df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'})
|
||
+ df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'})
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Export Format
|
||
+ export_format = request.args.get('format', 'excel')
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('EXPORT', 'license',
|
||
+ additional_info=f"Export aller Lizenzen als {export_format.upper()}")
|
||
+ filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}'
|
||
+
|
||
+ if export_format == 'csv':
|
||
+ # CSV Export
|
||
+ output = io.StringIO()
|
||
+ df.to_csv(output, index=False, encoding='utf-8-sig', sep=';')
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
+ mimetype='text/csv',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv'
|
||
+ )
|
||
+ else:
|
||
+ # Excel Export
|
||
+ output = io.BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, sheet_name='Lizenzen', index=False)
|
||
+
|
||
+ # Formatierung
|
||
+ worksheet = writer.sheets['Lizenzen']
|
||
+ for column in worksheet.columns:
|
||
+ max_length = 0
|
||
+ column_letter = column[0].column_letter
|
||
+ for cell in column:
|
||
+ try:
|
||
+ if len(str(cell.value)) > max_length:
|
||
+ max_length = len(str(cell.value))
|
||
+ except:
|
||
+ pass
|
||
+ adjusted_width = min(max_length + 2, 50)
|
||
+ worksheet.column_dimensions[column_letter].width = adjusted_width
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx'
|
||
+ )
|
||
+
|
||
+@app.route("/export/audit")
|
||
+@login_required
|
||
+def export_audit():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Holen der Filter-Parameter
|
||
+ filter_user = request.args.get('user', '')
|
||
+ filter_action = request.args.get('action', '')
|
||
+ filter_entity = request.args.get('entity', '')
|
||
+ export_format = request.args.get('format', 'excel')
|
||
+
|
||
+ # SQL Query mit Filtern
|
||
+ query = """
|
||
+ SELECT id, timestamp, username, action, entity_type, entity_id,
|
||
+ old_values, new_values, ip_address, user_agent, additional_info
|
||
+ FROM audit_log
|
||
+ WHERE 1=1
|
||
+ """
|
||
+ params = []
|
||
+
|
||
+ if filter_user:
|
||
+ query += " AND username ILIKE %s"
|
||
+ params.append(f'%{filter_user}%')
|
||
+
|
||
+ if filter_action:
|
||
+ query += " AND action = %s"
|
||
+ params.append(filter_action)
|
||
+
|
||
+ if filter_entity:
|
||
+ query += " AND entity_type = %s"
|
||
+ params.append(filter_entity)
|
||
+
|
||
+ query += " ORDER BY timestamp DESC"
|
||
+
|
||
+ cur.execute(query, params)
|
||
+ audit_logs = cur.fetchall()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Daten für Export vorbereiten
|
||
+ data = []
|
||
+ for log in audit_logs:
|
||
+ action_text = {
|
||
+ 'CREATE': 'Erstellt',
|
||
+ 'UPDATE': 'Bearbeitet',
|
||
+ 'DELETE': 'Gelöscht',
|
||
+ 'LOGIN': 'Anmeldung',
|
||
+ 'LOGOUT': 'Abmeldung',
|
||
+ 'AUTO_LOGOUT': 'Auto-Logout',
|
||
+ 'EXPORT': 'Export',
|
||
+ 'GENERATE_KEY': 'Key generiert',
|
||
+ 'CREATE_BATCH': 'Batch erstellt',
|
||
+ 'BACKUP': 'Backup erstellt',
|
||
+ 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung',
|
||
+ 'LOGIN_2FA_BACKUP': '2FA-Backup-Code',
|
||
+ 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen',
|
||
+ 'LOGIN_BLOCKED': 'Login-Blockiert',
|
||
+ 'RESTORE': 'Wiederhergestellt',
|
||
+ 'PASSWORD_CHANGE': 'Passwort geändert',
|
||
+ '2FA_ENABLED': '2FA aktiviert',
|
||
+ '2FA_DISABLED': '2FA deaktiviert'
|
||
+ }.get(log[3], log[3])
|
||
+
|
||
+ data.append({
|
||
+ 'ID': log[0],
|
||
+ 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'),
|
||
+ 'Benutzer': log[2],
|
||
+ 'Aktion': action_text,
|
||
+ 'Entität': log[4],
|
||
+ 'Entität-ID': log[5] or '',
|
||
+ 'IP-Adresse': log[8] or '',
|
||
+ 'Zusatzinfo': log[10] or ''
|
||
+ })
|
||
+
|
||
+ # DataFrame erstellen
|
||
+ df = pd.DataFrame(data)
|
||
+
|
||
+ # Timestamp für Dateiname
|
||
+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
+ filename = f'audit_log_export_{timestamp}'
|
||
+
|
||
+ # Audit Log für Export
|
||
+ log_audit('EXPORT', 'audit_log',
|
||
+ additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen")
|
||
+
|
||
+ if export_format == 'csv':
|
||
+ # CSV Export
|
||
+ output = io.StringIO()
|
||
+ # UTF-8 BOM für Excel
|
||
+ output.write('\ufeff')
|
||
+ df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8')),
|
||
+ mimetype='text/csv;charset=utf-8',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv'
|
||
+ )
|
||
+ else:
|
||
+ # Excel Export
|
||
+ output = BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, index=False, sheet_name='Audit Log')
|
||
+
|
||
+ # Spaltenbreiten anpassen
|
||
+ worksheet = writer.sheets['Audit Log']
|
||
+ for idx, col in enumerate(df.columns):
|
||
+ max_length = max(
|
||
+ df[col].astype(str).map(len).max(),
|
||
+ len(col)
|
||
+ ) + 2
|
||
+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx'
|
||
+ )
|
||
+
|
||
+@app.route("/export/customers")
|
||
+@login_required
|
||
+def export_customers():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Check if test data should be included
|
||
+ include_test = request.args.get('include_test', 'false').lower() == 'true'
|
||
+
|
||
+ # Build query based on test data filter
|
||
+ if include_test:
|
||
+ # Include all customers
|
||
+ query = """
|
||
+ SELECT c.id, c.name, c.email, c.created_at, c.is_test,
|
||
+ COUNT(l.id) as total_licenses,
|
||
+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses,
|
||
+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ GROUP BY c.id, c.name, c.email, c.created_at, c.is_test
|
||
+ ORDER BY c.id
|
||
+ """
|
||
+ else:
|
||
+ # Exclude test customers and test licenses
|
||
+ query = """
|
||
+ SELECT c.id, c.name, c.email, c.created_at, c.is_test,
|
||
+ COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses,
|
||
+ COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses,
|
||
+ COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses
|
||
+ FROM customers c
|
||
+ LEFT JOIN licenses l ON c.id = l.customer_id
|
||
+ WHERE c.is_test = FALSE
|
||
+ GROUP BY c.id, c.name, c.email, c.created_at, c.is_test
|
||
+ ORDER BY c.id
|
||
+ """
|
||
+
|
||
+ cur.execute(query)
|
||
+
|
||
+ # Spaltennamen
|
||
+ columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten',
|
||
+ 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen']
|
||
+
|
||
+ # Daten in DataFrame
|
||
+ data = cur.fetchall()
|
||
+ df = pd.DataFrame(data, columns=columns)
|
||
+
|
||
+ # Datumsformatierung
|
||
+ df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M')
|
||
+
|
||
+ # Testdaten formatting
|
||
+ df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'})
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Export Format
|
||
+ export_format = request.args.get('format', 'excel')
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('EXPORT', 'customer',
|
||
+ additional_info=f"Export aller Kunden als {export_format.upper()}")
|
||
+ filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}'
|
||
+
|
||
+ if export_format == 'csv':
|
||
+ # CSV Export
|
||
+ output = io.StringIO()
|
||
+ df.to_csv(output, index=False, encoding='utf-8-sig', sep=';')
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
+ mimetype='text/csv',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv'
|
||
+ )
|
||
+ else:
|
||
+ # Excel Export
|
||
+ output = io.BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, sheet_name='Kunden', index=False)
|
||
+
|
||
+ # Formatierung
|
||
+ worksheet = writer.sheets['Kunden']
|
||
+ for column in worksheet.columns:
|
||
+ max_length = 0
|
||
+ column_letter = column[0].column_letter
|
||
+ for cell in column:
|
||
+ try:
|
||
+ if len(str(cell.value)) > max_length:
|
||
+ max_length = len(str(cell.value))
|
||
+ except:
|
||
+ pass
|
||
+ adjusted_width = min(max_length + 2, 50)
|
||
+ worksheet.column_dimensions[column_letter].width = adjusted_width
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx'
|
||
+ )
|
||
+
|
||
+@app.route("/export/sessions")
|
||
+@login_required
|
||
+def export_sessions():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Holen des Session-Typs (active oder ended)
|
||
+ session_type = request.args.get('type', 'active')
|
||
+ export_format = request.args.get('format', 'excel')
|
||
+
|
||
+ # Daten je nach Typ abrufen
|
||
+ if session_type == 'active':
|
||
+ # Aktive Lizenz-Sessions
|
||
+ cur.execute("""
|
||
+ SELECT s.id, l.license_key, c.name as customer_name, s.session_id,
|
||
+ s.started_at, s.last_heartbeat,
|
||
+ EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds,
|
||
+ s.ip_address, s.user_agent
|
||
+ FROM sessions s
|
||
+ JOIN licenses l ON s.license_id = l.id
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE s.is_active = true
|
||
+ ORDER BY s.last_heartbeat DESC
|
||
+ """)
|
||
+ sessions = cur.fetchall()
|
||
+
|
||
+ # Daten für Export vorbereiten
|
||
+ data = []
|
||
+ for sess in sessions:
|
||
+ duration = sess[6]
|
||
+ hours = duration // 3600
|
||
+ minutes = (duration % 3600) // 60
|
||
+ seconds = duration % 60
|
||
+
|
||
+ data.append({
|
||
+ 'Session-ID': sess[0],
|
||
+ 'Lizenzschlüssel': sess[1],
|
||
+ 'Kunde': sess[2],
|
||
+ 'Session-ID (Tech)': sess[3],
|
||
+ 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'),
|
||
+ 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'),
|
||
+ 'Dauer': f"{hours}h {minutes}m {seconds}s",
|
||
+ 'IP-Adresse': sess[7],
|
||
+ 'Browser': sess[8]
|
||
+ })
|
||
+
|
||
+ sheet_name = 'Aktive Sessions'
|
||
+ filename_prefix = 'aktive_sessions'
|
||
+ else:
|
||
+ # Beendete Lizenz-Sessions
|
||
+ cur.execute("""
|
||
+ SELECT s.id, l.license_key, c.name as customer_name, s.session_id,
|
||
+ s.started_at, s.ended_at,
|
||
+ EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds,
|
||
+ s.ip_address, s.user_agent
|
||
+ FROM sessions s
|
||
+ JOIN licenses l ON s.license_id = l.id
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE s.is_active = false AND s.ended_at IS NOT NULL
|
||
+ ORDER BY s.ended_at DESC
|
||
+ LIMIT 1000
|
||
+ """)
|
||
+ sessions = cur.fetchall()
|
||
+
|
||
+ # Daten für Export vorbereiten
|
||
+ data = []
|
||
+ for sess in sessions:
|
||
+ duration = sess[6] if sess[6] else 0
|
||
+ hours = duration // 3600
|
||
+ minutes = (duration % 3600) // 60
|
||
+ seconds = duration % 60
|
||
+
|
||
+ data.append({
|
||
+ 'Session-ID': sess[0],
|
||
+ 'Lizenzschlüssel': sess[1],
|
||
+ 'Kunde': sess[2],
|
||
+ 'Session-ID (Tech)': sess[3],
|
||
+ 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'),
|
||
+ 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '',
|
||
+ 'Dauer': f"{hours}h {minutes}m {seconds}s",
|
||
+ 'IP-Adresse': sess[7],
|
||
+ 'Browser': sess[8]
|
||
+ })
|
||
+
|
||
+ sheet_name = 'Beendete Sessions'
|
||
+ filename_prefix = 'beendete_sessions'
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # DataFrame erstellen
|
||
+ df = pd.DataFrame(data)
|
||
+
|
||
+ # Timestamp für Dateiname
|
||
+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
+ filename = f'{filename_prefix}_export_{timestamp}'
|
||
+
|
||
+ # Audit Log für Export
|
||
+ log_audit('EXPORT', 'sessions',
|
||
+ additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen")
|
||
+
|
||
+ if export_format == 'csv':
|
||
+ # CSV Export
|
||
+ output = io.StringIO()
|
||
+ # UTF-8 BOM für Excel
|
||
+ output.write('\ufeff')
|
||
+ df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8')),
|
||
+ mimetype='text/csv;charset=utf-8',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv'
|
||
+ )
|
||
+ else:
|
||
+ # Excel Export
|
||
+ output = BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, index=False, sheet_name=sheet_name)
|
||
+
|
||
+ # Spaltenbreiten anpassen
|
||
+ worksheet = writer.sheets[sheet_name]
|
||
+ for idx, col in enumerate(df.columns):
|
||
+ max_length = max(
|
||
+ df[col].astype(str).map(len).max(),
|
||
+ len(col)
|
||
+ ) + 2
|
||
+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx'
|
||
+ )
|
||
+
|
||
+@app.route("/export/resources")
|
||
+@login_required
|
||
+def export_resources():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Holen der Filter-Parameter
|
||
+ filter_type = request.args.get('type', '')
|
||
+ filter_status = request.args.get('status', '')
|
||
+ search_query = request.args.get('search', '')
|
||
+ show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
+ export_format = request.args.get('format', 'excel')
|
||
+
|
||
+ # SQL Query mit Filtern
|
||
+ query = """
|
||
+ SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license,
|
||
+ r.created_at, r.status_changed_at,
|
||
+ l.license_key, c.name as customer_name, c.email as customer_email,
|
||
+ l.license_type
|
||
+ FROM resource_pools r
|
||
+ LEFT JOIN licenses l ON r.allocated_to_license = l.id
|
||
+ LEFT JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE 1=1
|
||
+ """
|
||
+ params = []
|
||
+
|
||
+ # Filter für Testdaten
|
||
+ if not show_test:
|
||
+ query += " AND (r.is_test = false OR r.is_test IS NULL)"
|
||
+
|
||
+ # Filter für Ressourcentyp
|
||
+ if filter_type:
|
||
+ query += " AND r.resource_type = %s"
|
||
+ params.append(filter_type)
|
||
+
|
||
+ # Filter für Status
|
||
+ if filter_status:
|
||
+ query += " AND r.status = %s"
|
||
+ params.append(filter_status)
|
||
+
|
||
+ # Suchfilter
|
||
+ if search_query:
|
||
+ query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)"
|
||
+ params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%'])
|
||
+
|
||
+ query += " ORDER BY r.id DESC"
|
||
+
|
||
+ cur.execute(query, params)
|
||
+ resources = cur.fetchall()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Daten für Export vorbereiten
|
||
+ data = []
|
||
+ for res in resources:
|
||
+ status_text = {
|
||
+ 'available': 'Verfügbar',
|
||
+ 'allocated': 'Zugewiesen',
|
||
+ 'quarantine': 'Quarantäne'
|
||
+ }.get(res[3], res[3])
|
||
+
|
||
+ type_text = {
|
||
+ 'domain': 'Domain',
|
||
+ 'ipv4': 'IPv4',
|
||
+ 'phone': 'Telefon'
|
||
+ }.get(res[1], res[1])
|
||
+
|
||
+ data.append({
|
||
+ 'ID': res[0],
|
||
+ 'Typ': type_text,
|
||
+ 'Ressource': res[2],
|
||
+ 'Status': status_text,
|
||
+ 'Lizenzschlüssel': res[7] or '',
|
||
+ 'Kunde': res[8] or '',
|
||
+ 'Kunden-Email': res[9] or '',
|
||
+ 'Lizenztyp': res[10] or '',
|
||
+ 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '',
|
||
+ 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else ''
|
||
+ })
|
||
+
|
||
+ # DataFrame erstellen
|
||
+ df = pd.DataFrame(data)
|
||
+
|
||
+ # Timestamp für Dateiname
|
||
+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
+ filename = f'resources_export_{timestamp}'
|
||
+
|
||
+ # Audit Log für Export
|
||
+ log_audit('EXPORT', 'resources',
|
||
+ additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen")
|
||
+
|
||
+ if export_format == 'csv':
|
||
+ # CSV Export
|
||
+ output = io.StringIO()
|
||
+ # UTF-8 BOM für Excel
|
||
+ output.write('\ufeff')
|
||
+ df.to_csv(output, index=False, sep=';', encoding='utf-8')
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ io.BytesIO(output.getvalue().encode('utf-8')),
|
||
+ mimetype='text/csv;charset=utf-8',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv'
|
||
+ )
|
||
+ else:
|
||
+ # Excel Export
|
||
+ output = BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, index=False, sheet_name='Resources')
|
||
+
|
||
+ # Spaltenbreiten anpassen
|
||
+ worksheet = writer.sheets['Resources']
|
||
+ for idx, col in enumerate(df.columns):
|
||
+ max_length = max(
|
||
+ df[col].astype(str).map(len).max(),
|
||
+ len(col)
|
||
+ ) + 2
|
||
+ worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ return send_file(
|
||
+ output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx'
|
||
+ )
|
||
+
|
||
+@app.route("/audit")
|
||
+@login_required
|
||
+def audit_log():
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Parameter
|
||
+ filter_user = request.args.get('user', '').strip()
|
||
+ filter_action = request.args.get('action', '').strip()
|
||
+ filter_entity = request.args.get('entity', '').strip()
|
||
+ page = request.args.get('page', 1, type=int)
|
||
+ sort = request.args.get('sort', 'timestamp')
|
||
+ order = request.args.get('order', 'desc')
|
||
+ per_page = 50
|
||
+
|
||
+ # Whitelist für erlaubte Sortierfelder
|
||
+ allowed_sort_fields = {
|
||
+ 'timestamp': 'timestamp',
|
||
+ 'username': 'username',
|
||
+ 'action': 'action',
|
||
+ 'entity': 'entity_type',
|
||
+ 'ip': 'ip_address'
|
||
+ }
|
||
+
|
||
+ # Validierung
|
||
+ if sort not in allowed_sort_fields:
|
||
+ sort = 'timestamp'
|
||
+ if order not in ['asc', 'desc']:
|
||
+ order = 'desc'
|
||
+
|
||
+ sort_field = allowed_sort_fields[sort]
|
||
+
|
||
+ # SQL Query mit optionalen Filtern
|
||
+ query = """
|
||
+ SELECT id, timestamp, username, action, entity_type, entity_id,
|
||
+ old_values, new_values, ip_address, user_agent, additional_info
|
||
+ FROM audit_log
|
||
+ WHERE 1=1
|
||
+ """
|
||
+
|
||
+ params = []
|
||
+
|
||
+ # Filter
|
||
+ if filter_user:
|
||
+ query += " AND LOWER(username) LIKE LOWER(%s)"
|
||
+ params.append(f'%{filter_user}%')
|
||
+
|
||
+ if filter_action:
|
||
+ query += " AND action = %s"
|
||
+ params.append(filter_action)
|
||
+
|
||
+ if filter_entity:
|
||
+ query += " AND entity_type = %s"
|
||
+ params.append(filter_entity)
|
||
+
|
||
+ # Gesamtanzahl für Pagination
|
||
+ count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table"
|
||
+ cur.execute(count_query, params)
|
||
+ total = cur.fetchone()[0]
|
||
+
|
||
+ # Pagination
|
||
+ offset = (page - 1) * per_page
|
||
+ query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s"
|
||
+ params.extend([per_page, offset])
|
||
+
|
||
+ cur.execute(query, params)
|
||
+ logs = cur.fetchall()
|
||
+
|
||
+ # JSON-Werte parsen
|
||
+ parsed_logs = []
|
||
+ for log in logs:
|
||
+ parsed_log = list(log)
|
||
+ # old_values und new_values sind bereits Dictionaries (JSONB)
|
||
+ # Keine Konvertierung nötig
|
||
+ parsed_logs.append(parsed_log)
|
||
+
|
||
+ # Pagination Info
|
||
+ total_pages = (total + per_page - 1) // per_page
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template("audit_log.html",
|
||
+ logs=parsed_logs,
|
||
+ filter_user=filter_user,
|
||
+ filter_action=filter_action,
|
||
+ filter_entity=filter_entity,
|
||
+ page=page,
|
||
+ total_pages=total_pages,
|
||
+ total=total,
|
||
+ sort=sort,
|
||
+ order=order,
|
||
+ username=session.get('username'))
|
||
+
|
||
+@app.route("/backups")
|
||
+@login_required
|
||
+def backups():
|
||
+ """Zeigt die Backup-Historie an"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Letztes erfolgreiches Backup für Dashboard
|
||
+ cur.execute("""
|
||
+ SELECT created_at, filesize, duration_seconds
|
||
+ FROM backup_history
|
||
+ WHERE status = 'success'
|
||
+ ORDER BY created_at DESC
|
||
+ LIMIT 1
|
||
+ """)
|
||
+ last_backup = cur.fetchone()
|
||
+
|
||
+ # Alle Backups abrufen
|
||
+ cur.execute("""
|
||
+ SELECT id, filename, filesize, backup_type, status, error_message,
|
||
+ created_at, created_by, tables_count, records_count,
|
||
+ duration_seconds, is_encrypted
|
||
+ FROM backup_history
|
||
+ ORDER BY created_at DESC
|
||
+ """)
|
||
+ backups = cur.fetchall()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template("backups.html",
|
||
+ backups=backups,
|
||
+ last_backup=last_backup,
|
||
+ username=session.get('username'))
|
||
+
|
||
+@app.route("/backup/create", methods=["POST"])
|
||
+@login_required
|
||
+def create_backup_route():
|
||
+ """Erstellt ein manuelles Backup"""
|
||
+ username = session.get('username')
|
||
+ success, result = create_backup(backup_type="manual", created_by=username)
|
||
+
|
||
+ if success:
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'message': f'Backup erfolgreich erstellt: {result}'
|
||
+ })
|
||
+ else:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': f'Backup fehlgeschlagen: {result}'
|
||
+ }), 500
|
||
+
|
||
+@app.route("/backup/restore/<int:backup_id>", methods=["POST"])
|
||
+@login_required
|
||
+def restore_backup_route(backup_id):
|
||
+ """Stellt ein Backup wieder her"""
|
||
+ encryption_key = request.form.get('encryption_key')
|
||
+
|
||
+ success, message = restore_backup(backup_id, encryption_key)
|
||
+
|
||
+ if success:
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'message': message
|
||
+ })
|
||
+ else:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': message
|
||
+ }), 500
|
||
+
|
||
+@app.route("/backup/download/<int:backup_id>")
|
||
+@login_required
|
||
+def download_backup(backup_id):
|
||
+ """Lädt eine Backup-Datei herunter"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT filename, filepath
|
||
+ FROM backup_history
|
||
+ WHERE id = %s
|
||
+ """, (backup_id,))
|
||
+ backup_info = cur.fetchone()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ if not backup_info:
|
||
+ return "Backup nicht gefunden", 404
|
||
+
|
||
+ filename, filepath = backup_info
|
||
+ filepath = Path(filepath)
|
||
+
|
||
+ if not filepath.exists():
|
||
+ return "Backup-Datei nicht gefunden", 404
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('DOWNLOAD', 'backup', backup_id,
|
||
+ additional_info=f"Backup heruntergeladen: {filename}")
|
||
+
|
||
+ return send_file(filepath, as_attachment=True, download_name=filename)
|
||
+
|
||
+@app.route("/backup/delete/<int:backup_id>", methods=["DELETE"])
|
||
+@login_required
|
||
+def delete_backup(backup_id):
|
||
+ """Löscht ein Backup"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ # Backup-Informationen abrufen
|
||
+ cur.execute("""
|
||
+ SELECT filename, filepath
|
||
+ FROM backup_history
|
||
+ WHERE id = %s
|
||
+ """, (backup_id,))
|
||
+ backup_info = cur.fetchone()
|
||
+
|
||
+ if not backup_info:
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': 'Backup nicht gefunden'
|
||
+ }), 404
|
||
+
|
||
+ filename, filepath = backup_info
|
||
+ filepath = Path(filepath)
|
||
+
|
||
+ # Datei löschen, wenn sie existiert
|
||
+ if filepath.exists():
|
||
+ filepath.unlink()
|
||
+
|
||
+ # Aus Datenbank löschen
|
||
+ cur.execute("""
|
||
+ DELETE FROM backup_history
|
||
+ WHERE id = %s
|
||
+ """, (backup_id,))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('DELETE', 'backup', backup_id,
|
||
+ additional_info=f"Backup gelöscht: {filename}")
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'message': f'Backup "{filename}" wurde erfolgreich gelöscht'
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'message': f'Fehler beim Löschen des Backups: {str(e)}'
|
||
+ }), 500
|
||
+ finally:
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+@app.route("/security/blocked-ips")
|
||
+@login_required
|
||
+def blocked_ips():
|
||
+ """Zeigt alle gesperrten IPs an"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ ip_address,
|
||
+ attempt_count,
|
||
+ first_attempt,
|
||
+ last_attempt,
|
||
+ blocked_until,
|
||
+ last_username_tried,
|
||
+ last_error_message
|
||
+ FROM login_attempts
|
||
+ WHERE blocked_until IS NOT NULL
|
||
+ ORDER BY blocked_until DESC
|
||
+ """)
|
||
+
|
||
+ blocked_ips_list = []
|
||
+ for ip in cur.fetchall():
|
||
+ blocked_ips_list.append({
|
||
+ 'ip_address': ip[0],
|
||
+ 'attempt_count': ip[1],
|
||
+ 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'),
|
||
+ 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'),
|
||
+ 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'),
|
||
+ 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None),
|
||
+ 'last_username': ip[5],
|
||
+ 'last_error': ip[6]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template("blocked_ips.html",
|
||
+ blocked_ips=blocked_ips_list,
|
||
+ username=session.get('username'))
|
||
+
|
||
+@app.route("/security/unblock-ip", methods=["POST"])
|
||
+@login_required
|
||
+def unblock_ip():
|
||
+ """Entsperrt eine IP-Adresse"""
|
||
+ ip_address = request.form.get('ip_address')
|
||
+
|
||
+ if ip_address:
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ cur.execute("""
|
||
+ UPDATE login_attempts
|
||
+ SET blocked_until = NULL
|
||
+ WHERE ip_address = %s
|
||
+ """, (ip_address,))
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('UNBLOCK_IP', 'security',
|
||
+ additional_info=f"IP {ip_address} manuell entsperrt")
|
||
+
|
||
+ return redirect(url_for('blocked_ips'))
|
||
+
|
||
+@app.route("/security/clear-attempts", methods=["POST"])
|
||
+@login_required
|
||
+def clear_attempts():
|
||
+ """Löscht alle Login-Versuche für eine IP"""
|
||
+ ip_address = request.form.get('ip_address')
|
||
+
|
||
+ if ip_address:
|
||
+ reset_login_attempts(ip_address)
|
||
+
|
||
+ # Audit-Log
|
||
+ log_audit('CLEAR_ATTEMPTS', 'security',
|
||
+ additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt")
|
||
+
|
||
+ return redirect(url_for('blocked_ips'))
|
||
+
|
||
+# API Endpoints for License Management
|
||
+@app.route("/api/license/<int:license_id>/toggle", methods=["POST"])
|
||
+@login_required
|
||
+def toggle_license_api(license_id):
|
||
+ """Toggle license active status via API"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ is_active = data.get('is_active', False)
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Update license status
|
||
+ cur.execute("""
|
||
+ UPDATE licenses
|
||
+ SET is_active = %s
|
||
+ WHERE id = %s
|
||
+ """, (is_active, license_id))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Log the action
|
||
+ log_audit('UPDATE', 'license', license_id,
|
||
+ new_values={'is_active': is_active},
|
||
+ additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle")
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': 'Status erfolgreich geändert'})
|
||
+ except Exception as e:
|
||
+ return jsonify({'success': False, 'message': str(e)}), 500
|
||
+
|
||
+@app.route("/api/licenses/bulk-activate", methods=["POST"])
|
||
+@login_required
|
||
+def bulk_activate_licenses():
|
||
+ """Activate multiple licenses at once"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ license_ids = data.get('ids', [])
|
||
+
|
||
+ if not license_ids:
|
||
+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Update all selected licenses (nur Live-Daten)
|
||
+ cur.execute("""
|
||
+ UPDATE licenses
|
||
+ SET is_active = TRUE
|
||
+ WHERE id = ANY(%s) AND is_test = FALSE
|
||
+ """, (license_ids,))
|
||
+
|
||
+ affected_rows = cur.rowcount
|
||
+ conn.commit()
|
||
+
|
||
+ # Log the bulk action
|
||
+ log_audit('BULK_UPDATE', 'licenses', None,
|
||
+ new_values={'is_active': True, 'count': affected_rows},
|
||
+ additional_info=f"{affected_rows} Lizenzen aktiviert")
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'})
|
||
+ except Exception as e:
|
||
+ return jsonify({'success': False, 'message': str(e)}), 500
|
||
+
|
||
+@app.route("/api/licenses/bulk-deactivate", methods=["POST"])
|
||
+@login_required
|
||
+def bulk_deactivate_licenses():
|
||
+ """Deactivate multiple licenses at once"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ license_ids = data.get('ids', [])
|
||
+
|
||
+ if not license_ids:
|
||
+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Update all selected licenses (nur Live-Daten)
|
||
+ cur.execute("""
|
||
+ UPDATE licenses
|
||
+ SET is_active = FALSE
|
||
+ WHERE id = ANY(%s) AND is_test = FALSE
|
||
+ """, (license_ids,))
|
||
+
|
||
+ affected_rows = cur.rowcount
|
||
+ conn.commit()
|
||
+
|
||
+ # Log the bulk action
|
||
+ log_audit('BULK_UPDATE', 'licenses', None,
|
||
+ new_values={'is_active': False, 'count': affected_rows},
|
||
+ additional_info=f"{affected_rows} Lizenzen deaktiviert")
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'})
|
||
+ except Exception as e:
|
||
+ return jsonify({'success': False, 'message': str(e)}), 500
|
||
+
|
||
+@app.route("/api/license/<int:license_id>/devices")
|
||
+@login_required
|
||
+def get_license_devices(license_id):
|
||
+ """Hole alle registrierten Geräte einer Lizenz"""
|
||
+ try:
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Prüfe ob Lizenz existiert und hole device_limit
|
||
+ cur.execute("""
|
||
+ SELECT device_limit FROM licenses WHERE id = %s
|
||
+ """, (license_id,))
|
||
+ license_data = cur.fetchone()
|
||
+
|
||
+ if not license_data:
|
||
+ return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404
|
||
+
|
||
+ device_limit = license_data[0]
|
||
+
|
||
+ # Hole alle Geräte für diese Lizenz
|
||
+ cur.execute("""
|
||
+ SELECT id, hardware_id, device_name, operating_system,
|
||
+ first_seen, last_seen, is_active, ip_address
|
||
+ FROM device_registrations
|
||
+ WHERE license_id = %s
|
||
+ ORDER BY is_active DESC, last_seen DESC
|
||
+ """, (license_id,))
|
||
+
|
||
+ devices = []
|
||
+ for row in cur.fetchall():
|
||
+ devices.append({
|
||
+ 'id': row[0],
|
||
+ 'hardware_id': row[1],
|
||
+ 'device_name': row[2] or 'Unbekanntes Gerät',
|
||
+ 'operating_system': row[3] or 'Unbekannt',
|
||
+ 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '',
|
||
+ 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '',
|
||
+ 'is_active': row[6],
|
||
+ 'ip_address': row[7] or '-'
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'devices': devices,
|
||
+ 'device_limit': device_limit,
|
||
+ 'active_count': sum(1 for d in devices if d['is_active'])
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}")
|
||
+ return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500
|
||
+
|
||
+@app.route("/api/license/<int:license_id>/register-device", methods=["POST"])
|
||
+def register_device(license_id):
|
||
+ """Registriere ein neues Gerät für eine Lizenz"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ hardware_id = data.get('hardware_id')
|
||
+ device_name = data.get('device_name', '')
|
||
+ operating_system = data.get('operating_system', '')
|
||
+
|
||
+ if not hardware_id:
|
||
+ return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Prüfe ob Lizenz existiert und aktiv ist
|
||
+ cur.execute("""
|
||
+ SELECT device_limit, is_active, valid_until
|
||
+ FROM licenses
|
||
+ WHERE id = %s
|
||
+ """, (license_id,))
|
||
+ license_data = cur.fetchone()
|
||
+
|
||
+ if not license_data:
|
||
+ return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404
|
||
+
|
||
+ device_limit, is_active, valid_until = license_data
|
||
+
|
||
+ # Prüfe ob Lizenz aktiv und gültig ist
|
||
+ if not is_active:
|
||
+ return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403
|
||
+
|
||
+ if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date():
|
||
+ return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403
|
||
+
|
||
+ # Prüfe ob Gerät bereits registriert ist
|
||
+ cur.execute("""
|
||
+ SELECT id, is_active FROM device_registrations
|
||
+ WHERE license_id = %s AND hardware_id = %s
|
||
+ """, (license_id, hardware_id))
|
||
+ existing_device = cur.fetchone()
|
||
+
|
||
+ if existing_device:
|
||
+ device_id, is_device_active = existing_device
|
||
+ if is_device_active:
|
||
+ # Gerät ist bereits aktiv, update last_seen
|
||
+ cur.execute("""
|
||
+ UPDATE device_registrations
|
||
+ SET last_seen = CURRENT_TIMESTAMP,
|
||
+ ip_address = %s,
|
||
+ user_agent = %s
|
||
+ WHERE id = %s
|
||
+ """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
+ conn.commit()
|
||
+ return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id})
|
||
+ else:
|
||
+ # Gerät war deaktiviert, prüfe ob wir es reaktivieren können
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM device_registrations
|
||
+ WHERE license_id = %s AND is_active = TRUE
|
||
+ """, (license_id,))
|
||
+ active_count = cur.fetchone()[0]
|
||
+
|
||
+ if active_count >= device_limit:
|
||
+ return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
+
|
||
+ # Reaktiviere das Gerät
|
||
+ cur.execute("""
|
||
+ UPDATE device_registrations
|
||
+ SET is_active = TRUE,
|
||
+ last_seen = CURRENT_TIMESTAMP,
|
||
+ deactivated_at = NULL,
|
||
+ deactivated_by = NULL,
|
||
+ ip_address = %s,
|
||
+ user_agent = %s
|
||
+ WHERE id = %s
|
||
+ """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id))
|
||
+ conn.commit()
|
||
+ return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id})
|
||
+
|
||
+ # Neues Gerät - prüfe Gerätelimit
|
||
+ cur.execute("""
|
||
+ SELECT COUNT(*) FROM device_registrations
|
||
+ WHERE license_id = %s AND is_active = TRUE
|
||
+ """, (license_id,))
|
||
+ active_count = cur.fetchone()[0]
|
||
+
|
||
+ if active_count >= device_limit:
|
||
+ return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403
|
||
+
|
||
+ # Registriere neues Gerät
|
||
+ cur.execute("""
|
||
+ INSERT INTO device_registrations
|
||
+ (license_id, hardware_id, device_name, operating_system, ip_address, user_agent)
|
||
+ VALUES (%s, %s, %s, %s, %s, %s)
|
||
+ RETURNING id
|
||
+ """, (license_id, hardware_id, device_name, operating_system,
|
||
+ get_client_ip(), request.headers.get('User-Agent', '')))
|
||
+ device_id = cur.fetchone()[0]
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit Log
|
||
+ log_audit('DEVICE_REGISTER', 'device', device_id,
|
||
+ new_values={'license_id': license_id, 'hardware_id': hardware_id})
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id})
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}")
|
||
+ return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500
|
||
+
|
||
+@app.route("/api/license/<int:license_id>/deactivate-device/<int:device_id>", methods=["POST"])
|
||
+@login_required
|
||
+def deactivate_device(license_id, device_id):
|
||
+ """Deaktiviere ein registriertes Gerät"""
|
||
+ try:
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Prüfe ob das Gerät zu dieser Lizenz gehört
|
||
+ cur.execute("""
|
||
+ SELECT id FROM device_registrations
|
||
+ WHERE id = %s AND license_id = %s AND is_active = TRUE
|
||
+ """, (device_id, license_id))
|
||
+
|
||
+ if not cur.fetchone():
|
||
+ return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404
|
||
+
|
||
+ # Deaktiviere das Gerät
|
||
+ cur.execute("""
|
||
+ UPDATE device_registrations
|
||
+ SET is_active = FALSE,
|
||
+ deactivated_at = CURRENT_TIMESTAMP,
|
||
+ deactivated_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (session['username'], device_id))
|
||
+
|
||
+ conn.commit()
|
||
+
|
||
+ # Audit Log
|
||
+ log_audit('DEVICE_DEACTIVATE', 'device', device_id,
|
||
+ old_values={'is_active': True},
|
||
+ new_values={'is_active': False})
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'})
|
||
+
|
||
+ except Exception as e:
|
||
+ logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}")
|
||
+ return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500
|
||
+
|
||
+@app.route("/api/licenses/bulk-delete", methods=["POST"])
|
||
+@login_required
|
||
+def bulk_delete_licenses():
|
||
+ """Delete multiple licenses at once"""
|
||
+ try:
|
||
+ data = request.get_json()
|
||
+ license_ids = data.get('ids', [])
|
||
+
|
||
+ if not license_ids:
|
||
+ return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Get license info for audit log (nur Live-Daten)
|
||
+ cur.execute("""
|
||
+ SELECT license_key
|
||
+ FROM licenses
|
||
+ WHERE id = ANY(%s) AND is_test = FALSE
|
||
+ """, (license_ids,))
|
||
+ license_keys = [row[0] for row in cur.fetchall()]
|
||
+
|
||
+ # Delete all selected licenses (nur Live-Daten)
|
||
+ cur.execute("""
|
||
+ DELETE FROM licenses
|
||
+ WHERE id = ANY(%s) AND is_test = FALSE
|
||
+ """, (license_ids,))
|
||
+
|
||
+ affected_rows = cur.rowcount
|
||
+ conn.commit()
|
||
+
|
||
+ # Log the bulk action
|
||
+ log_audit('BULK_DELETE', 'licenses', None,
|
||
+ old_values={'license_keys': license_keys, 'count': affected_rows},
|
||
+ additional_info=f"{affected_rows} Lizenzen gelöscht")
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'})
|
||
+ except Exception as e:
|
||
+ return jsonify({'success': False, 'message': str(e)}), 500
|
||
+
|
||
+# ===================== RESOURCE POOL MANAGEMENT =====================
|
||
+
|
||
+@app.route('/resources')
|
||
+@login_required
|
||
+def resources():
|
||
+ """Resource Pool Hauptübersicht"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden)
|
||
+ show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
+
|
||
+ # Statistiken abrufen
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ resource_type,
|
||
+ COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
+ COUNT(*) as total
|
||
+ FROM resource_pools
|
||
+ WHERE is_test = %s
|
||
+ GROUP BY resource_type
|
||
+ """, (show_test,))
|
||
+
|
||
+ stats = {}
|
||
+ for row in cur.fetchall():
|
||
+ stats[row[0]] = {
|
||
+ 'available': row[1],
|
||
+ 'allocated': row[2],
|
||
+ 'quarantine': row[3],
|
||
+ 'total': row[4],
|
||
+ 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1)
|
||
+ }
|
||
+
|
||
+ # Letzte Aktivitäten (gefiltert nach Test/Live)
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ rh.action,
|
||
+ rh.action_by,
|
||
+ rh.action_at,
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ rh.details
|
||
+ FROM resource_history rh
|
||
+ JOIN resource_pools rp ON rh.resource_id = rp.id
|
||
+ WHERE rp.is_test = %s
|
||
+ ORDER BY rh.action_at DESC
|
||
+ LIMIT 10
|
||
+ """, (show_test,))
|
||
+ recent_activities = cur.fetchall()
|
||
+
|
||
+ # Ressourcen-Liste mit Pagination
|
||
+ page = request.args.get('page', 1, type=int)
|
||
+ per_page = 50
|
||
+ offset = (page - 1) * per_page
|
||
+
|
||
+ resource_type = request.args.get('type', '')
|
||
+ status_filter = request.args.get('status', '')
|
||
+ search = request.args.get('search', '')
|
||
+
|
||
+ # Sortierung
|
||
+ sort_by = request.args.get('sort', 'id')
|
||
+ sort_order = request.args.get('order', 'desc')
|
||
+
|
||
+ # Base Query
|
||
+ query = """
|
||
+ SELECT
|
||
+ rp.id,
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ rp.status,
|
||
+ rp.allocated_to_license,
|
||
+ l.license_key,
|
||
+ c.name as customer_name,
|
||
+ rp.status_changed_at,
|
||
+ rp.quarantine_reason,
|
||
+ rp.quarantine_until,
|
||
+ c.id as customer_id
|
||
+ FROM resource_pools rp
|
||
+ LEFT JOIN licenses l ON rp.allocated_to_license = l.id
|
||
+ LEFT JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE rp.is_test = %s
|
||
+ """
|
||
+ params = [show_test]
|
||
+
|
||
+ if resource_type:
|
||
+ query += " AND rp.resource_type = %s"
|
||
+ params.append(resource_type)
|
||
+
|
||
+ if status_filter:
|
||
+ query += " AND rp.status = %s"
|
||
+ params.append(status_filter)
|
||
+
|
||
+ if search:
|
||
+ query += " AND rp.resource_value ILIKE %s"
|
||
+ params.append(f'%{search}%')
|
||
+
|
||
+ # Count total
|
||
+ count_query = f"SELECT COUNT(*) FROM ({query}) as cnt"
|
||
+ cur.execute(count_query, params)
|
||
+ total = cur.fetchone()[0]
|
||
+ total_pages = (total + per_page - 1) // per_page
|
||
+
|
||
+ # Get paginated results with dynamic sorting
|
||
+ sort_column_map = {
|
||
+ 'id': 'rp.id',
|
||
+ 'type': 'rp.resource_type',
|
||
+ 'resource': 'rp.resource_value',
|
||
+ 'status': 'rp.status',
|
||
+ 'assigned': 'c.name',
|
||
+ 'changed': 'rp.status_changed_at'
|
||
+ }
|
||
+
|
||
+ sort_column = sort_column_map.get(sort_by, 'rp.id')
|
||
+ sort_direction = 'ASC' if sort_order == 'asc' else 'DESC'
|
||
+
|
||
+ query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s"
|
||
+ params.extend([per_page, offset])
|
||
+
|
||
+ cur.execute(query, params)
|
||
+ resources = cur.fetchall()
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template('resources.html',
|
||
+ stats=stats,
|
||
+ resources=resources,
|
||
+ recent_activities=recent_activities,
|
||
+ page=page,
|
||
+ total_pages=total_pages,
|
||
+ total=total,
|
||
+ resource_type=resource_type,
|
||
+ status_filter=status_filter,
|
||
+ search=search,
|
||
+ show_test=show_test,
|
||
+ sort_by=sort_by,
|
||
+ sort_order=sort_order,
|
||
+ datetime=datetime,
|
||
+ timedelta=timedelta)
|
||
+
|
||
+@app.route('/resources/add', methods=['GET', 'POST'])
|
||
+@login_required
|
||
+def add_resources():
|
||
+ """Ressourcen zum Pool hinzufügen"""
|
||
+ # Hole show_test Parameter für die Anzeige
|
||
+ show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
+
|
||
+ if request.method == 'POST':
|
||
+ resource_type = request.form.get('resource_type')
|
||
+ resources_text = request.form.get('resources_text', '')
|
||
+ is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten
|
||
+
|
||
+ # Parse resources (one per line)
|
||
+ resources = [r.strip() for r in resources_text.split('\n') if r.strip()]
|
||
+
|
||
+ if not resources:
|
||
+ flash('Keine Ressourcen angegeben', 'error')
|
||
+ return redirect(url_for('add_resources', show_test=show_test))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ added = 0
|
||
+ duplicates = 0
|
||
+
|
||
+ for resource_value in resources:
|
||
+ try:
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test)
|
||
+ VALUES (%s, %s, %s, %s)
|
||
+ ON CONFLICT (resource_type, resource_value) DO NOTHING
|
||
+ """, (resource_type, resource_value, session['username'], is_test))
|
||
+
|
||
+ if cur.rowcount > 0:
|
||
+ added += 1
|
||
+ # Get the inserted ID
|
||
+ cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s",
|
||
+ (resource_type, resource_value))
|
||
+ resource_id = cur.fetchone()[0]
|
||
+
|
||
+ # Log in history
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, action, action_by, ip_address)
|
||
+ VALUES (%s, 'created', %s, %s)
|
||
+ """, (resource_id, session['username'], get_client_ip()))
|
||
+ else:
|
||
+ duplicates += 1
|
||
+
|
||
+ except Exception as e:
|
||
+ app.logger.error(f"Error adding resource {resource_value}: {e}")
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ log_audit('CREATE', 'resource_pool', None,
|
||
+ new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test},
|
||
+ additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen")
|
||
+
|
||
+ flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success')
|
||
+ return redirect(url_for('resources', show_test=show_test))
|
||
+
|
||
+ return render_template('add_resources.html', show_test=show_test)
|
||
+
|
||
+@app.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
|
||
+@login_required
|
||
+def quarantine_resource(resource_id):
|
||
+ """Ressource in Quarantäne setzen"""
|
||
+ reason = request.form.get('reason', 'review')
|
||
+ until_date = request.form.get('until_date')
|
||
+ notes = request.form.get('notes', '')
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Get current resource info
|
||
+ cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,))
|
||
+ resource = cur.fetchone()
|
||
+
|
||
+ if not resource:
|
||
+ flash('Ressource nicht gefunden', 'error')
|
||
+ return redirect(url_for('resources'))
|
||
+
|
||
+ old_status = resource[2]
|
||
+
|
||
+ # Update resource
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'quarantine',
|
||
+ quarantine_reason = %s,
|
||
+ quarantine_until = %s,
|
||
+ notes = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP,
|
||
+ status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (reason, until_date if until_date else None, notes, session['username'], resource_id))
|
||
+
|
||
+ # Log in history
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, action, action_by, ip_address, details)
|
||
+ VALUES (%s, 'quarantined', %s, %s, %s)
|
||
+ """, (resource_id, session['username'], get_client_ip(),
|
||
+ Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status})))
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ log_audit('UPDATE', 'resource', resource_id,
|
||
+ old_values={'status': old_status},
|
||
+ new_values={'status': 'quarantine', 'reason': reason},
|
||
+ additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne")
|
||
+
|
||
+ flash('Ressource in Quarantäne gesetzt', 'success')
|
||
+
|
||
+ # Redirect mit allen aktuellen Filtern
|
||
+ return redirect(url_for('resources',
|
||
+ show_test=request.args.get('show_test', request.form.get('show_test', 'false')),
|
||
+ type=request.args.get('type', request.form.get('type', '')),
|
||
+ status=request.args.get('status', request.form.get('status', '')),
|
||
+ search=request.args.get('search', request.form.get('search', ''))))
|
||
+
|
||
+@app.route('/resources/release', methods=['POST'])
|
||
+@login_required
|
||
+def release_resources():
|
||
+ """Ressourcen aus Quarantäne freigeben"""
|
||
+ resource_ids = request.form.getlist('resource_ids')
|
||
+
|
||
+ if not resource_ids:
|
||
+ flash('Keine Ressourcen ausgewählt', 'error')
|
||
+ return redirect(url_for('resources'))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ released = 0
|
||
+ for resource_id in resource_ids:
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'available',
|
||
+ quarantine_reason = NULL,
|
||
+ quarantine_until = NULL,
|
||
+ allocated_to_license = NULL,
|
||
+ status_changed_at = CURRENT_TIMESTAMP,
|
||
+ status_changed_by = %s
|
||
+ WHERE id = %s AND status = 'quarantine'
|
||
+ """, (session['username'], resource_id))
|
||
+
|
||
+ if cur.rowcount > 0:
|
||
+ released += 1
|
||
+ # Log in history
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, action, action_by, ip_address)
|
||
+ VALUES (%s, 'released', %s, %s)
|
||
+ """, (resource_id, session['username'], get_client_ip()))
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ log_audit('UPDATE', 'resource_pool', None,
|
||
+ new_values={'released': released},
|
||
+ additional_info=f"{released} Ressourcen aus Quarantäne freigegeben")
|
||
+
|
||
+ flash(f'{released} Ressourcen freigegeben', 'success')
|
||
+
|
||
+ # Redirect mit allen aktuellen Filtern
|
||
+ return redirect(url_for('resources',
|
||
+ show_test=request.args.get('show_test', request.form.get('show_test', 'false')),
|
||
+ type=request.args.get('type', request.form.get('type', '')),
|
||
+ status=request.args.get('status', request.form.get('status', '')),
|
||
+ search=request.args.get('search', request.form.get('search', ''))))
|
||
+
|
||
+@app.route('/api/resources/allocate', methods=['POST'])
|
||
+@login_required
|
||
+def allocate_resources_api():
|
||
+ """API für Ressourcen-Zuweisung bei Lizenzerstellung"""
|
||
+ data = request.json
|
||
+ license_id = data.get('license_id')
|
||
+ domain_count = data.get('domain_count', 1)
|
||
+ ipv4_count = data.get('ipv4_count', 1)
|
||
+ phone_count = data.get('phone_count', 1)
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ try:
|
||
+ allocated = {'domains': [], 'ipv4s': [], 'phones': []}
|
||
+
|
||
+ # Allocate domains
|
||
+ if domain_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id, resource_value FROM resource_pools
|
||
+ WHERE resource_type = 'domain' AND status = 'available'
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (domain_count,))
|
||
+ domains = cur.fetchall()
|
||
+
|
||
+ if len(domains) < domain_count:
|
||
+ raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})")
|
||
+
|
||
+ for domain_id, domain_value in domains:
|
||
+ # Update resource status
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated',
|
||
+ allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP,
|
||
+ status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], domain_id))
|
||
+
|
||
+ # Create assignment
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, domain_id, session['username']))
|
||
+
|
||
+ # Log history
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (domain_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ allocated['domains'].append(domain_value)
|
||
+
|
||
+ # Allocate IPv4s (similar logic)
|
||
+ if ipv4_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id, resource_value FROM resource_pools
|
||
+ WHERE resource_type = 'ipv4' AND status = 'available'
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (ipv4_count,))
|
||
+ ipv4s = cur.fetchall()
|
||
+
|
||
+ if len(ipv4s) < ipv4_count:
|
||
+ raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar")
|
||
+
|
||
+ for ipv4_id, ipv4_value in ipv4s:
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated',
|
||
+ allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP,
|
||
+ status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], ipv4_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, ipv4_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (ipv4_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ allocated['ipv4s'].append(ipv4_value)
|
||
+
|
||
+ # Allocate phones (similar logic)
|
||
+ if phone_count > 0:
|
||
+ cur.execute("""
|
||
+ SELECT id, resource_value FROM resource_pools
|
||
+ WHERE resource_type = 'phone' AND status = 'available'
|
||
+ LIMIT %s FOR UPDATE
|
||
+ """, (phone_count,))
|
||
+ phones = cur.fetchall()
|
||
+
|
||
+ if len(phones) < phone_count:
|
||
+ raise ValueError(f"Nicht genügend Telefonnummern verfügbar")
|
||
+
|
||
+ for phone_id, phone_value in phones:
|
||
+ cur.execute("""
|
||
+ UPDATE resource_pools
|
||
+ SET status = 'allocated',
|
||
+ allocated_to_license = %s,
|
||
+ status_changed_at = CURRENT_TIMESTAMP,
|
||
+ status_changed_by = %s
|
||
+ WHERE id = %s
|
||
+ """, (license_id, session['username'], phone_id))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||
+ VALUES (%s, %s, %s)
|
||
+ """, (license_id, phone_id, session['username']))
|
||
+
|
||
+ cur.execute("""
|
||
+ INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||
+ VALUES (%s, %s, 'allocated', %s, %s)
|
||
+ """, (phone_id, license_id, session['username'], get_client_ip()))
|
||
+
|
||
+ allocated['phones'].append(phone_value)
|
||
+
|
||
+ # Update license resource counts
|
||
+ cur.execute("""
|
||
+ UPDATE licenses
|
||
+ SET domain_count = %s,
|
||
+ ipv4_count = %s,
|
||
+ phone_count = %s
|
||
+ WHERE id = %s
|
||
+ """, (domain_count, ipv4_count, phone_count, license_id))
|
||
+
|
||
+ conn.commit()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'success': True,
|
||
+ 'allocated': allocated
|
||
+ })
|
||
+
|
||
+ except Exception as e:
|
||
+ conn.rollback()
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+ return jsonify({
|
||
+ 'success': False,
|
||
+ 'error': str(e)
|
||
+ }), 400
|
||
+
|
||
+@app.route('/api/resources/check-availability', methods=['GET'])
|
||
+@login_required
|
||
+def check_resource_availability():
|
||
+ """Prüft verfügbare Ressourcen"""
|
||
+ resource_type = request.args.get('type', '')
|
||
+ count = request.args.get('count', 10, type=int)
|
||
+ show_test = request.args.get('show_test', 'false').lower() == 'true'
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ if resource_type:
|
||
+ # Spezifische Ressourcen für einen Typ
|
||
+ cur.execute("""
|
||
+ SELECT id, resource_value
|
||
+ FROM resource_pools
|
||
+ WHERE status = 'available'
|
||
+ AND resource_type = %s
|
||
+ AND is_test = %s
|
||
+ ORDER BY resource_value
|
||
+ LIMIT %s
|
||
+ """, (resource_type, show_test, count))
|
||
+
|
||
+ resources = []
|
||
+ for row in cur.fetchall():
|
||
+ resources.append({
|
||
+ 'id': row[0],
|
||
+ 'value': row[1]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'available': resources,
|
||
+ 'type': resource_type,
|
||
+ 'count': len(resources)
|
||
+ })
|
||
+ else:
|
||
+ # Zusammenfassung aller Typen
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ resource_type,
|
||
+ COUNT(*) as available
|
||
+ FROM resource_pools
|
||
+ WHERE status = 'available'
|
||
+ AND is_test = %s
|
||
+ GROUP BY resource_type
|
||
+ """, (show_test,))
|
||
+
|
||
+ availability = {}
|
||
+ for row in cur.fetchall():
|
||
+ availability[row[0]] = row[1]
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify(availability)
|
||
+
|
||
+@app.route('/api/global-search', methods=['GET'])
|
||
+@login_required
|
||
+def global_search():
|
||
+ """Global search API endpoint for searching customers and licenses"""
|
||
+ query = request.args.get('q', '').strip()
|
||
+
|
||
+ if not query or len(query) < 2:
|
||
+ return jsonify({'customers': [], 'licenses': []})
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Search pattern with wildcards
|
||
+ search_pattern = f'%{query}%'
|
||
+
|
||
+ # Search customers
|
||
+ cur.execute("""
|
||
+ SELECT id, name, email, company_name
|
||
+ FROM customers
|
||
+ WHERE (LOWER(name) LIKE LOWER(%s)
|
||
+ OR LOWER(email) LIKE LOWER(%s)
|
||
+ OR LOWER(company_name) LIKE LOWER(%s))
|
||
+ AND is_test = FALSE
|
||
+ ORDER BY name
|
||
+ LIMIT 5
|
||
+ """, (search_pattern, search_pattern, search_pattern))
|
||
+
|
||
+ customers = []
|
||
+ for row in cur.fetchall():
|
||
+ customers.append({
|
||
+ 'id': row[0],
|
||
+ 'name': row[1],
|
||
+ 'email': row[2],
|
||
+ 'company_name': row[3]
|
||
+ })
|
||
+
|
||
+ # Search licenses
|
||
+ cur.execute("""
|
||
+ SELECT l.id, l.license_key, c.name as customer_name
|
||
+ FROM licenses l
|
||
+ JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE LOWER(l.license_key) LIKE LOWER(%s)
|
||
+ AND l.is_test = FALSE
|
||
+ ORDER BY l.created_at DESC
|
||
+ LIMIT 5
|
||
+ """, (search_pattern,))
|
||
+
|
||
+ licenses = []
|
||
+ for row in cur.fetchall():
|
||
+ licenses.append({
|
||
+ 'id': row[0],
|
||
+ 'license_key': row[1],
|
||
+ 'customer_name': row[2]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return jsonify({
|
||
+ 'customers': customers,
|
||
+ 'licenses': licenses
|
||
+ })
|
||
+
|
||
+@app.route('/resources/history/<int:resource_id>')
|
||
+@login_required
|
||
+def resource_history(resource_id):
|
||
+ """Zeigt die komplette Historie einer Ressource"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Get complete resource info using named columns
|
||
+ cur.execute("""
|
||
+ SELECT id, resource_type, resource_value, status, allocated_to_license,
|
||
+ status_changed_at, status_changed_by, quarantine_reason,
|
||
+ quarantine_until, created_at, notes
|
||
+ FROM resource_pools
|
||
+ WHERE id = %s
|
||
+ """, (resource_id,))
|
||
+ row = cur.fetchone()
|
||
+
|
||
+ if not row:
|
||
+ flash('Ressource nicht gefunden', 'error')
|
||
+ return redirect(url_for('resources'))
|
||
+
|
||
+ # Create resource object with named attributes
|
||
+ resource = {
|
||
+ 'id': row[0],
|
||
+ 'resource_type': row[1],
|
||
+ 'resource_value': row[2],
|
||
+ 'status': row[3],
|
||
+ 'allocated_to_license': row[4],
|
||
+ 'status_changed_at': row[5],
|
||
+ 'status_changed_by': row[6],
|
||
+ 'quarantine_reason': row[7],
|
||
+ 'quarantine_until': row[8],
|
||
+ 'created_at': row[9],
|
||
+ 'notes': row[10]
|
||
+ }
|
||
+
|
||
+ # Get license info if allocated
|
||
+ license_info = None
|
||
+ if resource['allocated_to_license']:
|
||
+ cur.execute("SELECT license_key FROM licenses WHERE id = %s",
|
||
+ (resource['allocated_to_license'],))
|
||
+ lic = cur.fetchone()
|
||
+ if lic:
|
||
+ license_info = {'license_key': lic[0]}
|
||
+
|
||
+ # Get history with named columns
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ rh.action,
|
||
+ rh.action_by,
|
||
+ rh.action_at,
|
||
+ rh.details,
|
||
+ rh.license_id,
|
||
+ rh.ip_address
|
||
+ FROM resource_history rh
|
||
+ WHERE rh.resource_id = %s
|
||
+ ORDER BY rh.action_at DESC
|
||
+ """, (resource_id,))
|
||
+
|
||
+ history = []
|
||
+ for row in cur.fetchall():
|
||
+ history.append({
|
||
+ 'action': row[0],
|
||
+ 'action_by': row[1],
|
||
+ 'action_at': row[2],
|
||
+ 'details': row[3],
|
||
+ 'license_id': row[4],
|
||
+ 'ip_address': row[5]
|
||
+ })
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Convert to object-like for template
|
||
+ class ResourceObj:
|
||
+ def __init__(self, data):
|
||
+ for key, value in data.items():
|
||
+ setattr(self, key, value)
|
||
+
|
||
+ resource_obj = ResourceObj(resource)
|
||
+ history_objs = [ResourceObj(h) for h in history]
|
||
+
|
||
+ return render_template('resource_history.html',
|
||
+ resource=resource_obj,
|
||
+ license_info=license_info,
|
||
+ history=history_objs)
|
||
+
|
||
+@app.route('/resources/metrics')
|
||
+@login_required
|
||
+def resources_metrics():
|
||
+ """Dashboard für Resource Metrics und Reports"""
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ # Overall stats with fallback values
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ COUNT(DISTINCT resource_id) as total_resources,
|
||
+ COALESCE(AVG(performance_score), 0) as avg_performance,
|
||
+ COALESCE(SUM(cost), 0) as total_cost,
|
||
+ COALESCE(SUM(revenue), 0) as total_revenue,
|
||
+ COALESCE(SUM(issues_count), 0) as total_issues
|
||
+ FROM resource_metrics
|
||
+ WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
+ """)
|
||
+ row = cur.fetchone()
|
||
+
|
||
+ # Calculate ROI
|
||
+ roi = 0
|
||
+ if row[2] > 0: # if total_cost > 0
|
||
+ roi = row[3] / row[2] # revenue / cost
|
||
+
|
||
+ stats = {
|
||
+ 'total_resources': row[0] or 0,
|
||
+ 'avg_performance': row[1] or 0,
|
||
+ 'total_cost': row[2] or 0,
|
||
+ 'total_revenue': row[3] or 0,
|
||
+ 'total_issues': row[4] or 0,
|
||
+ 'roi': roi
|
||
+ }
|
||
+
|
||
+ # Performance by type
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ rp.resource_type,
|
||
+ COALESCE(AVG(rm.performance_score), 0) as avg_score,
|
||
+ COUNT(DISTINCT rp.id) as resource_count
|
||
+ FROM resource_pools rp
|
||
+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
+ GROUP BY rp.resource_type
|
||
+ ORDER BY rp.resource_type
|
||
+ """)
|
||
+ performance_by_type = cur.fetchall()
|
||
+
|
||
+ # Utilization data
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ resource_type,
|
||
+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
+ COUNT(*) as total,
|
||
+ ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent
|
||
+ FROM resource_pools
|
||
+ GROUP BY resource_type
|
||
+ """)
|
||
+ utilization_rows = cur.fetchall()
|
||
+ utilization_data = [
|
||
+ {
|
||
+ 'type': row[0].upper(),
|
||
+ 'allocated': row[1],
|
||
+ 'total': row[2],
|
||
+ 'allocated_percent': row[3]
|
||
+ }
|
||
+ for row in utilization_rows
|
||
+ ]
|
||
+
|
||
+ # Top performing resources
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ rp.id,
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ COALESCE(AVG(rm.performance_score), 0) as avg_score,
|
||
+ COALESCE(SUM(rm.revenue), 0) as total_revenue,
|
||
+ COALESCE(SUM(rm.cost), 1) as total_cost,
|
||
+ CASE
|
||
+ WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0
|
||
+ ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1)
|
||
+ END as roi
|
||
+ FROM resource_pools rp
|
||
+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
+ WHERE rp.status != 'quarantine'
|
||
+ GROUP BY rp.id, rp.resource_type, rp.resource_value
|
||
+ HAVING AVG(rm.performance_score) IS NOT NULL
|
||
+ ORDER BY avg_score DESC
|
||
+ LIMIT 10
|
||
+ """)
|
||
+ top_rows = cur.fetchall()
|
||
+ top_performers = [
|
||
+ {
|
||
+ 'id': row[0],
|
||
+ 'resource_type': row[1],
|
||
+ 'resource_value': row[2],
|
||
+ 'avg_score': row[3],
|
||
+ 'roi': row[6]
|
||
+ }
|
||
+ for row in top_rows
|
||
+ ]
|
||
+
|
||
+ # Resources with issues
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ rp.id,
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ rp.status,
|
||
+ COALESCE(SUM(rm.issues_count), 0) as total_issues
|
||
+ FROM resource_pools rp
|
||
+ LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
+ AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
+ WHERE rm.issues_count > 0 OR rp.status = 'quarantine'
|
||
+ GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status
|
||
+ HAVING SUM(rm.issues_count) > 0
|
||
+ ORDER BY total_issues DESC
|
||
+ LIMIT 10
|
||
+ """)
|
||
+ problem_rows = cur.fetchall()
|
||
+ problem_resources = [
|
||
+ {
|
||
+ 'id': row[0],
|
||
+ 'resource_type': row[1],
|
||
+ 'resource_value': row[2],
|
||
+ 'status': row[3],
|
||
+ 'total_issues': row[4]
|
||
+ }
|
||
+ for row in problem_rows
|
||
+ ]
|
||
+
|
||
+ # Daily metrics for trend chart (last 30 days)
|
||
+ cur.execute("""
|
||
+ SELECT
|
||
+ metric_date,
|
||
+ COALESCE(AVG(performance_score), 0) as avg_performance,
|
||
+ COALESCE(SUM(issues_count), 0) as total_issues
|
||
+ FROM resource_metrics
|
||
+ WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days'
|
||
+ GROUP BY metric_date
|
||
+ ORDER BY metric_date
|
||
+ """)
|
||
+ daily_rows = cur.fetchall()
|
||
+ daily_metrics = [
|
||
+ {
|
||
+ 'date': row[0].strftime('%d.%m'),
|
||
+ 'performance': float(row[1]),
|
||
+ 'issues': int(row[2])
|
||
+ }
|
||
+ for row in daily_rows
|
||
+ ]
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ return render_template('resource_metrics.html',
|
||
+ stats=stats,
|
||
+ performance_by_type=performance_by_type,
|
||
+ utilization_data=utilization_data,
|
||
+ top_performers=top_performers,
|
||
+ problem_resources=problem_resources,
|
||
+ daily_metrics=daily_metrics)
|
||
+
|
||
+@app.route('/resources/report', methods=['GET'])
|
||
+@login_required
|
||
+def resources_report():
|
||
+ """Generiert Ressourcen-Reports oder zeigt Report-Formular"""
|
||
+ # Prüfe ob Download angefordert wurde
|
||
+ if request.args.get('download') == 'true':
|
||
+ report_type = request.args.get('type', 'usage')
|
||
+ format_type = request.args.get('format', 'excel')
|
||
+ date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d'))
|
||
+ date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d'))
|
||
+
|
||
+ conn = get_connection()
|
||
+ cur = conn.cursor()
|
||
+
|
||
+ if report_type == 'usage':
|
||
+ # Auslastungsreport
|
||
+ query = """
|
||
+ SELECT
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ rp.status,
|
||
+ COUNT(DISTINCT rh.license_id) as unique_licenses,
|
||
+ COUNT(rh.id) as total_allocations,
|
||
+ MIN(rh.action_at) as first_used,
|
||
+ MAX(rh.action_at) as last_used
|
||
+ FROM resource_pools rp
|
||
+ LEFT JOIN resource_history rh ON rp.id = rh.resource_id
|
||
+ AND rh.action = 'allocated'
|
||
+ AND rh.action_at BETWEEN %s AND %s
|
||
+ GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status
|
||
+ ORDER BY rp.resource_type, total_allocations DESC
|
||
+ """
|
||
+ cur.execute(query, (date_from, date_to))
|
||
+ columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung']
|
||
+
|
||
+ elif report_type == 'performance':
|
||
+ # Performance-Report
|
||
+ query = """
|
||
+ SELECT
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ AVG(rm.performance_score) as avg_performance,
|
||
+ SUM(rm.usage_count) as total_usage,
|
||
+ SUM(rm.revenue) as total_revenue,
|
||
+ SUM(rm.cost) as total_cost,
|
||
+ SUM(rm.revenue - rm.cost) as profit,
|
||
+ SUM(rm.issues_count) as total_issues
|
||
+ FROM resource_pools rp
|
||
+ JOIN resource_metrics rm ON rp.id = rm.resource_id
|
||
+ WHERE rm.metric_date BETWEEN %s AND %s
|
||
+ GROUP BY rp.id, rp.resource_type, rp.resource_value
|
||
+ ORDER BY profit DESC
|
||
+ """
|
||
+ cur.execute(query, (date_from, date_to))
|
||
+ columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues']
|
||
+
|
||
+ elif report_type == 'compliance':
|
||
+ # Compliance-Report
|
||
+ query = """
|
||
+ SELECT
|
||
+ rh.action_at,
|
||
+ rh.action,
|
||
+ rh.action_by,
|
||
+ rp.resource_type,
|
||
+ rp.resource_value,
|
||
+ l.license_key,
|
||
+ c.name as customer_name,
|
||
+ rh.ip_address
|
||
+ FROM resource_history rh
|
||
+ JOIN resource_pools rp ON rh.resource_id = rp.id
|
||
+ LEFT JOIN licenses l ON rh.license_id = l.id
|
||
+ LEFT JOIN customers c ON l.customer_id = c.id
|
||
+ WHERE rh.action_at BETWEEN %s AND %s
|
||
+ ORDER BY rh.action_at DESC
|
||
+ """
|
||
+ cur.execute(query, (date_from, date_to))
|
||
+ columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse']
|
||
+
|
||
+ else: # inventory report
|
||
+ # Inventar-Report
|
||
+ query = """
|
||
+ SELECT
|
||
+ resource_type,
|
||
+ COUNT(*) FILTER (WHERE status = 'available') as available,
|
||
+ COUNT(*) FILTER (WHERE status = 'allocated') as allocated,
|
||
+ COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||
+ COUNT(*) as total
|
||
+ FROM resource_pools
|
||
+ GROUP BY resource_type
|
||
+ ORDER BY resource_type
|
||
+ """
|
||
+ cur.execute(query)
|
||
+ columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt']
|
||
+
|
||
+ # Convert to DataFrame
|
||
+ data = cur.fetchall()
|
||
+ df = pd.DataFrame(data, columns=columns)
|
||
+
|
||
+ cur.close()
|
||
+ conn.close()
|
||
+
|
||
+ # Generate file
|
||
+ timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||
+ filename = f"resource_report_{report_type}_{timestamp}"
|
||
+
|
||
+ if format_type == 'excel':
|
||
+ output = io.BytesIO()
|
||
+ with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||
+ df.to_excel(writer, sheet_name='Report', index=False)
|
||
+
|
||
+ # Auto-adjust columns width
|
||
+ worksheet = writer.sheets['Report']
|
||
+ for column in worksheet.columns:
|
||
+ max_length = 0
|
||
+ column = [cell for cell in column]
|
||
+ for cell in column:
|
||
+ try:
|
||
+ if len(str(cell.value)) > max_length:
|
||
+ max_length = len(str(cell.value))
|
||
+ except:
|
||
+ pass
|
||
+ adjusted_width = (max_length + 2)
|
||
+ worksheet.column_dimensions[column[0].column_letter].width = adjusted_width
|
||
+
|
||
+ output.seek(0)
|
||
+
|
||
+ log_audit('EXPORT', 'resource_report', None,
|
||
+ new_values={'type': report_type, 'format': 'excel', 'rows': len(df)},
|
||
+ additional_info=f"Resource Report {report_type} exportiert")
|
||
+
|
||
+ return send_file(output,
|
||
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.xlsx')
|
||
+
|
||
+ else: # CSV
|
||
+ output = io.StringIO()
|
||
+ df.to_csv(output, index=False, sep=';', encoding='utf-8-sig')
|
||
+ output.seek(0)
|
||
+
|
||
+ log_audit('EXPORT', 'resource_report', None,
|
||
+ new_values={'type': report_type, 'format': 'csv', 'rows': len(df)},
|
||
+ additional_info=f"Resource Report {report_type} exportiert")
|
||
+
|
||
+ return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')),
|
||
+ mimetype='text/csv',
|
||
+ as_attachment=True,
|
||
+ download_name=f'{filename}.csv')
|
||
+
|
||
+ # Wenn kein Download, zeige Report-Formular
|
||
+ return render_template('resource_report.html',
|
||
+ datetime=datetime,
|
||
+ timedelta=timedelta,
|
||
+ username=session.get('username'))
|
||
+
|
||
+if __name__ == "__main__":
|
||
+ app.run(host="0.0.0.0", port=5000)
|
||
diff --git a/v2_adminpanel/comment_routes.py b/v2_adminpanel/comment_routes.py
|
||
deleted file mode 100644
|
||
index 1c5c44d..0000000
|
||
--- a/v2_adminpanel/comment_routes.py
|
||
+++ /dev/null
|
||
@@ -1,42 +0,0 @@
|
||
-#!/usr/bin/env python3
|
||
-"""
|
||
-Script to comment out routes that have been moved to blueprints
|
||
-"""
|
||
-
|
||
-# Routes that have been moved to auth_routes.py
|
||
-auth_routes = [
|
||
- ("@app.route(\"/login\"", "def login():", 138, 251), # login route
|
||
- ("@app.route(\"/logout\")", "def logout():", 252, 263), # logout route
|
||
- ("@app.route(\"/verify-2fa\"", "def verify_2fa():", 264, 342), # verify-2fa route
|
||
- ("@app.route(\"/profile\")", "def profile():", 343, 352), # profile route
|
||
- ("@app.route(\"/profile/change-password\"", "def change_password():", 353, 390), # change-password route
|
||
- ("@app.route(\"/profile/setup-2fa\")", "def setup_2fa():", 391, 410), # setup-2fa route
|
||
- ("@app.route(\"/profile/enable-2fa\"", "def enable_2fa():", 411, 448), # enable-2fa route
|
||
- ("@app.route(\"/profile/disable-2fa\"", "def disable_2fa():", 449, 475), # disable-2fa route
|
||
- ("@app.route(\"/heartbeat\"", "def heartbeat():", 476, 489), # heartbeat route
|
||
-]
|
||
-
|
||
-# Routes that have been moved to admin_routes.py
|
||
-admin_routes = [
|
||
- ("@app.route(\"/\")", "def dashboard():", 647, 870), # dashboard route
|
||
- ("@app.route(\"/audit\")", "def audit_log():", 2772, 2866), # audit route
|
||
- ("@app.route(\"/backups\")", "def backups():", 2866, 2901), # backups route
|
||
- ("@app.route(\"/backup/create\"", "def create_backup_route():", 2901, 2919), # backup/create route
|
||
- ("@app.route(\"/backup/restore/<int:backup_id>\"", "def restore_backup_route(backup_id):", 2919, 2938), # backup/restore route
|
||
- ("@app.route(\"/backup/download/<int:backup_id>\")", "def download_backup(backup_id):", 2938, 2970), # backup/download route
|
||
- ("@app.route(\"/backup/delete/<int:backup_id>\"", "def delete_backup(backup_id):", 2970, 3026), # backup/delete route
|
||
- ("@app.route(\"/security/blocked-ips\")", "def blocked_ips():", 3026, 3067), # security/blocked-ips route
|
||
- ("@app.route(\"/security/unblock-ip\"", "def unblock_ip():", 3067, 3093), # security/unblock-ip route
|
||
- ("@app.route(\"/security/clear-attempts\"", "def clear_attempts():", 3093, 3119), # security/clear-attempts route
|
||
-]
|
||
-
|
||
-print("This script would comment out the following routes:")
|
||
-print("\nAuth routes:")
|
||
-for route in auth_routes:
|
||
- print(f" - {route[0]} (lines {route[2]}-{route[3]})")
|
||
-
|
||
-print("\nAdmin routes:")
|
||
-for route in admin_routes:
|
||
- print(f" - {route[0]} (lines {route[2]}-{route[3]})")
|
||
-
|
||
-print("\nNote: Manual verification and adjustment of line numbers is recommended before running the actual commenting.")
|
||
\ No newline at end of file
|
||
diff --git a/v2_adminpanel/init.sql b/v2_adminpanel/init.sql
|
||
index fc91a66..5754e24 100644
|
||
--- a/v2_adminpanel/init.sql
|
||
+++ b/v2_adminpanel/init.sql
|
||
@@ -1,282 +1,282 @@
|
||
--- UTF-8 Encoding für deutsche Sonderzeichen sicherstellen
|
||
-SET client_encoding = 'UTF8';
|
||
-
|
||
--- Zeitzone auf Europe/Berlin setzen
|
||
-SET timezone = 'Europe/Berlin';
|
||
-
|
||
-CREATE TABLE IF NOT EXISTS customers (
|
||
- id SERIAL PRIMARY KEY,
|
||
- name TEXT NOT NULL,
|
||
- email TEXT,
|
||
- is_test BOOLEAN DEFAULT FALSE,
|
||
- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- CONSTRAINT unique_email UNIQUE (email)
|
||
-);
|
||
-
|
||
-CREATE TABLE IF NOT EXISTS licenses (
|
||
- id SERIAL PRIMARY KEY,
|
||
- license_key TEXT UNIQUE NOT NULL,
|
||
- customer_id INTEGER REFERENCES customers(id),
|
||
- license_type TEXT NOT NULL,
|
||
- valid_from DATE NOT NULL,
|
||
- valid_until DATE NOT NULL,
|
||
- is_active BOOLEAN DEFAULT TRUE,
|
||
- is_test BOOLEAN DEFAULT FALSE,
|
||
- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||
-);
|
||
-
|
||
-CREATE TABLE IF NOT EXISTS sessions (
|
||
- id SERIAL PRIMARY KEY,
|
||
- license_id INTEGER REFERENCES licenses(id),
|
||
- session_id TEXT UNIQUE NOT NULL,
|
||
- ip_address TEXT,
|
||
- user_agent TEXT,
|
||
- started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- ended_at TIMESTAMP WITH TIME ZONE,
|
||
- is_active BOOLEAN DEFAULT TRUE
|
||
-);
|
||
-
|
||
--- Audit-Log-Tabelle für Änderungsprotokolle
|
||
-CREATE TABLE IF NOT EXISTS audit_log (
|
||
- id SERIAL PRIMARY KEY,
|
||
- timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- username TEXT NOT NULL,
|
||
- action TEXT NOT NULL,
|
||
- entity_type TEXT NOT NULL,
|
||
- entity_id INTEGER,
|
||
- old_values JSONB,
|
||
- new_values JSONB,
|
||
- ip_address TEXT,
|
||
- user_agent TEXT,
|
||
- additional_info TEXT
|
||
-);
|
||
-
|
||
--- Index für bessere Performance bei Abfragen
|
||
-CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC);
|
||
-CREATE INDEX idx_audit_log_username ON audit_log(username);
|
||
-CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id);
|
||
-
|
||
--- Backup-Historie-Tabelle
|
||
-CREATE TABLE IF NOT EXISTS backup_history (
|
||
- id SERIAL PRIMARY KEY,
|
||
- filename TEXT NOT NULL,
|
||
- filepath TEXT NOT NULL,
|
||
- filesize BIGINT,
|
||
- backup_type TEXT NOT NULL, -- 'manual' oder 'scheduled'
|
||
- status TEXT NOT NULL, -- 'success', 'failed', 'in_progress'
|
||
- error_message TEXT,
|
||
- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- created_by TEXT NOT NULL,
|
||
- tables_count INTEGER,
|
||
- records_count INTEGER,
|
||
- duration_seconds NUMERIC,
|
||
- is_encrypted BOOLEAN DEFAULT TRUE
|
||
-);
|
||
-
|
||
--- Index für bessere Performance
|
||
-CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC);
|
||
-CREATE INDEX idx_backup_history_status ON backup_history(status);
|
||
-
|
||
--- Login-Attempts-Tabelle für Rate-Limiting
|
||
-CREATE TABLE IF NOT EXISTS login_attempts (
|
||
- ip_address VARCHAR(45) PRIMARY KEY,
|
||
- attempt_count INTEGER DEFAULT 0,
|
||
- first_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- last_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- blocked_until TIMESTAMP WITH TIME ZONE NULL,
|
||
- last_username_tried TEXT,
|
||
- last_error_message TEXT
|
||
-);
|
||
-
|
||
--- Index für schnelle Abfragen
|
||
-CREATE INDEX idx_login_attempts_blocked_until ON login_attempts(blocked_until);
|
||
-CREATE INDEX idx_login_attempts_last_attempt ON login_attempts(last_attempt DESC);
|
||
-
|
||
--- Migration: Füge created_at zu licenses hinzu, falls noch nicht vorhanden
|
||
-DO $$
|
||
-BEGIN
|
||
- IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
- WHERE table_name = 'licenses' AND column_name = 'created_at') THEN
|
||
- ALTER TABLE licenses ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
|
||
-
|
||
- -- Setze created_at für bestehende Einträge auf das valid_from Datum
|
||
- UPDATE licenses SET created_at = valid_from WHERE created_at IS NULL;
|
||
- END IF;
|
||
-END $$;
|
||
-
|
||
--- ===================== RESOURCE POOL SYSTEM =====================
|
||
-
|
||
--- Haupttabelle für den Resource Pool
|
||
-CREATE TABLE IF NOT EXISTS resource_pools (
|
||
- id SERIAL PRIMARY KEY,
|
||
- resource_type VARCHAR(20) NOT NULL CHECK (resource_type IN ('domain', 'ipv4', 'phone')),
|
||
- resource_value VARCHAR(255) NOT NULL,
|
||
- status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('available', 'allocated', 'quarantine')),
|
||
- allocated_to_license INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
|
||
- status_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- status_changed_by VARCHAR(50),
|
||
- quarantine_reason VARCHAR(100) CHECK (quarantine_reason IN ('abuse', 'defect', 'maintenance', 'blacklisted', 'expired', 'review', NULL)),
|
||
- quarantine_until TIMESTAMP WITH TIME ZONE,
|
||
- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- notes TEXT,
|
||
- is_test BOOLEAN DEFAULT FALSE,
|
||
- UNIQUE(resource_type, resource_value)
|
||
-);
|
||
-
|
||
--- Resource History für vollständige Nachverfolgbarkeit
|
||
-CREATE TABLE IF NOT EXISTS resource_history (
|
||
- id SERIAL PRIMARY KEY,
|
||
- resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||
- license_id INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
|
||
- action VARCHAR(50) NOT NULL CHECK (action IN ('allocated', 'deallocated', 'quarantined', 'released', 'created', 'deleted')),
|
||
- action_by VARCHAR(50) NOT NULL,
|
||
- action_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- details JSONB,
|
||
- ip_address TEXT
|
||
-);
|
||
-
|
||
--- Resource Metrics für Performance-Tracking und ROI
|
||
-CREATE TABLE IF NOT EXISTS resource_metrics (
|
||
- id SERIAL PRIMARY KEY,
|
||
- resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||
- metric_date DATE NOT NULL,
|
||
- usage_count INTEGER DEFAULT 0,
|
||
- performance_score DECIMAL(5,2) DEFAULT 0.00,
|
||
- cost DECIMAL(10,2) DEFAULT 0.00,
|
||
- revenue DECIMAL(10,2) DEFAULT 0.00,
|
||
- issues_count INTEGER DEFAULT 0,
|
||
- availability_percent DECIMAL(5,2) DEFAULT 100.00,
|
||
- UNIQUE(resource_id, metric_date)
|
||
-);
|
||
-
|
||
--- Zuordnungstabelle zwischen Lizenzen und Ressourcen
|
||
-CREATE TABLE IF NOT EXISTS license_resources (
|
||
- id SERIAL PRIMARY KEY,
|
||
- license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||
- resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||
- assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- assigned_by VARCHAR(50),
|
||
- is_active BOOLEAN DEFAULT TRUE,
|
||
- UNIQUE(license_id, resource_id)
|
||
-);
|
||
-
|
||
--- Erweiterung der licenses Tabelle um Resource-Counts
|
||
-DO $$
|
||
-BEGIN
|
||
- IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
- WHERE table_name = 'licenses' AND column_name = 'domain_count') THEN
|
||
- ALTER TABLE licenses
|
||
- ADD COLUMN domain_count INTEGER DEFAULT 1 CHECK (domain_count >= 0 AND domain_count <= 10),
|
||
- ADD COLUMN ipv4_count INTEGER DEFAULT 1 CHECK (ipv4_count >= 0 AND ipv4_count <= 10),
|
||
- ADD COLUMN phone_count INTEGER DEFAULT 1 CHECK (phone_count >= 0 AND phone_count <= 10);
|
||
- END IF;
|
||
-END $$;
|
||
-
|
||
--- Erweiterung der licenses Tabelle um device_limit
|
||
-DO $$
|
||
-BEGIN
|
||
- IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
- WHERE table_name = 'licenses' AND column_name = 'device_limit') THEN
|
||
- ALTER TABLE licenses
|
||
- ADD COLUMN device_limit INTEGER DEFAULT 3 CHECK (device_limit >= 1 AND device_limit <= 10);
|
||
- END IF;
|
||
-END $$;
|
||
-
|
||
--- Tabelle für Geräte-Registrierungen
|
||
-CREATE TABLE IF NOT EXISTS device_registrations (
|
||
- id SERIAL PRIMARY KEY,
|
||
- license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||
- hardware_id TEXT NOT NULL,
|
||
- device_name TEXT,
|
||
- operating_system TEXT,
|
||
- first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- is_active BOOLEAN DEFAULT TRUE,
|
||
- deactivated_at TIMESTAMP WITH TIME ZONE,
|
||
- deactivated_by TEXT,
|
||
- ip_address TEXT,
|
||
- user_agent TEXT,
|
||
- UNIQUE(license_id, hardware_id)
|
||
-);
|
||
-
|
||
--- Indizes für device_registrations
|
||
-CREATE INDEX IF NOT EXISTS idx_device_license ON device_registrations(license_id);
|
||
-CREATE INDEX IF NOT EXISTS idx_device_hardware ON device_registrations(hardware_id);
|
||
-CREATE INDEX IF NOT EXISTS idx_device_active ON device_registrations(license_id, is_active) WHERE is_active = TRUE;
|
||
-
|
||
--- Indizes für Performance
|
||
-CREATE INDEX IF NOT EXISTS idx_resource_status ON resource_pools(status);
|
||
-CREATE INDEX IF NOT EXISTS idx_resource_type_status ON resource_pools(resource_type, status);
|
||
-CREATE INDEX IF NOT EXISTS idx_resource_allocated ON resource_pools(allocated_to_license) WHERE allocated_to_license IS NOT NULL;
|
||
-CREATE INDEX IF NOT EXISTS idx_resource_quarantine ON resource_pools(quarantine_until) WHERE status = 'quarantine';
|
||
-CREATE INDEX IF NOT EXISTS idx_resource_history_date ON resource_history(action_at DESC);
|
||
-CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id);
|
||
-CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC);
|
||
-CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE;
|
||
-
|
||
--- Users table for authentication with password and 2FA support
|
||
-CREATE TABLE IF NOT EXISTS users (
|
||
- id SERIAL PRIMARY KEY,
|
||
- username VARCHAR(50) UNIQUE NOT NULL,
|
||
- password_hash VARCHAR(255) NOT NULL,
|
||
- email VARCHAR(100),
|
||
- totp_secret VARCHAR(32),
|
||
- totp_enabled BOOLEAN DEFAULT FALSE,
|
||
- backup_codes TEXT, -- JSON array of hashed backup codes
|
||
- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
- password_reset_token VARCHAR(64),
|
||
- password_reset_expires TIMESTAMP WITH TIME ZONE,
|
||
- failed_2fa_attempts INTEGER DEFAULT 0,
|
||
- last_failed_2fa TIMESTAMP WITH TIME ZONE
|
||
-);
|
||
-
|
||
--- Index for faster login lookups
|
||
-CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||
-CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL;
|
||
-
|
||
--- Migration: Add is_test column to licenses if it doesn't exist
|
||
-DO $$
|
||
-BEGIN
|
||
- IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
- WHERE table_name = 'licenses' AND column_name = 'is_test') THEN
|
||
- ALTER TABLE licenses ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
|
||
-
|
||
- -- Mark all existing licenses as test data
|
||
- UPDATE licenses SET is_test = TRUE;
|
||
-
|
||
- -- Add index for better performance when filtering test data
|
||
- CREATE INDEX idx_licenses_is_test ON licenses(is_test);
|
||
- END IF;
|
||
-END $$;
|
||
-
|
||
--- Migration: Add is_test column to customers if it doesn't exist
|
||
-DO $$
|
||
-BEGIN
|
||
- IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
- WHERE table_name = 'customers' AND column_name = 'is_test') THEN
|
||
- ALTER TABLE customers ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
|
||
-
|
||
- -- Mark all existing customers as test data
|
||
- UPDATE customers SET is_test = TRUE;
|
||
-
|
||
- -- Add index for better performance
|
||
- CREATE INDEX idx_customers_is_test ON customers(is_test);
|
||
- END IF;
|
||
-END $$;
|
||
-
|
||
--- Migration: Add is_test column to resource_pools if it doesn't exist
|
||
-DO $$
|
||
-BEGIN
|
||
- IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
- WHERE table_name = 'resource_pools' AND column_name = 'is_test') THEN
|
||
- ALTER TABLE resource_pools ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
|
||
-
|
||
- -- Mark all existing resources as test data
|
||
- UPDATE resource_pools SET is_test = TRUE;
|
||
-
|
||
- -- Add index for better performance
|
||
- CREATE INDEX idx_resource_pools_is_test ON resource_pools(is_test);
|
||
- END IF;
|
||
-END $$;
|
||
+-- UTF-8 Encoding für deutsche Sonderzeichen sicherstellen
|
||
+SET client_encoding = 'UTF8';
|
||
+
|
||
+-- Zeitzone auf Europe/Berlin setzen
|
||
+SET timezone = 'Europe/Berlin';
|
||
+
|
||
+CREATE TABLE IF NOT EXISTS customers (
|
||
+ id SERIAL PRIMARY KEY,
|
||
+ name TEXT NOT NULL,
|
||
+ email TEXT,
|
||
+ is_test BOOLEAN DEFAULT FALSE,
|
||
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ CONSTRAINT unique_email UNIQUE (email)
|
||
+);
|
||
+
|
||
+CREATE TABLE IF NOT EXISTS licenses (
|
||
+ id SERIAL PRIMARY KEY,
|
||
+ license_key TEXT UNIQUE NOT NULL,
|
||
+ customer_id INTEGER REFERENCES customers(id),
|
||
+ license_type TEXT NOT NULL,
|
||
+ valid_from DATE NOT NULL,
|
||
+ valid_until DATE NOT NULL,
|
||
+ is_active BOOLEAN DEFAULT TRUE,
|
||
+ is_test BOOLEAN DEFAULT FALSE,
|
||
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||
+);
|
||
+
|
||
+CREATE TABLE IF NOT EXISTS sessions (
|
||
+ id SERIAL PRIMARY KEY,
|
||
+ license_id INTEGER REFERENCES licenses(id),
|
||
+ session_id TEXT UNIQUE NOT NULL,
|
||
+ ip_address TEXT,
|
||
+ user_agent TEXT,
|
||
+ started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ ended_at TIMESTAMP WITH TIME ZONE,
|
||
+ is_active BOOLEAN DEFAULT TRUE
|
||
+);
|
||
+
|
||
+-- Audit-Log-Tabelle für Änderungsprotokolle
|
||
+CREATE TABLE IF NOT EXISTS audit_log (
|
||
+ id SERIAL PRIMARY KEY,
|
||
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ username TEXT NOT NULL,
|
||
+ action TEXT NOT NULL,
|
||
+ entity_type TEXT NOT NULL,
|
||
+ entity_id INTEGER,
|
||
+ old_values JSONB,
|
||
+ new_values JSONB,
|
||
+ ip_address TEXT,
|
||
+ user_agent TEXT,
|
||
+ additional_info TEXT
|
||
+);
|
||
+
|
||
+-- Index für bessere Performance bei Abfragen
|
||
+CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC);
|
||
+CREATE INDEX idx_audit_log_username ON audit_log(username);
|
||
+CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id);
|
||
+
|
||
+-- Backup-Historie-Tabelle
|
||
+CREATE TABLE IF NOT EXISTS backup_history (
|
||
+ id SERIAL PRIMARY KEY,
|
||
+ filename TEXT NOT NULL,
|
||
+ filepath TEXT NOT NULL,
|
||
+ filesize BIGINT,
|
||
+ backup_type TEXT NOT NULL, -- 'manual' oder 'scheduled'
|
||
+ status TEXT NOT NULL, -- 'success', 'failed', 'in_progress'
|
||
+ error_message TEXT,
|
||
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ created_by TEXT NOT NULL,
|
||
+ tables_count INTEGER,
|
||
+ records_count INTEGER,
|
||
+ duration_seconds NUMERIC,
|
||
+ is_encrypted BOOLEAN DEFAULT TRUE
|
||
+);
|
||
+
|
||
+-- Index für bessere Performance
|
||
+CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC);
|
||
+CREATE INDEX idx_backup_history_status ON backup_history(status);
|
||
+
|
||
+-- Login-Attempts-Tabelle für Rate-Limiting
|
||
+CREATE TABLE IF NOT EXISTS login_attempts (
|
||
+ ip_address VARCHAR(45) PRIMARY KEY,
|
||
+ attempt_count INTEGER DEFAULT 0,
|
||
+ first_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ last_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ blocked_until TIMESTAMP WITH TIME ZONE NULL,
|
||
+ last_username_tried TEXT,
|
||
+ last_error_message TEXT
|
||
+);
|
||
+
|
||
+-- Index für schnelle Abfragen
|
||
+CREATE INDEX idx_login_attempts_blocked_until ON login_attempts(blocked_until);
|
||
+CREATE INDEX idx_login_attempts_last_attempt ON login_attempts(last_attempt DESC);
|
||
+
|
||
+-- Migration: Füge created_at zu licenses hinzu, falls noch nicht vorhanden
|
||
+DO $$
|
||
+BEGIN
|
||
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
+ WHERE table_name = 'licenses' AND column_name = 'created_at') THEN
|
||
+ ALTER TABLE licenses ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
|
||
+
|
||
+ -- Setze created_at für bestehende Einträge auf das valid_from Datum
|
||
+ UPDATE licenses SET created_at = valid_from WHERE created_at IS NULL;
|
||
+ END IF;
|
||
+END $$;
|
||
+
|
||
+-- ===================== RESOURCE POOL SYSTEM =====================
|
||
+
|
||
+-- Haupttabelle für den Resource Pool
|
||
+CREATE TABLE IF NOT EXISTS resource_pools (
|
||
+ id SERIAL PRIMARY KEY,
|
||
+ resource_type VARCHAR(20) NOT NULL CHECK (resource_type IN ('domain', 'ipv4', 'phone')),
|
||
+ resource_value VARCHAR(255) NOT NULL,
|
||
+ status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('available', 'allocated', 'quarantine')),
|
||
+ allocated_to_license INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
|
||
+ status_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ status_changed_by VARCHAR(50),
|
||
+ quarantine_reason VARCHAR(100) CHECK (quarantine_reason IN ('abuse', 'defect', 'maintenance', 'blacklisted', 'expired', 'review', NULL)),
|
||
+ quarantine_until TIMESTAMP WITH TIME ZONE,
|
||
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ notes TEXT,
|
||
+ is_test BOOLEAN DEFAULT FALSE,
|
||
+ UNIQUE(resource_type, resource_value)
|
||
+);
|
||
+
|
||
+-- Resource History für vollständige Nachverfolgbarkeit
|
||
+CREATE TABLE IF NOT EXISTS resource_history (
|
||
+ id SERIAL PRIMARY KEY,
|
||
+ resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||
+ license_id INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
|
||
+ action VARCHAR(50) NOT NULL CHECK (action IN ('allocated', 'deallocated', 'quarantined', 'released', 'created', 'deleted')),
|
||
+ action_by VARCHAR(50) NOT NULL,
|
||
+ action_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ details JSONB,
|
||
+ ip_address TEXT
|
||
+);
|
||
+
|
||
+-- Resource Metrics für Performance-Tracking und ROI
|
||
+CREATE TABLE IF NOT EXISTS resource_metrics (
|
||
+ id SERIAL PRIMARY KEY,
|
||
+ resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||
+ metric_date DATE NOT NULL,
|
||
+ usage_count INTEGER DEFAULT 0,
|
||
+ performance_score DECIMAL(5,2) DEFAULT 0.00,
|
||
+ cost DECIMAL(10,2) DEFAULT 0.00,
|
||
+ revenue DECIMAL(10,2) DEFAULT 0.00,
|
||
+ issues_count INTEGER DEFAULT 0,
|
||
+ availability_percent DECIMAL(5,2) DEFAULT 100.00,
|
||
+ UNIQUE(resource_id, metric_date)
|
||
+);
|
||
+
|
||
+-- Zuordnungstabelle zwischen Lizenzen und Ressourcen
|
||
+CREATE TABLE IF NOT EXISTS license_resources (
|
||
+ id SERIAL PRIMARY KEY,
|
||
+ license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||
+ resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||
+ assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ assigned_by VARCHAR(50),
|
||
+ is_active BOOLEAN DEFAULT TRUE,
|
||
+ UNIQUE(license_id, resource_id)
|
||
+);
|
||
+
|
||
+-- Erweiterung der licenses Tabelle um Resource-Counts
|
||
+DO $$
|
||
+BEGIN
|
||
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
+ WHERE table_name = 'licenses' AND column_name = 'domain_count') THEN
|
||
+ ALTER TABLE licenses
|
||
+ ADD COLUMN domain_count INTEGER DEFAULT 1 CHECK (domain_count >= 0 AND domain_count <= 10),
|
||
+ ADD COLUMN ipv4_count INTEGER DEFAULT 1 CHECK (ipv4_count >= 0 AND ipv4_count <= 10),
|
||
+ ADD COLUMN phone_count INTEGER DEFAULT 1 CHECK (phone_count >= 0 AND phone_count <= 10);
|
||
+ END IF;
|
||
+END $$;
|
||
+
|
||
+-- Erweiterung der licenses Tabelle um device_limit
|
||
+DO $$
|
||
+BEGIN
|
||
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
+ WHERE table_name = 'licenses' AND column_name = 'device_limit') THEN
|
||
+ ALTER TABLE licenses
|
||
+ ADD COLUMN device_limit INTEGER DEFAULT 3 CHECK (device_limit >= 1 AND device_limit <= 10);
|
||
+ END IF;
|
||
+END $$;
|
||
+
|
||
+-- Tabelle für Geräte-Registrierungen
|
||
+CREATE TABLE IF NOT EXISTS device_registrations (
|
||
+ id SERIAL PRIMARY KEY,
|
||
+ license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||
+ hardware_id TEXT NOT NULL,
|
||
+ device_name TEXT,
|
||
+ operating_system TEXT,
|
||
+ first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ is_active BOOLEAN DEFAULT TRUE,
|
||
+ deactivated_at TIMESTAMP WITH TIME ZONE,
|
||
+ deactivated_by TEXT,
|
||
+ ip_address TEXT,
|
||
+ user_agent TEXT,
|
||
+ UNIQUE(license_id, hardware_id)
|
||
+);
|
||
+
|
||
+-- Indizes für device_registrations
|
||
+CREATE INDEX IF NOT EXISTS idx_device_license ON device_registrations(license_id);
|
||
+CREATE INDEX IF NOT EXISTS idx_device_hardware ON device_registrations(hardware_id);
|
||
+CREATE INDEX IF NOT EXISTS idx_device_active ON device_registrations(license_id, is_active) WHERE is_active = TRUE;
|
||
+
|
||
+-- Indizes für Performance
|
||
+CREATE INDEX IF NOT EXISTS idx_resource_status ON resource_pools(status);
|
||
+CREATE INDEX IF NOT EXISTS idx_resource_type_status ON resource_pools(resource_type, status);
|
||
+CREATE INDEX IF NOT EXISTS idx_resource_allocated ON resource_pools(allocated_to_license) WHERE allocated_to_license IS NOT NULL;
|
||
+CREATE INDEX IF NOT EXISTS idx_resource_quarantine ON resource_pools(quarantine_until) WHERE status = 'quarantine';
|
||
+CREATE INDEX IF NOT EXISTS idx_resource_history_date ON resource_history(action_at DESC);
|
||
+CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id);
|
||
+CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC);
|
||
+CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE;
|
||
+
|
||
+-- Users table for authentication with password and 2FA support
|
||
+CREATE TABLE IF NOT EXISTS users (
|
||
+ id SERIAL PRIMARY KEY,
|
||
+ username VARCHAR(50) UNIQUE NOT NULL,
|
||
+ password_hash VARCHAR(255) NOT NULL,
|
||
+ email VARCHAR(100),
|
||
+ totp_secret VARCHAR(32),
|
||
+ totp_enabled BOOLEAN DEFAULT FALSE,
|
||
+ backup_codes TEXT, -- JSON array of hashed backup codes
|
||
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||
+ password_reset_token VARCHAR(64),
|
||
+ password_reset_expires TIMESTAMP WITH TIME ZONE,
|
||
+ failed_2fa_attempts INTEGER DEFAULT 0,
|
||
+ last_failed_2fa TIMESTAMP WITH TIME ZONE
|
||
+);
|
||
+
|
||
+-- Index for faster login lookups
|
||
+CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||
+CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL;
|
||
+
|
||
+-- Migration: Add is_test column to licenses if it doesn't exist
|
||
+DO $$
|
||
+BEGIN
|
||
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
+ WHERE table_name = 'licenses' AND column_name = 'is_test') THEN
|
||
+ ALTER TABLE licenses ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
|
||
+
|
||
+ -- Mark all existing licenses as test data
|
||
+ UPDATE licenses SET is_test = TRUE;
|
||
+
|
||
+ -- Add index for better performance when filtering test data
|
||
+ CREATE INDEX idx_licenses_is_test ON licenses(is_test);
|
||
+ END IF;
|
||
+END $$;
|
||
+
|
||
+-- Migration: Add is_test column to customers if it doesn't exist
|
||
+DO $$
|
||
+BEGIN
|
||
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
+ WHERE table_name = 'customers' AND column_name = 'is_test') THEN
|
||
+ ALTER TABLE customers ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
|
||
+
|
||
+ -- Mark all existing customers as test data
|
||
+ UPDATE customers SET is_test = TRUE;
|
||
+
|
||
+ -- Add index for better performance
|
||
+ CREATE INDEX idx_customers_is_test ON customers(is_test);
|
||
+ END IF;
|
||
+END $$;
|
||
+
|
||
+-- Migration: Add is_test column to resource_pools if it doesn't exist
|
||
+DO $$
|
||
+BEGIN
|
||
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||
+ WHERE table_name = 'resource_pools' AND column_name = 'is_test') THEN
|
||
+ ALTER TABLE resource_pools ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
|
||
+
|
||
+ -- Mark all existing resources as test data
|
||
+ UPDATE resource_pools SET is_test = TRUE;
|
||
+
|
||
+ -- Add index for better performance
|
||
+ CREATE INDEX idx_resource_pools_is_test ON resource_pools(is_test);
|
||
+ END IF;
|
||
+END $$;
|
||
diff --git a/v2_adminpanel/requirements.txt b/v2_adminpanel/requirements.txt
|
||
index f1fcc95..8588045 100644
|
||
--- a/v2_adminpanel/requirements.txt
|
||
+++ b/v2_adminpanel/requirements.txt
|
||
@@ -1,14 +1,14 @@
|
||
-flask
|
||
-flask-session
|
||
-psycopg2-binary
|
||
-python-dotenv
|
||
-pyopenssl
|
||
-pandas
|
||
-openpyxl
|
||
-cryptography
|
||
-apscheduler
|
||
-requests
|
||
-python-dateutil
|
||
-bcrypt
|
||
-pyotp
|
||
-qrcode[pil]
|
||
+flask
|
||
+flask-session
|
||
+psycopg2-binary
|
||
+python-dotenv
|
||
+pyopenssl
|
||
+pandas
|
||
+openpyxl
|
||
+cryptography
|
||
+apscheduler
|
||
+requests
|
||
+python-dateutil
|
||
+bcrypt
|
||
+pyotp
|
||
+qrcode[pil]
|
||
diff --git a/v2_adminpanel/templates/create_customer.html b/v2_adminpanel/templates/create_customer.html
|
||
index 55518fc..689ad85 100644
|
||
--- a/v2_adminpanel/templates/create_customer.html
|
||
+++ b/v2_adminpanel/templates/create_customer.html
|
||
@@ -1,71 +1,71 @@
|
||
-{% extends "base.html" %}
|
||
-
|
||
-{% block title %}Neuer Kunde{% endblock %}
|
||
-
|
||
-{% block content %}
|
||
-<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 %}
|
||
+{% 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 %}
|
||
\ No newline at end of file
|
||
diff --git a/v2_adminpanel/templates/index.html b/v2_adminpanel/templates/index.html
|
||
index a14329a..2e0810f 100644
|
||
--- a/v2_adminpanel/templates/index.html
|
||
+++ b/v2_adminpanel/templates/index.html
|
||
@@ -1,533 +1,533 @@
|
||
-{% extends "base.html" %}
|
||
-
|
||
-{% block title %}Admin Panel{% endblock %}
|
||
-
|
||
-{% block content %}
|
||
-<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 %}
|
||
+{% 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 %}
|
||
diff --git a/v2_nginx/nginx.conf b/v2_nginx/nginx.conf
|
||
index 5830a2f..c3bbf80 100644
|
||
--- a/v2_nginx/nginx.conf
|
||
+++ b/v2_nginx/nginx.conf
|
||
@@ -1,99 +1,99 @@
|
||
-events {
|
||
- worker_connections 1024;
|
||
-}
|
||
-
|
||
-http {
|
||
- # Moderne SSL-Einstellungen für maximale Sicherheit
|
||
- ssl_protocols TLSv1.2 TLSv1.3;
|
||
- ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
|
||
- ssl_prefer_server_ciphers off;
|
||
-
|
||
- # SSL Session Einstellungen
|
||
- ssl_session_timeout 1d;
|
||
- ssl_session_cache shared:SSL:10m;
|
||
- ssl_session_tickets off;
|
||
-
|
||
- # OCSP Stapling
|
||
- ssl_stapling on;
|
||
- ssl_stapling_verify on;
|
||
- resolver 8.8.8.8 8.8.4.4 valid=300s;
|
||
- resolver_timeout 5s;
|
||
-
|
||
- # DH parameters für Perfect Forward Secrecy
|
||
- ssl_dhparam /etc/nginx/ssl/dhparam.pem;
|
||
-
|
||
- # Admin Panel
|
||
- server {
|
||
- listen 80;
|
||
- server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com;
|
||
-
|
||
- # Redirect HTTP to HTTPS
|
||
- return 301 https://$server_name$request_uri;
|
||
- }
|
||
-
|
||
- server {
|
||
- listen 443 ssl;
|
||
- server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com;
|
||
-
|
||
- # SSL-Zertifikate (echte Zertifikate)
|
||
- ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||
- ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||
-
|
||
- # Security Headers
|
||
- add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||
- add_header X-Content-Type-Options "nosniff" always;
|
||
- add_header X-Frame-Options "SAMEORIGIN" always;
|
||
- add_header X-XSS-Protection "1; mode=block" always;
|
||
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
-
|
||
- # Proxy-Einstellungen
|
||
- location / {
|
||
- proxy_pass http://admin-panel:5000;
|
||
- proxy_set_header Host $host;
|
||
- proxy_set_header X-Real-IP $remote_addr;
|
||
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
- proxy_set_header X-Forwarded-Proto $scheme;
|
||
-
|
||
- # WebSocket support (falls benötigt)
|
||
- proxy_http_version 1.1;
|
||
- proxy_set_header Upgrade $http_upgrade;
|
||
- proxy_set_header Connection "upgrade";
|
||
- }
|
||
- }
|
||
-
|
||
- # API Server (für später)
|
||
- server {
|
||
- listen 80;
|
||
- server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com;
|
||
-
|
||
- return 301 https://$server_name$request_uri;
|
||
- }
|
||
-
|
||
- server {
|
||
- listen 443 ssl;
|
||
- server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com;
|
||
-
|
||
- ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||
- ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||
-
|
||
- # Security Headers
|
||
- add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||
- add_header X-Content-Type-Options "nosniff" always;
|
||
- add_header X-Frame-Options "SAMEORIGIN" always;
|
||
- add_header X-XSS-Protection "1; mode=block" always;
|
||
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
-
|
||
- location / {
|
||
- proxy_pass http://license-server:8443;
|
||
- proxy_set_header Host $host;
|
||
- proxy_set_header X-Real-IP $remote_addr;
|
||
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
- proxy_set_header X-Forwarded-Proto $scheme;
|
||
-
|
||
- # WebSocket support (falls benötigt)
|
||
- proxy_http_version 1.1;
|
||
- proxy_set_header Upgrade $http_upgrade;
|
||
- proxy_set_header Connection "upgrade";
|
||
- }
|
||
- }
|
||
+events {
|
||
+ worker_connections 1024;
|
||
+}
|
||
+
|
||
+http {
|
||
+ # Moderne SSL-Einstellungen für maximale Sicherheit
|
||
+ ssl_protocols TLSv1.2 TLSv1.3;
|
||
+ ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
|
||
+ ssl_prefer_server_ciphers off;
|
||
+
|
||
+ # SSL Session Einstellungen
|
||
+ ssl_session_timeout 1d;
|
||
+ ssl_session_cache shared:SSL:10m;
|
||
+ ssl_session_tickets off;
|
||
+
|
||
+ # OCSP Stapling
|
||
+ ssl_stapling on;
|
||
+ ssl_stapling_verify on;
|
||
+ resolver 8.8.8.8 8.8.4.4 valid=300s;
|
||
+ resolver_timeout 5s;
|
||
+
|
||
+ # DH parameters für Perfect Forward Secrecy
|
||
+ ssl_dhparam /etc/nginx/ssl/dhparam.pem;
|
||
+
|
||
+ # Admin Panel
|
||
+ server {
|
||
+ listen 80;
|
||
+ server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com;
|
||
+
|
||
+ # Redirect HTTP to HTTPS
|
||
+ return 301 https://$server_name$request_uri;
|
||
+ }
|
||
+
|
||
+ server {
|
||
+ listen 443 ssl;
|
||
+ server_name admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com;
|
||
+
|
||
+ # SSL-Zertifikate (echte Zertifikate)
|
||
+ ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||
+ ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||
+
|
||
+ # Security Headers
|
||
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||
+ add_header X-Content-Type-Options "nosniff" always;
|
||
+ add_header X-Frame-Options "SAMEORIGIN" always;
|
||
+ add_header X-XSS-Protection "1; mode=block" always;
|
||
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
+
|
||
+ # Proxy-Einstellungen
|
||
+ location / {
|
||
+ proxy_pass http://admin-panel:5000;
|
||
+ proxy_set_header Host $host;
|
||
+ proxy_set_header X-Real-IP $remote_addr;
|
||
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
+ proxy_set_header X-Forwarded-Proto $scheme;
|
||
+
|
||
+ # WebSocket support (falls benötigt)
|
||
+ proxy_http_version 1.1;
|
||
+ proxy_set_header Upgrade $http_upgrade;
|
||
+ proxy_set_header Connection "upgrade";
|
||
+ }
|
||
+ }
|
||
+
|
||
+ # API Server (für später)
|
||
+ server {
|
||
+ listen 80;
|
||
+ server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com;
|
||
+
|
||
+ return 301 https://$server_name$request_uri;
|
||
+ }
|
||
+
|
||
+ server {
|
||
+ listen 443 ssl;
|
||
+ server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com;
|
||
+
|
||
+ ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||
+ ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||
+
|
||
+ # Security Headers
|
||
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||
+ add_header X-Content-Type-Options "nosniff" always;
|
||
+ add_header X-Frame-Options "SAMEORIGIN" always;
|
||
+ add_header X-XSS-Protection "1; mode=block" always;
|
||
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
+
|
||
+ location / {
|
||
+ proxy_pass http://license-server:8443;
|
||
+ proxy_set_header Host $host;
|
||
+ proxy_set_header X-Real-IP $remote_addr;
|
||
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
+ proxy_set_header X-Forwarded-Proto $scheme;
|
||
+
|
||
+ # WebSocket support (falls benötigt)
|
||
+ proxy_http_version 1.1;
|
||
+ proxy_set_header Upgrade $http_upgrade;
|
||
+ proxy_set_header Connection "upgrade";
|
||
+ }
|
||
+ }
|
||
}
|
||
\ No newline at end of file
|