Initial commit
Dieser Commit ist enthalten in:
1
backups/.backup_key
Normale Datei
1
backups/.backup_key
Normale Datei
@ -0,0 +1 @@
|
||||
vJgDckVjr3cSictLNFLGl8QIfqSXVD5skPU7kVhkyfc=
|
||||
1
backups/backup_v2docker_20250607_174645_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250607_174645_encrypted.sql.gz.enc
Normale Datei
@ -0,0 +1 @@
|
||||
gAAAAABoRHsFDJi5AsC1qRqcqnIM8eqtRWIwuHF7n2IL5DTz2myp3zVWmN3KmHNHO3pxV4Zf3DSWalPWCT45Ie-KapLGXdApCjDKFIBsTGlEStAxLx5UQPCTknCy0tqcw_osXjdCU1tE3YfLi6MRmJHFOClmipW0RVSDIoN8BBV8uex4rc10LZ79V1_UZ1pUjatSqjQW-WMOTdN3KcECW8MstAhp0JJG_AoKTZU8Px_kn-1wrQCyf0NIgcMFg4raEBsJ3290jRoHYdVs_89uei3xZAoyCfK1l2cvp0AQUKIC3RionZWqYxt420vmMYbninosyIHYKDmDj1xsPRWVZ4PLs6LPGrYY_AhHHj4011HSJmqG0kCfXmYqjTXlZQ9FHiPYze5mOayMJaCOQWDhkDghzKpW9Z3PHgfiEPKz-95soSeYdbICO_B7I4BTzjlNejBbV0iRZPYzkgX11QOQE1p268hRRLjl6PFOOPzBV1ectqpLuYdMuidaa243UmN-PjIfGOiAZrRCKsKbXF8wUmnCPlfLIT74PZo5YVLJiPPKn63qlLvRyZPn96WHdJF6sW4xOn5pxKn0wAtyg-Qp2RKwjg-W8a3RqhXfamNQvkR6w5cRZSgrbPjIuaPBE7Im-IWn2A-WjnPY8KqzoJDRmFpaeeKLMBGoQ6U6uYXjSXbmS2wmwSR3rmROcbsuIubTWpNakM8QbT3egfuFShWs68he4Gr8wM-mtddxci9HSlDTufqRRLgg74_1-0So94qRn6fR47zgMXF7sS0dQVUe_X7o73xirwECI_BQQe415OjeDI086PyNmGD9DBO9oARvIXcamT0Mxv5lJhCLFjT6vtASTGlNSxdKmwKdu5yEesMLPzatx0-tNf8YSFaYLFlczNIpEkuqKo04qlNbSYEunHIc8AFzK3WRurNrBvTbrYZ5cZHM2sh-nzNCxWlfhITodck0qt-aMr0XgRLkJ-Q7uzUfmsS2TKUsE5cJE2V1ibpHCHpzAAiWckUrH1LBirSGUSJGlgOP6hZqztUlYv46wNVHLs924HUTtBsUrHLCIBslgsUXR8SM_xBVXXhoFu_QZLMSPV63_HsOAhJr0U8Pwg0cu7S5OY03ZO4Ehpevqo8O-DLtKgrm3TOC_S44objadaBLnJUbP5KoZIRZNxqu_MO2kQKFT4_fcaTgFgz3nf6ztqxkBXMqQ8FeEm7IcfgQEcY52JI0jomkN_KFqp0aMa9P9pkcN-ZCi7ipzgjoJnJGt1mKxWM5uHn-eksD-zyhmw8LNyoOxPQv0b0MMq--5WGD1I0ylw7HuG-ZLt1G3KV3PqruZFsn3_as71yB1011Kr4iBNeDZfsd0IfC-VMUjPE1KBipy-zgtWN1244gGM8rfz8Fp_3_FXduE6ckYSXKbvCaZmcbUa537H_n0Eq0u6iG6-ZuhSV-ll0dv68T5LF8mH71HO_4fXMyjL5bf8CsqgL16F_EJHW25ljGM84XsXcJtVVTTPFfjwvDliQU07Sd1yDFhKUKD61DE0D74V4JJaTebg6HvtqFJ2cShAXDeI9WUMzdTmnS1vRSnED_5-ag-O3vgQYYbinJugGmj5W8J1r1b6OXD3Lok6DuqRKzPCYM5GJBVTUKZjCRzxkXTsbVogvNWLV8rRS5iC-hQgzwImuDi4tzMKX2k7Q5jGIuiCwNCu7t5QFe6zpDeR1hvrNfjHIwahQvUIIzInv2LwjRkG5S8eWpNBImLCYkyobxBRZsd24OSKxD8KIdMJqg9P3GlSgskopwsWiLEoLbWC9CuaS303aAgYjo3czq8Bx2QKrSZtI0uXyouNAc5P9t-Y2RSfloQh_TyVJij6LwCLgIHdoX4N-_OQjVCtxOqvem6PPMYoyjvh89bcrcIzBNke-z3nn8OK_4uTzzm9z3_OHMHK6TxbxaR4XNMogWWkQPZD2pePw73iDY4H7b8iXrD3zE0QT9F_yQWnqAfotudcdXDFe3c4-U6WHNJZ4XhkCcok2Kebi-pvQEkkv5YSi8sPC7jBx2O8qJp-VYCqQhSncTzZRyXt-ZCtvfHtZe4g9wEKmtf4jySJyW87YLe-fkmyP3kGaYCqAgCt6ieeQxG-n7Q_THwRTcHTxDn08YLukYoa5hFwurkn0LZmfcF7T0FRQqs-n_Y3IDqEK-32lBFwbajfvsPpZs_JQe7LFOOin4JhwCuZRKoStQNUBKtwEaxvXxayEKx5c2nJogshl284EyTdJesDujb1PMW1w-TyQDj0y0Bts41-fpIkCUxPl4gXXEP9J38LP-rg8Qh2HbSDHo-qSHO-PyHtSV6BQIVHQE9JUvEEa2OO35QY3cjMP3tyY_z7I2dOYGB4K9_LmmYQkn6Q1J3YDB646B4k9nQBnE8CJVAzTD5p2b6CrJm-r5GS3qC8e4EoJLg9-Gec3m-pjVo9E3ZToSDQ1Upf4Ej82YmIyy8r6aWrH_ztJ9uAwv63_osh3QAaWFcesBzIy9TnN_n2VzzIcP13RgAqwQZBhVGaXyfCZvUQNWo6vFOwgzvcDnt_ECYH-quSrS-g_Tdo1X9mv7SllMPmDZ1YEv0Szyr82tkP9NLBLdq8hZJ92kkrCahGAVgPEhlCIfJap3HOa22ezPVafmvxv8tc8qp9pUIySn5pmKK2YVL31lnCBa0IOTED0dlfyANKWJCregAf0ZpR0z57rqaG3nYDzN1f1sgCHdcTsyNR1revwlRr5XNd66PDu5_sA7-iqH2XGiqvoVfUIPz9mi7Zf4CbQW_gb1yyN4LrBQUq-fjn0xJMlkgJUHzVOcZo30IOSN_61sU1dLiJJNIvp_utwxD72zpOyxrpOzeytWBgKGpWmFhc21vUQar24m_dG9E4FmEULM6Nzep7y0dxgA4baQ5zoaYUTdLO8grxUlJ0PDoV9vldsXQ0sgwh4ioB7JcYh1WlKUTcQ-EPrhvh8lRc1WWjNnOnkjoRy1QFRnLk3peqxIkR7iMZRfwCAD46906g-TAiJWX7MAIOHyFEug7lh1jYMaHb_XfS5Uxzqj_YCfPW3KiPjyw_Bt5_NJucYwgEprD70VnvmC-39v3-hIxOCYQMMJdSGeY0omKH4PytVCkXAlM6HQqhY-Xt54-ZO6iVwrotHsH5oDgZ30Q9TT699V8XqoaKAP6ZgAfuiPzUubzJm6UhX9W6sRqZfgwxkb8rWgdMede80_ggxJkxV5fYsBw9hhRGwx6nCkuXL9hdTN_TFPilndna-AlSfwendm8Fh7YvIU7A-o4UXPwAVHDMwrlMfFsR-brEgZOvNZJgDd_LDYDLiWly5dvDUQ80L5erSFtsLCXTZB2TKrpROk1gEVyYR_B6Wf08A40MoKRLUpBAYW7B_Io4IBukAzTdkZp37rHJkpZB3gdmPpliLUpq5mh4lzQGZT-LyFAI-sbEWWNRtt-y5hs0S4VqIo9OO7JqmWuNScOPucmZnApfc1NzPA_2gxzd8kYDbtgjw0QD_WF0UTzgiRQK7Y80gzF-pJdeJGr6tfkSN0zTfxcLRBHhRM0rJe0933-V5V33pvMj1l66pt_5pHV4ZByXCNZJq4hd-TN8Al95VPox8qWK_2THyVuzRzX8BT0acEmsjDHWdZPNIlFdfMj7effacQSacxXTabiGrpB5-3sLXiQZoV94i8PNRG6ru9MFTxij42skadY-d3B5TVxLxifoyz2BJaWiLnKYzw405qCZdFIkzUQxUvrv6HO2ppGcZ7przRwk9wuxhBtT87G8vQR7y2ZZz2uqezrPPEs1nVlWXb-V9plqJjTmDVMgvRzkrVwGTLn0iBLrdjuJGoBUV8UV_S7HUkz6QQc9Apkqye7QIm1LUlEJ47s6jVkV3qQTrLu6b9OSJlUvK7SsFikNcCnKiqtHm1W679aOEaNKWZj6XhDDEaGDZqM6tq1mrTX8oUQdsXp31D8YbcftMzFBhyVLsXacL_1bkEPcwmfMoERuMPevCXcNk2KktJCu4t3z_ivC_bl-6jj47oMl0nau3Ug4bGe4jTD4TpUDlz6aLHEGJwaHaNuz9WbP0NUPaaEeWhCsrRyocOAeQYqDYI-jr0rTBGUPLrm74Tn18Hf3tD5KW5OdWOyeBF
|
||||
1
backups/backup_v2docker_20250607_232845_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250607_232845_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250608_075834_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250608_075834_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250608_174930_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250608_174930_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250608_200224_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250608_200224_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250616_211330_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250618_020559_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250618_020559_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250618_021107_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250618_021107_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250618_024414_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250618_024414_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250618_195853_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250618_195853_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250619_154355_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250619_154355_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250621_030000_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250621_030000_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250621_174144_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250621_174144_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250622_141119_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250622_141119_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250622_172034_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250622_172034_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
1
backups/backup_v2docker_20250623_030000_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250623_030000_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
4475
backups/refactoring_20250616_223724/app.py.backup_20250616_223724
Normale Datei
4475
backups/refactoring_20250616_223724/app.py.backup_20250616_223724
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
25
backups/refactoring_20250616_223724/blueprint_overview.txt
Normale Datei
25
backups/refactoring_20250616_223724/blueprint_overview.txt
Normale Datei
@ -0,0 +1,25 @@
|
||||
Blueprint Migration Status - 20250616_223724
|
||||
==========================================
|
||||
|
||||
Blueprints erstellt und registriert:
|
||||
- auth_bp (9 routes) - Authentication
|
||||
- admin_bp (10 routes) - Admin Dashboard
|
||||
- license_bp (4 routes) - License Management
|
||||
- customer_bp (7 routes) - Customer Management
|
||||
- resource_bp (7 routes) - Resource Pool
|
||||
- session_bp (6 routes) - Session Management
|
||||
- batch_bp (4 routes) - Batch Operations
|
||||
- api_bp (14 routes) - API Endpoints
|
||||
- export_bp (5 routes) - Export Functions
|
||||
|
||||
Gesamt: 66 Routes in Blueprints
|
||||
|
||||
Status:
|
||||
- Alle Routes aus app.py sind auskommentiert
|
||||
- Blueprints sind aktiv und funktionsfähig
|
||||
- Keine aktiven @app.route mehr in app.py
|
||||
|
||||
Nächste Schritte:
|
||||
1. Auskommentierte Routes entfernen
|
||||
2. Redundante Funktionen bereinigen
|
||||
3. URL-Präfixe implementieren
|
||||
60
backups/refactoring_20250616_223724/commented_routes.txt
Normale Datei
60
backups/refactoring_20250616_223724/commented_routes.txt
Normale Datei
@ -0,0 +1,60 @@
|
||||
153:# @app.route("/login", methods=["GET", "POST"])
|
||||
267:# @app.route("/logout")
|
||||
279:# @app.route("/verify-2fa", methods=["GET", "POST"])
|
||||
358:# @app.route("/profile")
|
||||
368:# @app.route("/profile/change-password", methods=["POST"])
|
||||
406:# @app.route("/profile/setup-2fa")
|
||||
426:# @app.route("/profile/enable-2fa", methods=["POST"])
|
||||
464:# @app.route("/profile/disable-2fa", methods=["POST"])
|
||||
491:# @app.route("/heartbeat", methods=['POST'])
|
||||
506:# @app.route("/api/generate-license-key", methods=['POST'])
|
||||
551:# @app.route("/api/customers", methods=['GET'])
|
||||
662:# @app.route("/")
|
||||
892:# @app.route("/create", methods=["GET", "POST"])
|
||||
1123:# @app.route("/batch", methods=["GET", "POST"])
|
||||
1378:# @app.route("/batch/export")
|
||||
1417:# @app.route("/licenses")
|
||||
1423:# @app.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
|
||||
1515:# @app.route("/license/delete/<int:license_id>", methods=["POST"])
|
||||
1548:# @app.route("/customers")
|
||||
1554:# @app.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
|
||||
1638:# @app.route("/customer/create", methods=["GET", "POST"])
|
||||
1693:# @app.route("/customer/delete/<int:customer_id>", methods=["POST"])
|
||||
1731:# @app.route("/customers-licenses")
|
||||
1824:# @app.route("/api/customer/<int:customer_id>/licenses")
|
||||
1927:# @app.route("/api/customer/<int:customer_id>/quick-stats")
|
||||
1960:# @app.route("/api/license/<int:license_id>/quick-edit", methods=['POST'])
|
||||
2030:# @app.route("/api/license/<int:license_id>/resources")
|
||||
2080:# @app.route("/sessions")
|
||||
2162:# @app.route("/session/end/<int:session_id>", methods=["POST"])
|
||||
2181:# @app.route("/export/licenses")
|
||||
2291:# @app.route("/export/audit")
|
||||
2415:# @app.route("/export/customers")
|
||||
2519:# @app.route("/export/sessions")
|
||||
2658:# @app.route("/export/resources")
|
||||
2787:# @app.route("/audit")
|
||||
2881:# @app.route("/backups")
|
||||
2916:# @app.route("/backup/create", methods=["POST"])
|
||||
2934:# @app.route("/backup/restore/<int:backup_id>", methods=["POST"])
|
||||
2953:# @app.route("/backup/download/<int:backup_id>")
|
||||
2985:# @app.route("/backup/delete/<int:backup_id>", methods=["DELETE"])
|
||||
3041:# @app.route("/security/blocked-ips")
|
||||
3082:# @app.route("/security/unblock-ip", methods=["POST"])
|
||||
3108:# @app.route("/security/clear-attempts", methods=["POST"])
|
||||
3124:# @app.route("/api/license/<int:license_id>/toggle", methods=["POST"])
|
||||
3156:# @app.route("/api/licenses/bulk-activate", methods=["POST"])
|
||||
3192:# @app.route("/api/licenses/bulk-deactivate", methods=["POST"])
|
||||
3228:# @app.route("/api/license/<int:license_id>/devices")
|
||||
3283:# @app.route("/api/license/<int:license_id>/register-device", methods=["POST"])
|
||||
3398:# @app.route("/api/license/<int:license_id>/deactivate-device/<int:device_id>", methods=["POST"])
|
||||
3440:# @app.route("/api/licenses/bulk-delete", methods=["POST"])
|
||||
3485:# @app.route('/resources')
|
||||
3625:# @app.route('/resources/add', methods=['GET', 'POST'])
|
||||
3689:# @app.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
|
||||
3747:# @app.route('/resources/release', methods=['POST'])
|
||||
3798:# @app.route('/api/resources/allocate', methods=['POST'])
|
||||
3946:# @app.route('/api/resources/check-availability', methods=['GET'])
|
||||
4005:# @app.route('/api/global-search', methods=['GET'])
|
||||
4068:# @app.route('/resources/history/<int:resource_id>')
|
||||
4155:# @app.route('/resources/metrics')
|
||||
4319:# @app.route('/resources/report', methods=['GET'])
|
||||
29251
backups/refactoring_20250616_223724/git_diff.txt
Normale Datei
29251
backups/refactoring_20250616_223724/git_diff.txt
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
10
backups/refactoring_20250616_223724/git_log.txt
Normale Datei
10
backups/refactoring_20250616_223724/git_log.txt
Normale Datei
@ -0,0 +1,10 @@
|
||||
4915513 Refactoring - Part 1
|
||||
29b302a Refactoring - Part1
|
||||
262de28 lizenzserver
|
||||
ff93520 Zuweisung über Kunden & Lizenzen geht
|
||||
13e1386 Ressource Sort gefixt
|
||||
b18fb49 Testressource Checkbox Fix
|
||||
d65e5d3 Export und Aktion gefixt
|
||||
df60ce6 Ressourcen bei Kunden&Lizenzen ist richtig
|
||||
a878d9b Gerätelimit drin
|
||||
4b66d8b Zurück zur Übersicht Button
|
||||
38
backups/refactoring_20250616_223724/git_status.txt
Normale Datei
38
backups/refactoring_20250616_223724/git_status.txt
Normale Datei
@ -0,0 +1,38 @@
|
||||
On branch main
|
||||
Your branch is up to date with 'origin/main'.
|
||||
|
||||
Changes not staged for commit:
|
||||
(use "git add/rm <file>..." to update what will be committed)
|
||||
(use "git restore <file>..." to discard changes in working directory)
|
||||
modified: .claude/settings.local.json
|
||||
modified: JOURNAL.md
|
||||
modified: v2/.env
|
||||
modified: v2/docker-compose.yaml
|
||||
modified: v2_adminpanel/Dockerfile
|
||||
modified: v2_adminpanel/__pycache__/app.cpython-312.pyc
|
||||
modified: v2_adminpanel/app.py
|
||||
modified: v2_adminpanel/app.py.backup
|
||||
modified: v2_adminpanel/app.py.old
|
||||
deleted: v2_adminpanel/comment_routes.py
|
||||
modified: v2_adminpanel/init.sql
|
||||
modified: v2_adminpanel/requirements.txt
|
||||
modified: v2_adminpanel/templates/create_customer.html
|
||||
modified: v2_adminpanel/templates/index.html
|
||||
modified: v2_nginx/nginx.conf
|
||||
|
||||
Untracked files:
|
||||
(use "git add <file>..." to include in what will be committed)
|
||||
backup_before_cleanup.sh
|
||||
backups/refactoring_20250616_223724/
|
||||
refactoring.md
|
||||
v2_adminpanel/app.py.backup_before_blueprint_migration
|
||||
v2_adminpanel/routes/api_routes.py
|
||||
v2_adminpanel/routes/batch_routes.py
|
||||
v2_adminpanel/routes/customer_routes.py
|
||||
v2_adminpanel/routes/export_routes.py
|
||||
v2_adminpanel/routes/license_routes.py
|
||||
v2_adminpanel/routes/resource_routes.py
|
||||
v2_adminpanel/routes/session_routes.py
|
||||
v2_adminpanel/test_blueprint_routes.py
|
||||
|
||||
no changes added to commit (use "git add" and/or "git commit -a")
|
||||
33
backups/refactoring_20250616_223724/v2_adminpanel_backup/Dockerfile
Normale Datei
33
backups/refactoring_20250616_223724/v2_adminpanel_backup/Dockerfile
Normale Datei
@ -0,0 +1,33 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Locale für deutsche Sprache und UTF-8 setzen
|
||||
ENV LANG=de_DE.UTF-8
|
||||
ENV LC_ALL=de_DE.UTF-8
|
||||
ENV PYTHONIOENCODING=utf-8
|
||||
|
||||
# Zeitzone auf Europe/Berlin setzen
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System-Dependencies inkl. PostgreSQL-Tools installieren
|
||||
RUN apt-get update && apt-get install -y \
|
||||
locales \
|
||||
postgresql-client \
|
||||
tzdata \
|
||||
&& sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \
|
||||
&& locale-gen \
|
||||
&& update-locale LANG=de_DE.UTF-8 \
|
||||
&& ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \
|
||||
&& echo "Europe/Berlin" > /etc/timezone \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
4475
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py
Normale Datei
4475
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
5032
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup
Normale Datei
5032
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.backup
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
5021
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.old
Normale Datei
5021
backups/refactoring_20250616_223724/v2_adminpanel_backup/app.py.old
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
124
backups/refactoring_20250616_223724/v2_adminpanel_backup/app_new.py
Normale Datei
124
backups/refactoring_20250616_223724/v2_adminpanel_backup/app_new.py
Normale Datei
@ -0,0 +1,124 @@
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash
|
||||
from flask_session import Session
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
import pandas as pd
|
||||
from psycopg2.extras import Json
|
||||
|
||||
# Import our new modules
|
||||
import config
|
||||
from db import get_connection, get_db_connection, get_db_cursor, execute_query
|
||||
from auth.decorators import login_required
|
||||
from auth.password import hash_password, verify_password
|
||||
from auth.two_factor import (
|
||||
generate_totp_secret, generate_qr_code, verify_totp,
|
||||
generate_backup_codes, hash_backup_code, verify_backup_code
|
||||
)
|
||||
from auth.rate_limiting import (
|
||||
get_client_ip, check_ip_blocked, record_failed_attempt,
|
||||
reset_login_attempts, get_login_attempts
|
||||
)
|
||||
from utils.audit import log_audit
|
||||
from utils.license import generate_license_key, validate_license_key
|
||||
from utils.backup import create_backup, restore_backup, get_or_create_encryption_key
|
||||
from utils.export import (
|
||||
create_excel_export, format_datetime_for_export,
|
||||
prepare_license_export_data, prepare_customer_export_data,
|
||||
prepare_session_export_data, prepare_audit_export_data
|
||||
)
|
||||
from models import get_user_by_username
|
||||
|
||||
app = Flask(__name__)
|
||||
# Load configuration from config module
|
||||
app.config['SECRET_KEY'] = config.SECRET_KEY
|
||||
app.config['SESSION_TYPE'] = config.SESSION_TYPE
|
||||
app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII
|
||||
app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY
|
||||
app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE
|
||||
app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME
|
||||
app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST
|
||||
Session(app)
|
||||
|
||||
# ProxyFix für korrekte IP-Adressen hinter Nginx
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
|
||||
)
|
||||
|
||||
# Configuration is now loaded from config module
|
||||
|
||||
# Scheduler für automatische Backups
|
||||
scheduler = BackgroundScheduler()
|
||||
scheduler.start()
|
||||
|
||||
# Logging konfigurieren
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
# Scheduled Backup Job
|
||||
def scheduled_backup():
|
||||
"""Führt ein geplantes Backup aus"""
|
||||
logging.info("Starte geplantes Backup...")
|
||||
create_backup(backup_type="scheduled", created_by="scheduler")
|
||||
|
||||
# Scheduler konfigurieren - täglich um 3:00 Uhr
|
||||
scheduler.add_job(
|
||||
scheduled_backup,
|
||||
'cron',
|
||||
hour=config.SCHEDULER_CONFIG['backup_hour'],
|
||||
minute=config.SCHEDULER_CONFIG['backup_minute'],
|
||||
id='daily_backup',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
|
||||
def verify_recaptcha(response):
|
||||
"""Verifiziert die reCAPTCHA v2 Response mit Google"""
|
||||
secret_key = config.RECAPTCHA_SECRET_KEY
|
||||
|
||||
# Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC)
|
||||
if not secret_key:
|
||||
logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen")
|
||||
return True
|
||||
|
||||
# Verifizierung bei Google
|
||||
try:
|
||||
verify_url = 'https://www.google.com/recaptcha/api/siteverify'
|
||||
data = {
|
||||
'secret': secret_key,
|
||||
'response': response
|
||||
}
|
||||
|
||||
# Timeout für Request setzen
|
||||
r = requests.post(verify_url, data=data, timeout=5)
|
||||
result = r.json()
|
||||
|
||||
# Log für Debugging
|
||||
if not result.get('success'):
|
||||
logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}")
|
||||
|
||||
return result.get('success', False)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}")
|
||||
# Bei Netzwerkfehlern CAPTCHA als bestanden werten
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
# Now copy all the route handlers from the original file
|
||||
# Starting from line 693...
|
||||
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@ -0,0 +1 @@
|
||||
# Auth module initialization
|
||||
@ -0,0 +1,44 @@
|
||||
from functools import wraps
|
||||
from flask import session, redirect, url_for, flash, request
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
import logging
|
||||
from utils.audit import log_audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'logged_in' not in session:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
# Check if session has expired
|
||||
if 'last_activity' in session:
|
||||
last_activity = datetime.fromisoformat(session['last_activity'])
|
||||
time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity
|
||||
|
||||
# Debug logging
|
||||
logger.info(f"Session check for {session.get('username', 'unknown')}: "
|
||||
f"Last activity: {last_activity}, "
|
||||
f"Time since: {time_since_activity.total_seconds()} seconds")
|
||||
|
||||
if time_since_activity > timedelta(minutes=5):
|
||||
# Session expired - Logout
|
||||
username = session.get('username', 'unbekannt')
|
||||
logger.info(f"Session timeout for user {username} - auto logout")
|
||||
# Audit log for automatic logout (before session.clear()!)
|
||||
try:
|
||||
log_audit('AUTO_LOGOUT', 'session',
|
||||
additional_info={'reason': 'Session timeout (5 minutes)', 'username': username})
|
||||
except:
|
||||
pass
|
||||
session.clear()
|
||||
flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
# Activity is NOT automatically updated
|
||||
# Only on explicit user actions (done by heartbeat)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
@ -0,0 +1,11 @@
|
||||
import bcrypt
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
"""Hash a password using bcrypt"""
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(password, hashed):
|
||||
"""Verify a password against its hash"""
|
||||
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
||||
@ -0,0 +1,124 @@
|
||||
import random
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import request
|
||||
from db import execute_query, get_db_connection, get_db_cursor
|
||||
from config import FAIL_MESSAGES, MAX_LOGIN_ATTEMPTS, BLOCK_DURATION_HOURS, EMAIL_ENABLED
|
||||
from utils.audit import log_audit
|
||||
from utils.network import get_client_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_ip_blocked(ip_address):
|
||||
"""Check if an IP address is blocked"""
|
||||
result = execute_query(
|
||||
"""
|
||||
SELECT blocked_until FROM login_attempts
|
||||
WHERE ip_address = %s AND blocked_until IS NOT NULL
|
||||
""",
|
||||
(ip_address,),
|
||||
fetch_one=True
|
||||
)
|
||||
|
||||
if result and result[0]:
|
||||
if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None):
|
||||
return True, result[0]
|
||||
return False, None
|
||||
|
||||
|
||||
def record_failed_attempt(ip_address, username):
|
||||
"""Record a failed login attempt"""
|
||||
# Random error message
|
||||
error_message = random.choice(FAIL_MESSAGES)
|
||||
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
try:
|
||||
# Check if IP already exists
|
||||
cur.execute("""
|
||||
SELECT attempt_count FROM login_attempts
|
||||
WHERE ip_address = %s
|
||||
""", (ip_address,))
|
||||
|
||||
result = cur.fetchone()
|
||||
|
||||
if result:
|
||||
# Update existing entry
|
||||
new_count = result[0] + 1
|
||||
blocked_until = None
|
||||
|
||||
if new_count >= MAX_LOGIN_ATTEMPTS:
|
||||
blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS)
|
||||
# Email notification (if enabled)
|
||||
if EMAIL_ENABLED:
|
||||
send_security_alert_email(ip_address, username, new_count)
|
||||
|
||||
cur.execute("""
|
||||
UPDATE login_attempts
|
||||
SET attempt_count = %s,
|
||||
last_attempt = CURRENT_TIMESTAMP,
|
||||
blocked_until = %s,
|
||||
last_username_tried = %s,
|
||||
last_error_message = %s
|
||||
WHERE ip_address = %s
|
||||
""", (new_count, blocked_until, username, error_message, ip_address))
|
||||
else:
|
||||
# Create new entry
|
||||
cur.execute("""
|
||||
INSERT INTO login_attempts
|
||||
(ip_address, attempt_count, last_username_tried, last_error_message)
|
||||
VALUES (%s, 1, %s, %s)
|
||||
""", (ip_address, username, error_message))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit log
|
||||
log_audit('LOGIN_FAILED', 'user',
|
||||
additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Rate limiting error: {e}")
|
||||
conn.rollback()
|
||||
|
||||
return error_message
|
||||
|
||||
|
||||
def reset_login_attempts(ip_address):
|
||||
"""Reset login attempts for an IP"""
|
||||
execute_query(
|
||||
"DELETE FROM login_attempts WHERE ip_address = %s",
|
||||
(ip_address,)
|
||||
)
|
||||
|
||||
|
||||
def get_login_attempts(ip_address):
|
||||
"""Get the number of login attempts for an IP"""
|
||||
result = execute_query(
|
||||
"SELECT attempt_count FROM login_attempts WHERE ip_address = %s",
|
||||
(ip_address,),
|
||||
fetch_one=True
|
||||
)
|
||||
return result[0] if result else 0
|
||||
|
||||
|
||||
def send_security_alert_email(ip_address, username, attempt_count):
|
||||
"""Send a security alert email"""
|
||||
subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche"
|
||||
body = f"""
|
||||
WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt!
|
||||
|
||||
IP-Adresse: {ip_address}
|
||||
Versuchter Benutzername: {username}
|
||||
Anzahl Versuche: {attempt_count}
|
||||
Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
Die IP-Adresse wurde für 24 Stunden gesperrt.
|
||||
|
||||
Dies ist eine automatische Nachricht vom v2-Docker Admin Panel.
|
||||
"""
|
||||
|
||||
# TODO: Email sending implementation when SMTP is configured
|
||||
logger.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}")
|
||||
print(f"E-Mail würde gesendet: {subject}")
|
||||
@ -0,0 +1,57 @@
|
||||
import pyotp
|
||||
import qrcode
|
||||
import random
|
||||
import string
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import base64
|
||||
|
||||
|
||||
def generate_totp_secret():
|
||||
"""Generate a new TOTP secret"""
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def generate_qr_code(username, totp_secret):
|
||||
"""Generate QR code for TOTP setup"""
|
||||
totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
|
||||
name=username,
|
||||
issuer_name='V2 Admin Panel'
|
||||
)
|
||||
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(totp_uri)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buf = BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def verify_totp(totp_secret, token):
|
||||
"""Verify a TOTP token"""
|
||||
totp = pyotp.TOTP(totp_secret)
|
||||
return totp.verify(token, valid_window=1)
|
||||
|
||||
|
||||
def generate_backup_codes(count=8):
|
||||
"""Generate backup codes for 2FA recovery"""
|
||||
codes = []
|
||||
for _ in range(count):
|
||||
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||||
codes.append(code)
|
||||
return codes
|
||||
|
||||
|
||||
def hash_backup_code(code):
|
||||
"""Hash a backup code for storage"""
|
||||
return hashlib.sha256(code.encode()).hexdigest()
|
||||
|
||||
|
||||
def verify_backup_code(code, hashed_codes):
|
||||
"""Verify a backup code against stored hashes"""
|
||||
code_hash = hashlib.sha256(code.encode()).hexdigest()
|
||||
return code_hash in hashed_codes
|
||||
64
backups/refactoring_20250616_223724/v2_adminpanel_backup/config.py
Normale Datei
64
backups/refactoring_20250616_223724/v2_adminpanel_backup/config.py
Normale Datei
@ -0,0 +1,64 @@
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Flask Configuration
|
||||
SECRET_KEY = os.urandom(24)
|
||||
SESSION_TYPE = 'filesystem'
|
||||
JSON_AS_ASCII = False
|
||||
JSONIFY_MIMETYPE = 'application/json; charset=utf-8'
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(minutes=5)
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SECURE = False # Set to True when HTTPS (internal runs HTTP)
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
SESSION_COOKIE_NAME = 'admin_session'
|
||||
SESSION_REFRESH_EACH_REQUEST = False
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_CONFIG = {
|
||||
'host': os.getenv("POSTGRES_HOST", "postgres"),
|
||||
'port': os.getenv("POSTGRES_PORT", "5432"),
|
||||
'dbname': os.getenv("POSTGRES_DB"),
|
||||
'user': os.getenv("POSTGRES_USER"),
|
||||
'password': os.getenv("POSTGRES_PASSWORD"),
|
||||
'options': '-c client_encoding=UTF8'
|
||||
}
|
||||
|
||||
# Backup Configuration
|
||||
BACKUP_DIR = Path("/app/backups")
|
||||
BACKUP_DIR.mkdir(exist_ok=True)
|
||||
BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY")
|
||||
|
||||
# Rate Limiting Configuration
|
||||
FAIL_MESSAGES = [
|
||||
"NOPE!",
|
||||
"ACCESS DENIED, TRY HARDER",
|
||||
"WRONG! 🚫",
|
||||
"COMPUTER SAYS NO",
|
||||
"YOU FAILED"
|
||||
]
|
||||
MAX_LOGIN_ATTEMPTS = 5
|
||||
BLOCK_DURATION_HOURS = 24
|
||||
CAPTCHA_AFTER_ATTEMPTS = 2
|
||||
|
||||
# reCAPTCHA Configuration
|
||||
RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY')
|
||||
RECAPTCHA_SECRET_KEY = os.getenv('RECAPTCHA_SECRET_KEY')
|
||||
|
||||
# Email Configuration
|
||||
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "false").lower() == "true"
|
||||
|
||||
# Admin Users (for backward compatibility)
|
||||
ADMIN_USERS = {
|
||||
os.getenv("ADMIN1_USERNAME"): os.getenv("ADMIN1_PASSWORD"),
|
||||
os.getenv("ADMIN2_USERNAME"): os.getenv("ADMIN2_PASSWORD")
|
||||
}
|
||||
|
||||
# Scheduler Configuration
|
||||
SCHEDULER_CONFIG = {
|
||||
'backup_hour': 3,
|
||||
'backup_minute': 0
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
# Netscape HTTP Cookie File
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
#HttpOnly_localhost FALSE / FALSE 1749329847 admin_session aojqyq4GcSt5oT7NJPeg7UHPoEZUVkn-s1Kr-EAnJWM
|
||||
@ -0,0 +1,20 @@
|
||||
-- Create users table if it doesn't exist
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
totp_secret VARCHAR(32),
|
||||
totp_enabled BOOLEAN DEFAULT FALSE,
|
||||
backup_codes TEXT, -- JSON array of hashed backup codes
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
password_reset_token VARCHAR(64),
|
||||
password_reset_expires TIMESTAMP WITH TIME ZONE,
|
||||
failed_2fa_attempts INTEGER DEFAULT 0,
|
||||
last_failed_2fa TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Index for faster login lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL;
|
||||
84
backups/refactoring_20250616_223724/v2_adminpanel_backup/db.py
Normale Datei
84
backups/refactoring_20250616_223724/v2_adminpanel_backup/db.py
Normale Datei
@ -0,0 +1,84 @@
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json, RealDictCursor
|
||||
from contextlib import contextmanager
|
||||
from config import DATABASE_CONFIG
|
||||
|
||||
|
||||
def get_connection():
|
||||
"""Create and return a new database connection"""
|
||||
conn = psycopg2.connect(**DATABASE_CONFIG)
|
||||
conn.set_client_encoding('UTF8')
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_connection():
|
||||
"""Context manager for database connections"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_cursor(conn=None):
|
||||
"""Context manager for database cursors"""
|
||||
if conn is None:
|
||||
with get_db_connection() as connection:
|
||||
cur = connection.cursor()
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
cur.close()
|
||||
else:
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_dict_cursor(conn=None):
|
||||
"""Context manager for dictionary cursors"""
|
||||
if conn is None:
|
||||
with get_db_connection() as connection:
|
||||
cur = connection.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
cur.close()
|
||||
else:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def execute_query(query, params=None, fetch_one=False, fetch_all=False, as_dict=False):
|
||||
"""Execute a query and optionally fetch results"""
|
||||
with get_db_connection() as conn:
|
||||
cursor_func = get_dict_cursor if as_dict else get_db_cursor
|
||||
with cursor_func(conn) as cur:
|
||||
cur.execute(query, params)
|
||||
|
||||
if fetch_one:
|
||||
return cur.fetchone()
|
||||
elif fetch_all:
|
||||
return cur.fetchall()
|
||||
else:
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def execute_many(query, params_list):
|
||||
"""Execute a query multiple times with different parameters"""
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.executemany(query, params_list)
|
||||
return cur.rowcount
|
||||
@ -0,0 +1,13 @@
|
||||
-- Fix für die fehlerhafte Migration - entfernt doppelte Bindestriche
|
||||
UPDATE licenses
|
||||
SET license_key = REPLACE(license_key, 'AF--', 'AF-')
|
||||
WHERE license_key LIKE 'AF--%';
|
||||
|
||||
UPDATE licenses
|
||||
SET license_key = REPLACE(license_key, '6--', '6-')
|
||||
WHERE license_key LIKE '%6--%';
|
||||
|
||||
-- Zeige die korrigierten Keys
|
||||
SELECT id, license_key, license_type
|
||||
FROM licenses
|
||||
ORDER BY id;
|
||||
282
backups/refactoring_20250616_223724/v2_adminpanel_backup/init.sql
Normale Datei
282
backups/refactoring_20250616_223724/v2_adminpanel_backup/init.sql
Normale Datei
@ -0,0 +1,282 @@
|
||||
-- UTF-8 Encoding für deutsche Sonderzeichen sicherstellen
|
||||
SET client_encoding = 'UTF8';
|
||||
|
||||
-- Zeitzone auf Europe/Berlin setzen
|
||||
SET timezone = 'Europe/Berlin';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
is_test BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_email UNIQUE (email)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
license_key TEXT UNIQUE NOT NULL,
|
||||
customer_id INTEGER REFERENCES customers(id),
|
||||
license_type TEXT NOT NULL,
|
||||
valid_from DATE NOT NULL,
|
||||
valid_until DATE NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_test BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
license_id INTEGER REFERENCES licenses(id),
|
||||
session_id TEXT UNIQUE NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TIMESTAMP WITH TIME ZONE,
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Audit-Log-Tabelle für Änderungsprotokolle
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER,
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
additional_info TEXT
|
||||
);
|
||||
|
||||
-- Index für bessere Performance bei Abfragen
|
||||
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC);
|
||||
CREATE INDEX idx_audit_log_username ON audit_log(username);
|
||||
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id);
|
||||
|
||||
-- Backup-Historie-Tabelle
|
||||
CREATE TABLE IF NOT EXISTS backup_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
filepath TEXT NOT NULL,
|
||||
filesize BIGINT,
|
||||
backup_type TEXT NOT NULL, -- 'manual' oder 'scheduled'
|
||||
status TEXT NOT NULL, -- 'success', 'failed', 'in_progress'
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT NOT NULL,
|
||||
tables_count INTEGER,
|
||||
records_count INTEGER,
|
||||
duration_seconds NUMERIC,
|
||||
is_encrypted BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Index für bessere Performance
|
||||
CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC);
|
||||
CREATE INDEX idx_backup_history_status ON backup_history(status);
|
||||
|
||||
-- Login-Attempts-Tabelle für Rate-Limiting
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
ip_address VARCHAR(45) PRIMARY KEY,
|
||||
attempt_count INTEGER DEFAULT 0,
|
||||
first_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
blocked_until TIMESTAMP WITH TIME ZONE NULL,
|
||||
last_username_tried TEXT,
|
||||
last_error_message TEXT
|
||||
);
|
||||
|
||||
-- Index für schnelle Abfragen
|
||||
CREATE INDEX idx_login_attempts_blocked_until ON login_attempts(blocked_until);
|
||||
CREATE INDEX idx_login_attempts_last_attempt ON login_attempts(last_attempt DESC);
|
||||
|
||||
-- Migration: Füge created_at zu licenses hinzu, falls noch nicht vorhanden
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'created_at') THEN
|
||||
ALTER TABLE licenses ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- Setze created_at für bestehende Einträge auf das valid_from Datum
|
||||
UPDATE licenses SET created_at = valid_from WHERE created_at IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ===================== RESOURCE POOL SYSTEM =====================
|
||||
|
||||
-- Haupttabelle für den Resource Pool
|
||||
CREATE TABLE IF NOT EXISTS resource_pools (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resource_type VARCHAR(20) NOT NULL CHECK (resource_type IN ('domain', 'ipv4', 'phone')),
|
||||
resource_value VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('available', 'allocated', 'quarantine')),
|
||||
allocated_to_license INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
|
||||
status_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
status_changed_by VARCHAR(50),
|
||||
quarantine_reason VARCHAR(100) CHECK (quarantine_reason IN ('abuse', 'defect', 'maintenance', 'blacklisted', 'expired', 'review', NULL)),
|
||||
quarantine_until TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
notes TEXT,
|
||||
is_test BOOLEAN DEFAULT FALSE,
|
||||
UNIQUE(resource_type, resource_value)
|
||||
);
|
||||
|
||||
-- Resource History für vollständige Nachverfolgbarkeit
|
||||
CREATE TABLE IF NOT EXISTS resource_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL CHECK (action IN ('allocated', 'deallocated', 'quarantined', 'released', 'created', 'deleted')),
|
||||
action_by VARCHAR(50) NOT NULL,
|
||||
action_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
details JSONB,
|
||||
ip_address TEXT
|
||||
);
|
||||
|
||||
-- Resource Metrics für Performance-Tracking und ROI
|
||||
CREATE TABLE IF NOT EXISTS resource_metrics (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||||
metric_date DATE NOT NULL,
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
performance_score DECIMAL(5,2) DEFAULT 0.00,
|
||||
cost DECIMAL(10,2) DEFAULT 0.00,
|
||||
revenue DECIMAL(10,2) DEFAULT 0.00,
|
||||
issues_count INTEGER DEFAULT 0,
|
||||
availability_percent DECIMAL(5,2) DEFAULT 100.00,
|
||||
UNIQUE(resource_id, metric_date)
|
||||
);
|
||||
|
||||
-- Zuordnungstabelle zwischen Lizenzen und Ressourcen
|
||||
CREATE TABLE IF NOT EXISTS license_resources (
|
||||
id SERIAL PRIMARY KEY,
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
|
||||
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
assigned_by VARCHAR(50),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
UNIQUE(license_id, resource_id)
|
||||
);
|
||||
|
||||
-- Erweiterung der licenses Tabelle um Resource-Counts
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'domain_count') THEN
|
||||
ALTER TABLE licenses
|
||||
ADD COLUMN domain_count INTEGER DEFAULT 1 CHECK (domain_count >= 0 AND domain_count <= 10),
|
||||
ADD COLUMN ipv4_count INTEGER DEFAULT 1 CHECK (ipv4_count >= 0 AND ipv4_count <= 10),
|
||||
ADD COLUMN phone_count INTEGER DEFAULT 1 CHECK (phone_count >= 0 AND phone_count <= 10);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Erweiterung der licenses Tabelle um device_limit
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'device_limit') THEN
|
||||
ALTER TABLE licenses
|
||||
ADD COLUMN device_limit INTEGER DEFAULT 3 CHECK (device_limit >= 1 AND device_limit <= 10);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Tabelle für Geräte-Registrierungen
|
||||
CREATE TABLE IF NOT EXISTS device_registrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
hardware_id TEXT NOT NULL,
|
||||
device_name TEXT,
|
||||
operating_system TEXT,
|
||||
first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
deactivated_at TIMESTAMP WITH TIME ZONE,
|
||||
deactivated_by TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
UNIQUE(license_id, hardware_id)
|
||||
);
|
||||
|
||||
-- Indizes für device_registrations
|
||||
CREATE INDEX IF NOT EXISTS idx_device_license ON device_registrations(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_hardware ON device_registrations(hardware_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_active ON device_registrations(license_id, is_active) WHERE is_active = TRUE;
|
||||
|
||||
-- Indizes für Performance
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_status ON resource_pools(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_type_status ON resource_pools(resource_type, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_allocated ON resource_pools(allocated_to_license) WHERE allocated_to_license IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_quarantine ON resource_pools(quarantine_until) WHERE status = 'quarantine';
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_history_date ON resource_history(action_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE;
|
||||
|
||||
-- Users table for authentication with password and 2FA support
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
totp_secret VARCHAR(32),
|
||||
totp_enabled BOOLEAN DEFAULT FALSE,
|
||||
backup_codes TEXT, -- JSON array of hashed backup codes
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
password_reset_token VARCHAR(64),
|
||||
password_reset_expires TIMESTAMP WITH TIME ZONE,
|
||||
failed_2fa_attempts INTEGER DEFAULT 0,
|
||||
last_failed_2fa TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Index for faster login lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL;
|
||||
|
||||
-- Migration: Add is_test column to licenses if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'is_test') THEN
|
||||
ALTER TABLE licenses ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Mark all existing licenses as test data
|
||||
UPDATE licenses SET is_test = TRUE;
|
||||
|
||||
-- Add index for better performance when filtering test data
|
||||
CREATE INDEX idx_licenses_is_test ON licenses(is_test);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add is_test column to customers if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'customers' AND column_name = 'is_test') THEN
|
||||
ALTER TABLE customers ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Mark all existing customers as test data
|
||||
UPDATE customers SET is_test = TRUE;
|
||||
|
||||
-- Add index for better performance
|
||||
CREATE INDEX idx_customers_is_test ON customers(is_test);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add is_test column to resource_pools if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'resource_pools' AND column_name = 'is_test') THEN
|
||||
ALTER TABLE resource_pools ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Mark all existing resources as test data
|
||||
UPDATE resource_pools SET is_test = TRUE;
|
||||
|
||||
-- Add index for better performance
|
||||
CREATE INDEX idx_resource_pools_is_test ON resource_pools(is_test);
|
||||
END IF;
|
||||
END $$;
|
||||
@ -0,0 +1,5 @@
|
||||
-- Markiere alle existierenden Ressourcen als Testdaten
|
||||
UPDATE resource_pools SET is_test = TRUE WHERE is_test = FALSE OR is_test IS NULL;
|
||||
|
||||
-- Zeige Anzahl der aktualisierten Ressourcen
|
||||
SELECT COUNT(*) as updated_resources FROM resource_pools WHERE is_test = TRUE;
|
||||
@ -0,0 +1,13 @@
|
||||
-- Migration: Setze device_limit für bestehende Test-Lizenzen auf 3
|
||||
-- Dieses Script wird nur einmal ausgeführt, um bestehende Lizenzen zu aktualisieren
|
||||
|
||||
-- Setze device_limit = 3 für alle bestehenden Lizenzen, die noch keinen Wert haben
|
||||
UPDATE licenses
|
||||
SET device_limit = 3
|
||||
WHERE device_limit IS NULL;
|
||||
|
||||
-- Bestätige die Änderung
|
||||
SELECT COUNT(*) as updated_licenses,
|
||||
COUNT(CASE WHEN is_test = TRUE THEN 1 END) as test_licenses_updated
|
||||
FROM licenses
|
||||
WHERE device_limit = 3;
|
||||
@ -0,0 +1,54 @@
|
||||
-- Migration der Lizenzschlüssel vom alten Format zum neuen Format
|
||||
-- Alt: AF-YYYYMMFT-XXXX-YYYY-ZZZZ
|
||||
-- Neu: AF-F-YYYYMM-XXXX-YYYY-ZZZZ
|
||||
|
||||
-- Backup der aktuellen Schlüssel erstellen (für Sicherheit)
|
||||
CREATE TEMP TABLE license_backup AS
|
||||
SELECT id, license_key FROM licenses;
|
||||
|
||||
-- Update für Fullversion Keys (F)
|
||||
UPDATE licenses
|
||||
SET license_key =
|
||||
CONCAT(
|
||||
SUBSTRING(license_key, 1, 3), -- 'AF-'
|
||||
'-F-',
|
||||
SUBSTRING(license_key, 4, 6), -- 'YYYYMM'
|
||||
'-',
|
||||
SUBSTRING(license_key, 11) -- Rest des Keys
|
||||
)
|
||||
WHERE license_key LIKE 'AF-%F-%'
|
||||
AND license_type = 'full'
|
||||
AND license_key NOT LIKE 'AF-F-%'; -- Nicht bereits migriert
|
||||
|
||||
-- Update für Testversion Keys (T)
|
||||
UPDATE licenses
|
||||
SET license_key =
|
||||
CONCAT(
|
||||
SUBSTRING(license_key, 1, 3), -- 'AF-'
|
||||
'-T-',
|
||||
SUBSTRING(license_key, 4, 6), -- 'YYYYMM'
|
||||
'-',
|
||||
SUBSTRING(license_key, 11) -- Rest des Keys
|
||||
)
|
||||
WHERE license_key LIKE 'AF-%T-%'
|
||||
AND license_type = 'test'
|
||||
AND license_key NOT LIKE 'AF-T-%'; -- Nicht bereits migriert
|
||||
|
||||
-- Zeige die Änderungen
|
||||
SELECT
|
||||
b.license_key as old_key,
|
||||
l.license_key as new_key,
|
||||
l.license_type
|
||||
FROM licenses l
|
||||
JOIN license_backup b ON l.id = b.id
|
||||
WHERE b.license_key != l.license_key
|
||||
ORDER BY l.id;
|
||||
|
||||
-- Anzahl der migrierten Keys
|
||||
SELECT
|
||||
COUNT(*) as total_migrated,
|
||||
SUM(CASE WHEN license_type = 'full' THEN 1 ELSE 0 END) as full_licenses,
|
||||
SUM(CASE WHEN license_type = 'test' THEN 1 ELSE 0 END) as test_licenses
|
||||
FROM licenses l
|
||||
JOIN license_backup b ON l.id = b.id
|
||||
WHERE b.license_key != l.license_key;
|
||||
@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to create initial users in the database from environment variables
|
||||
Run this once after creating the users table
|
||||
"""
|
||||
|
||||
import os
|
||||
import psycopg2
|
||||
import bcrypt
|
||||
from dotenv import load_dotenv
|
||||
from datetime import datetime
|
||||
|
||||
load_dotenv()
|
||||
|
||||
def get_connection():
|
||||
return psycopg2.connect(
|
||||
host=os.getenv("POSTGRES_HOST", "postgres"),
|
||||
port=os.getenv("POSTGRES_PORT", "5432"),
|
||||
dbname=os.getenv("POSTGRES_DB"),
|
||||
user=os.getenv("POSTGRES_USER"),
|
||||
password=os.getenv("POSTGRES_PASSWORD"),
|
||||
options='-c client_encoding=UTF8'
|
||||
)
|
||||
|
||||
def hash_password(password):
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
def migrate_users():
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if users already exist
|
||||
cur.execute("SELECT COUNT(*) FROM users")
|
||||
user_count = cur.fetchone()[0]
|
||||
|
||||
if user_count > 0:
|
||||
print(f"Users table already contains {user_count} users. Skipping migration.")
|
||||
return
|
||||
|
||||
# Get admin users from environment
|
||||
admin1_user = os.getenv("ADMIN1_USERNAME")
|
||||
admin1_pass = os.getenv("ADMIN1_PASSWORD")
|
||||
admin2_user = os.getenv("ADMIN2_USERNAME")
|
||||
admin2_pass = os.getenv("ADMIN2_PASSWORD")
|
||||
|
||||
if not all([admin1_user, admin1_pass, admin2_user, admin2_pass]):
|
||||
print("ERROR: Admin credentials not found in environment variables!")
|
||||
return
|
||||
|
||||
# Insert admin users
|
||||
users = [
|
||||
(admin1_user, hash_password(admin1_pass), f"{admin1_user}@v2-admin.local"),
|
||||
(admin2_user, hash_password(admin2_pass), f"{admin2_user}@v2-admin.local")
|
||||
]
|
||||
|
||||
for username, password_hash, email in users:
|
||||
cur.execute("""
|
||||
INSERT INTO users (username, password_hash, email, totp_enabled, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""", (username, password_hash, email, False, datetime.now()))
|
||||
print(f"Created user: {username}")
|
||||
|
||||
conn.commit()
|
||||
print("\nMigration completed successfully!")
|
||||
print("Users can now log in with their existing credentials.")
|
||||
print("They can enable 2FA from their profile page.")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"ERROR during migration: {e}")
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Starting user migration...")
|
||||
migrate_users()
|
||||
29
backups/refactoring_20250616_223724/v2_adminpanel_backup/models.py
Normale Datei
29
backups/refactoring_20250616_223724/v2_adminpanel_backup/models.py
Normale Datei
@ -0,0 +1,29 @@
|
||||
# Temporary models file - will be expanded in Phase 3
|
||||
from db import execute_query
|
||||
|
||||
|
||||
def get_user_by_username(username):
|
||||
"""Get user from database by username"""
|
||||
result = execute_query(
|
||||
"""
|
||||
SELECT id, username, password_hash, email, totp_secret, totp_enabled,
|
||||
backup_codes, last_password_change, failed_2fa_attempts
|
||||
FROM users WHERE username = %s
|
||||
""",
|
||||
(username,),
|
||||
fetch_one=True
|
||||
)
|
||||
|
||||
if result:
|
||||
return {
|
||||
'id': result[0],
|
||||
'username': result[1],
|
||||
'password_hash': result[2],
|
||||
'email': result[3],
|
||||
'totp_secret': result[4],
|
||||
'totp_enabled': result[5],
|
||||
'backup_codes': result[6],
|
||||
'last_password_change': result[7],
|
||||
'failed_2fa_attempts': result[8]
|
||||
}
|
||||
return None
|
||||
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Remove duplicate routes that have been moved to blueprints
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Read the current app.py
|
||||
with open('app.py', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# List of function names that have been moved to blueprints
|
||||
moved_functions = [
|
||||
# Auth routes
|
||||
'login',
|
||||
'logout',
|
||||
'verify_2fa',
|
||||
'profile',
|
||||
'change_password',
|
||||
'setup_2fa',
|
||||
'enable_2fa',
|
||||
'disable_2fa',
|
||||
'heartbeat',
|
||||
# Admin routes
|
||||
'dashboard',
|
||||
'audit_log',
|
||||
'backups',
|
||||
'create_backup_route',
|
||||
'restore_backup_route',
|
||||
'download_backup',
|
||||
'delete_backup',
|
||||
'blocked_ips',
|
||||
'unblock_ip',
|
||||
'clear_attempts'
|
||||
]
|
||||
|
||||
# Create a pattern to match route decorators and their functions
|
||||
for func_name in moved_functions:
|
||||
# Pattern to match from @app.route to the end of the function
|
||||
pattern = rf'@app\.route\([^)]+\)\s*(?:@login_required\s*)?def {func_name}\([^)]*\):.*?(?=\n@app\.route|\n@[a-zA-Z]|\nif __name__|$)'
|
||||
|
||||
# Replace with a comment
|
||||
replacement = f'# Function {func_name} moved to blueprint'
|
||||
|
||||
content = re.sub(pattern, replacement, content, flags=re.DOTALL)
|
||||
|
||||
# Write the modified content
|
||||
with open('app_no_duplicates.py', 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
print("Created app_no_duplicates.py with duplicate routes removed")
|
||||
print("Please review the file before using it")
|
||||
@ -0,0 +1,14 @@
|
||||
flask
|
||||
flask-session
|
||||
psycopg2-binary
|
||||
python-dotenv
|
||||
pyopenssl
|
||||
pandas
|
||||
openpyxl
|
||||
cryptography
|
||||
apscheduler
|
||||
requests
|
||||
python-dateutil
|
||||
bcrypt
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
@ -0,0 +1,2 @@
|
||||
# Routes module initialization
|
||||
# This module contains all Flask blueprints organized by functionality
|
||||
@ -0,0 +1,540 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file, jsonify
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from utils.backup import create_backup, restore_backup
|
||||
from utils.network import get_client_ip
|
||||
from db import get_connection, get_db_connection, get_db_cursor, execute_query
|
||||
from utils.export import create_excel_export, prepare_audit_export_data
|
||||
|
||||
# Create Blueprint
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
@admin_bp.route("/")
|
||||
@login_required
|
||||
def dashboard():
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole Statistiken
|
||||
# Anzahl aktiver Lizenzen
|
||||
cur.execute("SELECT COUNT(*) FROM licenses WHERE active = true")
|
||||
active_licenses = cur.fetchone()[0]
|
||||
|
||||
# Anzahl Kunden
|
||||
cur.execute("SELECT COUNT(*) FROM customers")
|
||||
total_customers = cur.fetchone()[0]
|
||||
|
||||
# Anzahl aktiver Sessions
|
||||
cur.execute("SELECT COUNT(*) FROM sessions WHERE active = true")
|
||||
active_sessions = cur.fetchone()[0]
|
||||
|
||||
# Top 10 Lizenzen nach Nutzung (letzte 30 Tage)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
l.license_key,
|
||||
c.name as customer_name,
|
||||
COUNT(DISTINCT s.id) as session_count,
|
||||
COUNT(DISTINCT s.username) as unique_users,
|
||||
MAX(s.last_activity) as last_activity
|
||||
FROM licenses l
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
LEFT JOIN sessions s ON l.license_key = s.license_key
|
||||
AND s.login_time >= CURRENT_TIMESTAMP - INTERVAL '30 days'
|
||||
GROUP BY l.license_key, c.name
|
||||
ORDER BY session_count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
top_licenses = cur.fetchall()
|
||||
|
||||
# Letzte 10 Aktivitäten aus dem Audit Log
|
||||
cur.execute("""
|
||||
SELECT
|
||||
id,
|
||||
timestamp AT TIME ZONE 'Europe/Berlin' as timestamp,
|
||||
username,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
additional_info
|
||||
FROM audit_log
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
recent_activities = cur.fetchall()
|
||||
|
||||
# Lizenztyp-Verteilung
|
||||
cur.execute("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN is_test_license THEN 'Test'
|
||||
ELSE 'Full'
|
||||
END as license_type,
|
||||
COUNT(*) as count
|
||||
FROM licenses
|
||||
GROUP BY is_test_license
|
||||
""")
|
||||
license_distribution = cur.fetchall()
|
||||
|
||||
# Sessions nach Stunden (letzte 24h)
|
||||
cur.execute("""
|
||||
WITH hours AS (
|
||||
SELECT generate_series(
|
||||
CURRENT_TIMESTAMP - INTERVAL '23 hours',
|
||||
CURRENT_TIMESTAMP,
|
||||
INTERVAL '1 hour'
|
||||
) AS hour
|
||||
)
|
||||
SELECT
|
||||
TO_CHAR(hours.hour AT TIME ZONE 'Europe/Berlin', 'HH24:00') as hour_label,
|
||||
COUNT(DISTINCT s.id) as session_count
|
||||
FROM hours
|
||||
LEFT JOIN sessions s ON
|
||||
s.login_time >= hours.hour AND
|
||||
s.login_time < hours.hour + INTERVAL '1 hour'
|
||||
GROUP BY hours.hour
|
||||
ORDER BY hours.hour
|
||||
""")
|
||||
hourly_sessions = cur.fetchall()
|
||||
|
||||
# System-Status
|
||||
cur.execute("SELECT pg_database_size(current_database())")
|
||||
db_size = cur.fetchone()[0]
|
||||
|
||||
# Letzte Backup-Info
|
||||
cur.execute("""
|
||||
SELECT filename, created_at, filesize, status
|
||||
FROM backup_history
|
||||
WHERE status = 'success'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
last_backup = cur.fetchone()
|
||||
|
||||
# Resource Statistiken
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'available') as available,
|
||||
COUNT(*) FILTER (WHERE status = 'in_use') as in_use,
|
||||
COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
|
||||
COUNT(*) as total
|
||||
FROM resources
|
||||
""")
|
||||
resource_stats = cur.fetchone()
|
||||
|
||||
return render_template('dashboard.html',
|
||||
active_licenses=active_licenses,
|
||||
total_customers=total_customers,
|
||||
active_sessions=active_sessions,
|
||||
top_licenses=top_licenses,
|
||||
recent_activities=recent_activities,
|
||||
license_distribution=license_distribution,
|
||||
hourly_sessions=hourly_sessions,
|
||||
db_size=db_size,
|
||||
last_backup=last_backup,
|
||||
resource_stats=resource_stats,
|
||||
username=session.get('username'))
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@admin_bp.route("/audit")
|
||||
@login_required
|
||||
def audit_log():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 50
|
||||
search = request.args.get('search', '')
|
||||
action_filter = request.args.get('action', '')
|
||||
entity_filter = request.args.get('entity', '')
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Base query
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
timestamp AT TIME ZONE 'Europe/Berlin' as timestamp,
|
||||
username,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
old_values::text,
|
||||
new_values::text,
|
||||
ip_address,
|
||||
user_agent,
|
||||
additional_info
|
||||
FROM audit_log
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
# Suchfilter
|
||||
if search:
|
||||
query += """ AND (
|
||||
username ILIKE %s OR
|
||||
action ILIKE %s OR
|
||||
entity_type ILIKE %s OR
|
||||
additional_info ILIKE %s OR
|
||||
ip_address ILIKE %s
|
||||
)"""
|
||||
search_param = f"%{search}%"
|
||||
params.extend([search_param] * 5)
|
||||
|
||||
# Action Filter
|
||||
if action_filter:
|
||||
query += " AND action = %s"
|
||||
params.append(action_filter)
|
||||
|
||||
# Entity Filter
|
||||
if entity_filter:
|
||||
query += " AND entity_type = %s"
|
||||
params.append(entity_filter)
|
||||
|
||||
# Count total
|
||||
count_query = f"SELECT COUNT(*) FROM ({query}) as filtered"
|
||||
cur.execute(count_query, params)
|
||||
total_count = cur.fetchone()[0]
|
||||
|
||||
# Add pagination
|
||||
query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s"
|
||||
params.extend([per_page, (page - 1) * per_page])
|
||||
|
||||
cur.execute(query, params)
|
||||
logs = cur.fetchall()
|
||||
|
||||
# Get unique actions and entities for filters
|
||||
cur.execute("SELECT DISTINCT action FROM audit_log ORDER BY action")
|
||||
actions = [row[0] for row in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type")
|
||||
entities = [row[0] for row in cur.fetchall()]
|
||||
|
||||
# Pagination info
|
||||
total_pages = (total_count + per_page - 1) // per_page
|
||||
|
||||
# Convert to dictionaries for easier template access
|
||||
audit_logs = []
|
||||
for log in logs:
|
||||
audit_logs.append({
|
||||
'id': log[0],
|
||||
'timestamp': log[1],
|
||||
'username': log[2],
|
||||
'action': log[3],
|
||||
'entity_type': log[4],
|
||||
'entity_id': log[5],
|
||||
'old_values': log[6],
|
||||
'new_values': log[7],
|
||||
'ip_address': log[8],
|
||||
'user_agent': log[9],
|
||||
'additional_info': log[10]
|
||||
})
|
||||
|
||||
return render_template('audit_log.html',
|
||||
logs=audit_logs,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
total_count=total_count,
|
||||
search=search,
|
||||
action_filter=action_filter,
|
||||
entity_filter=entity_filter,
|
||||
actions=actions,
|
||||
entities=entities,
|
||||
username=session.get('username'))
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@admin_bp.route("/backups")
|
||||
@login_required
|
||||
def backups():
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole alle Backups
|
||||
cur.execute("""
|
||||
SELECT
|
||||
id,
|
||||
filename,
|
||||
created_at AT TIME ZONE 'Europe/Berlin' as created_at,
|
||||
filesize,
|
||||
backup_type,
|
||||
status,
|
||||
created_by,
|
||||
duration_seconds,
|
||||
tables_count,
|
||||
records_count,
|
||||
error_message,
|
||||
is_encrypted
|
||||
FROM backup_history
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
backups = cur.fetchall()
|
||||
|
||||
# Prüfe ob Dateien noch existieren
|
||||
backups_with_status = []
|
||||
for backup in backups:
|
||||
backup_dict = {
|
||||
'id': backup[0],
|
||||
'filename': backup[1],
|
||||
'created_at': backup[2],
|
||||
'filesize': backup[3],
|
||||
'backup_type': backup[4],
|
||||
'status': backup[5],
|
||||
'created_by': backup[6],
|
||||
'duration_seconds': backup[7],
|
||||
'tables_count': backup[8],
|
||||
'records_count': backup[9],
|
||||
'error_message': backup[10],
|
||||
'is_encrypted': backup[11],
|
||||
'file_exists': False
|
||||
}
|
||||
|
||||
# Prüfe ob Datei existiert
|
||||
if backup[1]: # filename
|
||||
filepath = config.BACKUP_DIR / backup[1]
|
||||
backup_dict['file_exists'] = filepath.exists()
|
||||
|
||||
backups_with_status.append(backup_dict)
|
||||
|
||||
return render_template('backups.html',
|
||||
backups=backups_with_status,
|
||||
username=session.get('username'))
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@admin_bp.route("/backup/create", methods=["POST"])
|
||||
@login_required
|
||||
def create_backup_route():
|
||||
"""Manuelles Backup erstellen"""
|
||||
success, result = create_backup(backup_type="manual", created_by=session.get('username'))
|
||||
|
||||
if success:
|
||||
flash(f'Backup erfolgreich erstellt: {result}', 'success')
|
||||
else:
|
||||
flash(f'Backup fehlgeschlagen: {result}', 'error')
|
||||
|
||||
return redirect(url_for('admin.backups'))
|
||||
|
||||
|
||||
@admin_bp.route("/backup/restore/<int:backup_id>", methods=["POST"])
|
||||
@login_required
|
||||
def restore_backup_route(backup_id):
|
||||
"""Backup wiederherstellen"""
|
||||
encryption_key = request.form.get('encryption_key')
|
||||
|
||||
success, message = restore_backup(backup_id, encryption_key)
|
||||
|
||||
if success:
|
||||
flash(message, 'success')
|
||||
else:
|
||||
flash(f'Wiederherstellung fehlgeschlagen: {message}', 'error')
|
||||
|
||||
return redirect(url_for('admin.backups'))
|
||||
|
||||
|
||||
@admin_bp.route("/backup/download/<int:backup_id>")
|
||||
@login_required
|
||||
def download_backup(backup_id):
|
||||
"""Backup herunterladen"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole Backup-Info
|
||||
cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,))
|
||||
result = cur.fetchone()
|
||||
|
||||
if not result:
|
||||
flash('Backup nicht gefunden', 'error')
|
||||
return redirect(url_for('admin.backups'))
|
||||
|
||||
filename, filepath = result
|
||||
filepath = Path(filepath)
|
||||
|
||||
if not filepath.exists():
|
||||
flash('Backup-Datei nicht gefunden', 'error')
|
||||
return redirect(url_for('admin.backups'))
|
||||
|
||||
# Audit-Log
|
||||
log_audit('BACKUP_DOWNLOAD', 'backup', backup_id,
|
||||
additional_info=f"Backup heruntergeladen: {filename}")
|
||||
|
||||
return send_file(filepath, as_attachment=True, download_name=filename)
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@admin_bp.route("/backup/delete/<int:backup_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
def delete_backup(backup_id):
|
||||
"""Backup löschen"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole Backup-Info
|
||||
cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,))
|
||||
result = cur.fetchone()
|
||||
|
||||
if not result:
|
||||
return jsonify({'success': False, 'message': 'Backup nicht gefunden'}), 404
|
||||
|
||||
filename, filepath = result
|
||||
filepath = Path(filepath)
|
||||
|
||||
# Lösche Datei wenn vorhanden
|
||||
if filepath.exists():
|
||||
try:
|
||||
filepath.unlink()
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': f'Fehler beim Löschen der Datei: {str(e)}'}), 500
|
||||
|
||||
# Lösche Datenbank-Eintrag
|
||||
cur.execute("DELETE FROM backup_history WHERE id = %s", (backup_id,))
|
||||
conn.commit()
|
||||
|
||||
# Audit-Log
|
||||
log_audit('BACKUP_DELETE', 'backup', backup_id,
|
||||
additional_info=f"Backup gelöscht: {filename}")
|
||||
|
||||
return jsonify({'success': True, 'message': 'Backup erfolgreich gelöscht'})
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@admin_bp.route("/security/blocked-ips")
|
||||
@login_required
|
||||
def blocked_ips():
|
||||
"""Zeigt gesperrte IP-Adressen"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
ip_address,
|
||||
attempt_count,
|
||||
last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt,
|
||||
blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until,
|
||||
last_username_tried,
|
||||
last_error_message
|
||||
FROM login_attempts
|
||||
WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP
|
||||
ORDER BY blocked_until DESC
|
||||
""")
|
||||
blocked = cur.fetchall()
|
||||
|
||||
# Alle Login-Versuche (auch nicht gesperrte)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
ip_address,
|
||||
attempt_count,
|
||||
last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt,
|
||||
blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until,
|
||||
last_username_tried,
|
||||
last_error_message
|
||||
FROM login_attempts
|
||||
ORDER BY last_attempt DESC
|
||||
LIMIT 100
|
||||
""")
|
||||
all_attempts = cur.fetchall()
|
||||
|
||||
return render_template('blocked_ips.html',
|
||||
blocked_ips=blocked,
|
||||
all_attempts=all_attempts,
|
||||
username=session.get('username'))
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@admin_bp.route("/security/unblock-ip", methods=["POST"])
|
||||
@login_required
|
||||
def unblock_ip():
|
||||
"""Entsperrt eine IP-Adresse"""
|
||||
ip_address = request.form.get('ip_address')
|
||||
|
||||
if not ip_address:
|
||||
flash('Keine IP-Adresse angegeben', 'error')
|
||||
return redirect(url_for('admin.blocked_ips'))
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
UPDATE login_attempts
|
||||
SET blocked_until = NULL
|
||||
WHERE ip_address = %s
|
||||
""", (ip_address,))
|
||||
|
||||
if cur.rowcount > 0:
|
||||
conn.commit()
|
||||
flash(f'IP-Adresse {ip_address} wurde entsperrt', 'success')
|
||||
log_audit('UNBLOCK_IP', 'security',
|
||||
additional_info=f"IP-Adresse entsperrt: {ip_address}")
|
||||
else:
|
||||
flash(f'IP-Adresse {ip_address} nicht gefunden', 'warning')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
flash(f'Fehler beim Entsperren: {str(e)}', 'error')
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('admin.blocked_ips'))
|
||||
|
||||
|
||||
@admin_bp.route("/security/clear-attempts", methods=["POST"])
|
||||
@login_required
|
||||
def clear_attempts():
|
||||
"""Löscht alle Login-Versuche"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
cur.execute("DELETE FROM login_attempts")
|
||||
count = cur.rowcount
|
||||
conn.commit()
|
||||
|
||||
flash(f'{count} Login-Versuche wurden gelöscht', 'success')
|
||||
log_audit('CLEAR_LOGIN_ATTEMPTS', 'security',
|
||||
additional_info=f"{count} Login-Versuche gelöscht")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
flash(f'Fehler beim Löschen: {str(e)}', 'error')
|
||||
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('admin.blocked_ips'))
|
||||
@ -0,0 +1,906 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, request, jsonify, session
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from utils.network import get_client_ip
|
||||
from utils.license import generate_license_key
|
||||
from db import get_connection, get_db_connection, get_db_cursor
|
||||
from models import get_license_by_id
|
||||
|
||||
# Create Blueprint
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
@api_bp.route("/license/<int:license_id>/toggle", methods=["POST"])
|
||||
@login_required
|
||||
def toggle_license(license_id):
|
||||
"""Toggle license active status"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Get current status
|
||||
license_data = get_license_by_id(license_id)
|
||||
if not license_data:
|
||||
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
|
||||
|
||||
new_status = not license_data['active']
|
||||
|
||||
# Update status
|
||||
cur.execute("UPDATE licenses SET active = %s WHERE id = %s", (new_status, license_id))
|
||||
conn.commit()
|
||||
|
||||
# Log change
|
||||
log_audit('TOGGLE', 'license', license_id,
|
||||
old_values={'active': license_data['active']},
|
||||
new_values={'active': new_status})
|
||||
|
||||
return jsonify({'success': True, 'active': new_status})
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Umschalten der Lizenz: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Umschalten der Lizenz'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/licenses/bulk-activate", methods=["POST"])
|
||||
@login_required
|
||||
def bulk_activate_licenses():
|
||||
"""Aktiviere mehrere Lizenzen gleichzeitig"""
|
||||
data = request.get_json()
|
||||
license_ids = data.get('license_ids', [])
|
||||
|
||||
if not license_ids:
|
||||
return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Update all selected licenses
|
||||
cur.execute("""
|
||||
UPDATE licenses
|
||||
SET active = true
|
||||
WHERE id = ANY(%s) AND active = false
|
||||
RETURNING id
|
||||
""", (license_ids,))
|
||||
|
||||
updated_ids = [row[0] for row in cur.fetchall()]
|
||||
conn.commit()
|
||||
|
||||
# Log changes
|
||||
for license_id in updated_ids:
|
||||
log_audit('BULK_ACTIVATE', 'license', license_id,
|
||||
new_values={'active': True})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'updated_count': len(updated_ids)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Bulk-Aktivieren: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Aktivieren der Lizenzen'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/licenses/bulk-deactivate", methods=["POST"])
|
||||
@login_required
|
||||
def bulk_deactivate_licenses():
|
||||
"""Deaktiviere mehrere Lizenzen gleichzeitig"""
|
||||
data = request.get_json()
|
||||
license_ids = data.get('license_ids', [])
|
||||
|
||||
if not license_ids:
|
||||
return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Update all selected licenses
|
||||
cur.execute("""
|
||||
UPDATE licenses
|
||||
SET active = false
|
||||
WHERE id = ANY(%s) AND active = true
|
||||
RETURNING id
|
||||
""", (license_ids,))
|
||||
|
||||
updated_ids = [row[0] for row in cur.fetchall()]
|
||||
conn.commit()
|
||||
|
||||
# Log changes
|
||||
for license_id in updated_ids:
|
||||
log_audit('BULK_DEACTIVATE', 'license', license_id,
|
||||
new_values={'active': False})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'updated_count': len(updated_ids)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Bulk-Deaktivieren: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Deaktivieren der Lizenzen'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/license/<int:license_id>/devices")
|
||||
@login_required
|
||||
def get_license_devices(license_id):
|
||||
"""Hole alle Geräte einer Lizenz"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole Lizenz-Info
|
||||
license_data = get_license_by_id(license_id)
|
||||
if not license_data:
|
||||
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
|
||||
|
||||
# Hole registrierte Geräte
|
||||
cur.execute("""
|
||||
SELECT
|
||||
dr.id,
|
||||
dr.device_id,
|
||||
dr.device_name,
|
||||
dr.device_type,
|
||||
dr.registration_date,
|
||||
dr.last_seen,
|
||||
dr.is_active,
|
||||
(SELECT COUNT(*) FROM sessions s
|
||||
WHERE s.license_key = dr.license_key
|
||||
AND s.device_id = dr.device_id
|
||||
AND s.active = true) as active_sessions
|
||||
FROM device_registrations dr
|
||||
WHERE dr.license_key = %s
|
||||
ORDER BY dr.registration_date DESC
|
||||
""", (license_data['license_key'],))
|
||||
|
||||
devices = []
|
||||
for row in cur.fetchall():
|
||||
devices.append({
|
||||
'id': row[0],
|
||||
'device_id': row[1],
|
||||
'device_name': row[2],
|
||||
'device_type': row[3],
|
||||
'registration_date': row[4].isoformat() if row[4] else None,
|
||||
'last_seen': row[5].isoformat() if row[5] else None,
|
||||
'is_active': row[6],
|
||||
'active_sessions': row[7]
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'license_key': license_data['license_key'],
|
||||
'device_limit': license_data['device_limit'],
|
||||
'devices': devices,
|
||||
'device_count': len(devices)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Abrufen der Geräte'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/license/<int:license_id>/register-device", methods=["POST"])
|
||||
@login_required
|
||||
def register_device(license_id):
|
||||
"""Registriere ein neues Gerät für eine Lizenz"""
|
||||
data = request.get_json()
|
||||
|
||||
device_id = data.get('device_id')
|
||||
device_name = data.get('device_name')
|
||||
device_type = data.get('device_type', 'unknown')
|
||||
|
||||
if not device_id or not device_name:
|
||||
return jsonify({'error': 'Geräte-ID und Name erforderlich'}), 400
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole Lizenz-Info
|
||||
license_data = get_license_by_id(license_id)
|
||||
if not license_data:
|
||||
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
|
||||
|
||||
# Prüfe Gerätelimit
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM device_registrations
|
||||
WHERE license_key = %s AND is_active = true
|
||||
""", (license_data['license_key'],))
|
||||
|
||||
active_device_count = cur.fetchone()[0]
|
||||
|
||||
if active_device_count >= license_data['device_limit']:
|
||||
return jsonify({'error': 'Gerätelimit erreicht'}), 400
|
||||
|
||||
# Prüfe ob Gerät bereits registriert
|
||||
cur.execute("""
|
||||
SELECT id, is_active FROM device_registrations
|
||||
WHERE license_key = %s AND device_id = %s
|
||||
""", (license_data['license_key'], device_id))
|
||||
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
if existing[1]: # is_active
|
||||
return jsonify({'error': 'Gerät bereits registriert'}), 400
|
||||
else:
|
||||
# Reaktiviere Gerät
|
||||
cur.execute("""
|
||||
UPDATE device_registrations
|
||||
SET is_active = true, last_seen = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""", (existing[0],))
|
||||
else:
|
||||
# Registriere neues Gerät
|
||||
cur.execute("""
|
||||
INSERT INTO device_registrations
|
||||
(license_key, device_id, device_name, device_type, is_active)
|
||||
VALUES (%s, %s, %s, %s, true)
|
||||
""", (license_data['license_key'], device_id, device_name, device_type))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit-Log
|
||||
log_audit('DEVICE_REGISTER', 'license', license_id,
|
||||
additional_info=f"Gerät {device_name} ({device_id}) registriert")
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Registrieren des Geräts: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Registrieren des Geräts'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/license/<int:license_id>/deactivate-device/<int:device_id>", methods=["POST"])
|
||||
@login_required
|
||||
def deactivate_device(license_id, device_id):
|
||||
"""Deaktiviere ein Gerät einer Lizenz"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Prüfe ob Gerät zur Lizenz gehört
|
||||
cur.execute("""
|
||||
SELECT dr.device_name, dr.device_id, l.license_key
|
||||
FROM device_registrations dr
|
||||
JOIN licenses l ON dr.license_key = l.license_key
|
||||
WHERE dr.id = %s AND l.id = %s
|
||||
""", (device_id, license_id))
|
||||
|
||||
device = cur.fetchone()
|
||||
if not device:
|
||||
return jsonify({'error': 'Gerät nicht gefunden'}), 404
|
||||
|
||||
# Deaktiviere Gerät
|
||||
cur.execute("""
|
||||
UPDATE device_registrations
|
||||
SET is_active = false
|
||||
WHERE id = %s
|
||||
""", (device_id,))
|
||||
|
||||
# Beende aktive Sessions
|
||||
cur.execute("""
|
||||
UPDATE sessions
|
||||
SET active = false, logout_time = CURRENT_TIMESTAMP
|
||||
WHERE license_key = %s AND device_id = %s AND active = true
|
||||
""", (device[2], device[1]))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit-Log
|
||||
log_audit('DEVICE_DEACTIVATE', 'license', license_id,
|
||||
additional_info=f"Gerät {device[0]} ({device[1]}) deaktiviert")
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Deaktivieren des Geräts'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/licenses/bulk-delete", methods=["POST"])
|
||||
@login_required
|
||||
def bulk_delete_licenses():
|
||||
"""Lösche mehrere Lizenzen gleichzeitig"""
|
||||
data = request.get_json()
|
||||
license_ids = data.get('license_ids', [])
|
||||
|
||||
if not license_ids:
|
||||
return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
deleted_count = 0
|
||||
|
||||
for license_id in license_ids:
|
||||
# Hole Lizenz-Info für Audit
|
||||
cur.execute("SELECT license_key FROM licenses WHERE id = %s", (license_id,))
|
||||
result = cur.fetchone()
|
||||
|
||||
if result:
|
||||
license_key = result[0]
|
||||
|
||||
# Lösche Sessions
|
||||
cur.execute("DELETE FROM sessions WHERE license_key = %s", (license_key,))
|
||||
|
||||
# Lösche Geräte-Registrierungen
|
||||
cur.execute("DELETE FROM device_registrations WHERE license_key = %s", (license_key,))
|
||||
|
||||
# Lösche Lizenz
|
||||
cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,))
|
||||
|
||||
# Audit-Log
|
||||
log_audit('BULK_DELETE', 'license', license_id,
|
||||
old_values={'license_key': license_key})
|
||||
|
||||
deleted_count += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_count': deleted_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Bulk-Löschen: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Löschen der Lizenzen'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/license/<int:license_id>/quick-edit", methods=['POST'])
|
||||
@login_required
|
||||
def quick_edit_license(license_id):
|
||||
"""Schnellbearbeitung einer Lizenz"""
|
||||
data = request.get_json()
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole aktuelle Lizenz für Vergleich
|
||||
current_license = get_license_by_id(license_id)
|
||||
if not current_license:
|
||||
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
|
||||
|
||||
# Update nur die übergebenen Felder
|
||||
updates = []
|
||||
params = []
|
||||
old_values = {}
|
||||
new_values = {}
|
||||
|
||||
if 'device_limit' in data:
|
||||
updates.append("device_limit = %s")
|
||||
params.append(int(data['device_limit']))
|
||||
old_values['device_limit'] = current_license['device_limit']
|
||||
new_values['device_limit'] = int(data['device_limit'])
|
||||
|
||||
if 'valid_until' in data:
|
||||
updates.append("valid_until = %s")
|
||||
params.append(data['valid_until'])
|
||||
old_values['valid_until'] = str(current_license['valid_until'])
|
||||
new_values['valid_until'] = data['valid_until']
|
||||
|
||||
if 'active' in data:
|
||||
updates.append("active = %s")
|
||||
params.append(bool(data['active']))
|
||||
old_values['active'] = current_license['active']
|
||||
new_values['active'] = bool(data['active'])
|
||||
|
||||
if not updates:
|
||||
return jsonify({'error': 'Keine Änderungen angegeben'}), 400
|
||||
|
||||
# Führe Update aus
|
||||
params.append(license_id)
|
||||
cur.execute(f"""
|
||||
UPDATE licenses
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = %s
|
||||
""", params)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit-Log
|
||||
log_audit('QUICK_EDIT', 'license', license_id,
|
||||
old_values=old_values,
|
||||
new_values=new_values)
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler bei Schnellbearbeitung: {str(e)}")
|
||||
return jsonify({'error': 'Fehler bei der Bearbeitung'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/license/<int:license_id>/resources")
|
||||
@login_required
|
||||
def get_license_resources(license_id):
|
||||
"""Hole alle Ressourcen einer Lizenz"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole Lizenz-Info
|
||||
license_data = get_license_by_id(license_id)
|
||||
if not license_data:
|
||||
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
|
||||
|
||||
# Hole zugewiesene Ressourcen
|
||||
cur.execute("""
|
||||
SELECT
|
||||
rp.id,
|
||||
rp.resource_type,
|
||||
rp.resource_value,
|
||||
rp.is_test,
|
||||
rp.status_changed_at,
|
||||
lr.assigned_at,
|
||||
lr.assigned_by
|
||||
FROM resource_pools rp
|
||||
JOIN license_resources lr ON rp.id = lr.resource_id
|
||||
WHERE lr.license_id = %s
|
||||
ORDER BY rp.resource_type, rp.resource_value
|
||||
""", (license_id,))
|
||||
|
||||
resources = []
|
||||
for row in cur.fetchall():
|
||||
resources.append({
|
||||
'id': row[0],
|
||||
'type': row[1],
|
||||
'value': row[2],
|
||||
'is_test': row[3],
|
||||
'status_changed_at': row[4].isoformat() if row[4] else None,
|
||||
'assigned_at': row[5].isoformat() if row[5] else None,
|
||||
'assigned_by': row[6]
|
||||
})
|
||||
|
||||
# Gruppiere nach Typ
|
||||
grouped = {}
|
||||
for resource in resources:
|
||||
res_type = resource['type']
|
||||
if res_type not in grouped:
|
||||
grouped[res_type] = []
|
||||
grouped[res_type].append(resource)
|
||||
|
||||
return jsonify({
|
||||
'license_key': license_data['license_key'],
|
||||
'resources': resources,
|
||||
'grouped': grouped,
|
||||
'total_count': len(resources)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Abrufen der Ressourcen: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Abrufen der Ressourcen'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/resources/allocate", methods=['POST'])
|
||||
@login_required
|
||||
def allocate_resources():
|
||||
"""Weise Ressourcen einer Lizenz zu"""
|
||||
data = request.get_json()
|
||||
|
||||
license_id = data.get('license_id')
|
||||
resource_ids = data.get('resource_ids', [])
|
||||
|
||||
if not license_id or not resource_ids:
|
||||
return jsonify({'error': 'Lizenz-ID und Ressourcen erforderlich'}), 400
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Prüfe Lizenz
|
||||
license_data = get_license_by_id(license_id)
|
||||
if not license_data:
|
||||
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
|
||||
|
||||
allocated_count = 0
|
||||
errors = []
|
||||
|
||||
for resource_id in resource_ids:
|
||||
try:
|
||||
# Prüfe ob Ressource verfügbar ist
|
||||
cur.execute("""
|
||||
SELECT resource_value, status, is_test
|
||||
FROM resource_pools
|
||||
WHERE id = %s
|
||||
""", (resource_id,))
|
||||
|
||||
resource = cur.fetchone()
|
||||
if not resource:
|
||||
errors.append(f"Ressource {resource_id} nicht gefunden")
|
||||
continue
|
||||
|
||||
if resource[1] != 'available':
|
||||
errors.append(f"Ressource {resource[0]} ist nicht verfügbar")
|
||||
continue
|
||||
|
||||
# Prüfe Test/Produktion Kompatibilität
|
||||
if resource[2] != license_data['is_test']:
|
||||
errors.append(f"Ressource {resource[0]} ist {'Test' if resource[2] else 'Produktion'}, Lizenz ist {'Test' if license_data['is_test'] else 'Produktion'}")
|
||||
continue
|
||||
|
||||
# Weise Ressource zu
|
||||
cur.execute("""
|
||||
UPDATE resource_pools
|
||||
SET status = 'allocated',
|
||||
allocated_to_license = %s,
|
||||
status_changed_at = CURRENT_TIMESTAMP,
|
||||
status_changed_by = %s
|
||||
WHERE id = %s
|
||||
""", (license_id, session['username'], resource_id))
|
||||
|
||||
# Erstelle Verknüpfung
|
||||
cur.execute("""
|
||||
INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (license_id, resource_id, session['username']))
|
||||
|
||||
# History-Eintrag
|
||||
cur.execute("""
|
||||
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||||
VALUES (%s, %s, 'allocated', %s, %s)
|
||||
""", (resource_id, license_id, session['username'], get_client_ip()))
|
||||
|
||||
allocated_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Fehler bei Ressource {resource_id}: {str(e)}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit-Log
|
||||
if allocated_count > 0:
|
||||
log_audit('RESOURCE_ALLOCATE', 'license', license_id,
|
||||
additional_info=f"{allocated_count} Ressourcen zugewiesen")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'allocated_count': allocated_count,
|
||||
'errors': errors
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Zuweisen der Ressourcen: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Zuweisen der Ressourcen'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/resources/check-availability", methods=['GET'])
|
||||
@login_required
|
||||
def check_resource_availability():
|
||||
"""Prüfe Verfügbarkeit von Ressourcen"""
|
||||
resource_type = request.args.get('type')
|
||||
count = int(request.args.get('count', 1))
|
||||
is_test = request.args.get('is_test', 'false') == 'true'
|
||||
|
||||
if not resource_type:
|
||||
return jsonify({'error': 'Ressourcen-Typ erforderlich'}), 400
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Zähle verfügbare Ressourcen
|
||||
cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM resource_pools
|
||||
WHERE resource_type = %s
|
||||
AND status = 'available'
|
||||
AND is_test = %s
|
||||
""", (resource_type, is_test))
|
||||
|
||||
available_count = cur.fetchone()[0]
|
||||
|
||||
return jsonify({
|
||||
'resource_type': resource_type,
|
||||
'requested': count,
|
||||
'available': available_count,
|
||||
'sufficient': available_count >= count,
|
||||
'is_test': is_test
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Prüfen der Verfügbarkeit: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Prüfen der Verfügbarkeit'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/global-search", methods=['GET'])
|
||||
@login_required
|
||||
def global_search():
|
||||
"""Globale Suche über alle Entitäten"""
|
||||
query = request.args.get('q', '').strip()
|
||||
|
||||
if not query or len(query) < 3:
|
||||
return jsonify({'error': 'Suchbegriff muss mindestens 3 Zeichen haben'}), 400
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
results = {
|
||||
'licenses': [],
|
||||
'customers': [],
|
||||
'resources': [],
|
||||
'sessions': []
|
||||
}
|
||||
|
||||
try:
|
||||
# Suche in Lizenzen
|
||||
cur.execute("""
|
||||
SELECT id, license_key, customer_name, active
|
||||
FROM licenses
|
||||
WHERE license_key ILIKE %s
|
||||
OR customer_name ILIKE %s
|
||||
OR customer_email ILIKE %s
|
||||
LIMIT 10
|
||||
""", (f'%{query}%', f'%{query}%', f'%{query}%'))
|
||||
|
||||
for row in cur.fetchall():
|
||||
results['licenses'].append({
|
||||
'id': row[0],
|
||||
'license_key': row[1],
|
||||
'customer_name': row[2],
|
||||
'active': row[3]
|
||||
})
|
||||
|
||||
# Suche in Kunden
|
||||
cur.execute("""
|
||||
SELECT id, name, email
|
||||
FROM customers
|
||||
WHERE name ILIKE %s OR email ILIKE %s
|
||||
LIMIT 10
|
||||
""", (f'%{query}%', f'%{query}%'))
|
||||
|
||||
for row in cur.fetchall():
|
||||
results['customers'].append({
|
||||
'id': row[0],
|
||||
'name': row[1],
|
||||
'email': row[2]
|
||||
})
|
||||
|
||||
# Suche in Ressourcen
|
||||
cur.execute("""
|
||||
SELECT id, resource_type, resource_value, status
|
||||
FROM resource_pools
|
||||
WHERE resource_value ILIKE %s
|
||||
LIMIT 10
|
||||
""", (f'%{query}%',))
|
||||
|
||||
for row in cur.fetchall():
|
||||
results['resources'].append({
|
||||
'id': row[0],
|
||||
'type': row[1],
|
||||
'value': row[2],
|
||||
'status': row[3]
|
||||
})
|
||||
|
||||
# Suche in Sessions
|
||||
cur.execute("""
|
||||
SELECT id, license_key, username, device_id, active
|
||||
FROM sessions
|
||||
WHERE username ILIKE %s OR device_id ILIKE %s
|
||||
ORDER BY login_time DESC
|
||||
LIMIT 10
|
||||
""", (f'%{query}%', f'%{query}%'))
|
||||
|
||||
for row in cur.fetchall():
|
||||
results['sessions'].append({
|
||||
'id': row[0],
|
||||
'license_key': row[1],
|
||||
'username': row[2],
|
||||
'device_id': row[3],
|
||||
'active': row[4]
|
||||
})
|
||||
|
||||
return jsonify(results)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler bei der globalen Suche: {str(e)}")
|
||||
return jsonify({'error': 'Fehler bei der Suche'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@api_bp.route("/generate-license-key", methods=['POST'])
|
||||
@login_required
|
||||
def api_generate_key():
|
||||
"""API Endpoint zur Generierung eines neuen Lizenzschlüssels"""
|
||||
try:
|
||||
# Lizenztyp aus Request holen (default: full)
|
||||
data = request.get_json() or {}
|
||||
license_type = data.get('type', 'full')
|
||||
|
||||
# Key generieren
|
||||
key = generate_license_key(license_type)
|
||||
|
||||
# Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher)
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Wiederhole bis eindeutiger Key gefunden
|
||||
attempts = 0
|
||||
while attempts < 10: # Max 10 Versuche
|
||||
cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,))
|
||||
if not cur.fetchone():
|
||||
break # Key ist eindeutig
|
||||
key = generate_license_key(license_type)
|
||||
attempts += 1
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Log für Audit
|
||||
log_audit('GENERATE_KEY', 'license',
|
||||
additional_info={'type': license_type, 'key': key})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'key': key,
|
||||
'type': license_type
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler bei Key-Generierung: {str(e)}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Fehler bei der Key-Generierung'
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route("/customers", methods=['GET'])
|
||||
@login_required
|
||||
def api_customers():
|
||||
"""API Endpoint für die Kundensuche mit Select2"""
|
||||
try:
|
||||
# Suchparameter
|
||||
search = request.args.get('q', '').strip()
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
customer_id = request.args.get('id', type=int)
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Einzelnen Kunden per ID abrufen
|
||||
if customer_id:
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.email,
|
||||
COUNT(l.id) as license_count
|
||||
FROM customers c
|
||||
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||
WHERE c.id = %s
|
||||
GROUP BY c.id, c.name, c.email
|
||||
""", (customer_id,))
|
||||
|
||||
customer = cur.fetchone()
|
||||
results = []
|
||||
if customer:
|
||||
results.append({
|
||||
'id': customer[0],
|
||||
'text': f"{customer[1]} ({customer[2]})",
|
||||
'name': customer[1],
|
||||
'email': customer[2],
|
||||
'license_count': customer[3]
|
||||
})
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'results': results,
|
||||
'pagination': {'more': False}
|
||||
})
|
||||
|
||||
# SQL Query mit optionaler Suche
|
||||
elif search:
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.email,
|
||||
COUNT(l.id) as license_count
|
||||
FROM customers c
|
||||
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||
WHERE LOWER(c.name) LIKE LOWER(%s)
|
||||
OR LOWER(c.email) LIKE LOWER(%s)
|
||||
GROUP BY c.id, c.name, c.email
|
||||
ORDER BY c.name
|
||||
LIMIT %s OFFSET %s
|
||||
""", (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.email,
|
||||
COUNT(l.id) as license_count
|
||||
FROM customers c
|
||||
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||
GROUP BY c.id, c.name, c.email
|
||||
ORDER BY c.name
|
||||
LIMIT %s OFFSET %s
|
||||
""", (per_page, (page - 1) * per_page))
|
||||
|
||||
customers = cur.fetchall()
|
||||
|
||||
# Format für Select2
|
||||
results = []
|
||||
for customer in customers:
|
||||
results.append({
|
||||
'id': customer[0],
|
||||
'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)",
|
||||
'name': customer[1],
|
||||
'email': customer[2],
|
||||
'license_count': customer[3]
|
||||
})
|
||||
|
||||
# Gesamtanzahl für Pagination
|
||||
if search:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM customers
|
||||
WHERE LOWER(name) LIKE LOWER(%s)
|
||||
OR LOWER(email) LIKE LOWER(%s)
|
||||
""", (f'%{search}%', f'%{search}%'))
|
||||
else:
|
||||
cur.execute("SELECT COUNT(*) FROM customers")
|
||||
|
||||
total_count = cur.fetchone()[0]
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Select2 Response Format
|
||||
return jsonify({
|
||||
'results': results,
|
||||
'pagination': {
|
||||
'more': (page * per_page) < total_count
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler bei Kundensuche: {str(e)}")
|
||||
return jsonify({
|
||||
'results': [],
|
||||
'pagination': {'more': False},
|
||||
'error': str(e)
|
||||
}), 500
|
||||
@ -0,0 +1,377 @@
|
||||
import time
|
||||
import json
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from auth.password import hash_password, verify_password
|
||||
from auth.two_factor import (
|
||||
generate_totp_secret, generate_qr_code, verify_totp,
|
||||
generate_backup_codes, hash_backup_code, verify_backup_code
|
||||
)
|
||||
from auth.rate_limiting import (
|
||||
check_ip_blocked, record_failed_attempt,
|
||||
reset_login_attempts, get_login_attempts
|
||||
)
|
||||
from utils.network import get_client_ip
|
||||
from utils.audit import log_audit
|
||||
from models import get_user_by_username
|
||||
from db import get_db_connection, get_db_cursor
|
||||
from utils.recaptcha import verify_recaptcha
|
||||
|
||||
# Create Blueprint
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
# Timing-Attack Schutz - Start Zeit merken
|
||||
start_time = time.time()
|
||||
|
||||
# IP-Adresse ermitteln
|
||||
ip_address = get_client_ip()
|
||||
|
||||
# Prüfen ob IP gesperrt ist
|
||||
is_blocked, blocked_until = check_ip_blocked(ip_address)
|
||||
if is_blocked:
|
||||
time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600
|
||||
error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten."
|
||||
return render_template("login.html", error=error_msg, error_type="blocked")
|
||||
|
||||
# Anzahl bisheriger Versuche
|
||||
attempt_count = get_login_attempts(ip_address)
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
password = request.form.get("password")
|
||||
captcha_response = request.form.get("g-recaptcha-response")
|
||||
|
||||
# CAPTCHA-Prüfung nur wenn Keys konfiguriert sind
|
||||
recaptcha_site_key = config.RECAPTCHA_SITE_KEY
|
||||
if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key:
|
||||
if not captcha_response:
|
||||
# Timing-Attack Schutz
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed < 1.0:
|
||||
time.sleep(1.0 - elapsed)
|
||||
return render_template("login.html",
|
||||
error="CAPTCHA ERFORDERLICH!",
|
||||
show_captcha=True,
|
||||
error_type="captcha",
|
||||
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||||
recaptcha_site_key=recaptcha_site_key)
|
||||
|
||||
# CAPTCHA validieren
|
||||
if not verify_recaptcha(captcha_response):
|
||||
# Timing-Attack Schutz
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed < 1.0:
|
||||
time.sleep(1.0 - elapsed)
|
||||
return render_template("login.html",
|
||||
error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.",
|
||||
show_captcha=True,
|
||||
error_type="captcha",
|
||||
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||||
recaptcha_site_key=recaptcha_site_key)
|
||||
|
||||
# Check user in database first, fallback to env vars
|
||||
user = get_user_by_username(username)
|
||||
login_success = False
|
||||
needs_2fa = False
|
||||
|
||||
if user:
|
||||
# Database user authentication
|
||||
if verify_password(password, user['password_hash']):
|
||||
login_success = True
|
||||
needs_2fa = user['totp_enabled']
|
||||
else:
|
||||
# Fallback to environment variables for backward compatibility
|
||||
if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]:
|
||||
login_success = True
|
||||
|
||||
# Timing-Attack Schutz - Mindestens 1 Sekunde warten
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed < 1.0:
|
||||
time.sleep(1.0 - elapsed)
|
||||
|
||||
if login_success:
|
||||
# Erfolgreicher Login
|
||||
if needs_2fa:
|
||||
# Store temporary session for 2FA verification
|
||||
session['temp_username'] = username
|
||||
session['temp_user_id'] = user['id']
|
||||
session['awaiting_2fa'] = True
|
||||
return redirect(url_for('auth.verify_2fa'))
|
||||
else:
|
||||
# Complete login without 2FA
|
||||
session.permanent = True # Aktiviert das Timeout
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
session['user_id'] = user['id'] if user else None
|
||||
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||||
reset_login_attempts(ip_address)
|
||||
log_audit('LOGIN_SUCCESS', 'user',
|
||||
additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}")
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
else:
|
||||
# Fehlgeschlagener Login
|
||||
error_message = record_failed_attempt(ip_address, username)
|
||||
new_attempt_count = get_login_attempts(ip_address)
|
||||
|
||||
# Prüfen ob jetzt gesperrt
|
||||
is_now_blocked, _ = check_ip_blocked(ip_address)
|
||||
if is_now_blocked:
|
||||
log_audit('LOGIN_BLOCKED', 'security',
|
||||
additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt")
|
||||
|
||||
return render_template("login.html",
|
||||
error=error_message,
|
||||
show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY),
|
||||
error_type="failed",
|
||||
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count),
|
||||
recaptcha_site_key=config.RECAPTCHA_SITE_KEY)
|
||||
|
||||
# GET Request
|
||||
return render_template("login.html",
|
||||
show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY),
|
||||
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
|
||||
recaptcha_site_key=config.RECAPTCHA_SITE_KEY)
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
def logout():
|
||||
username = session.get('username', 'unknown')
|
||||
log_audit('LOGOUT', 'user', additional_info=f"Abmeldung")
|
||||
session.pop('logged_in', None)
|
||||
session.pop('username', None)
|
||||
session.pop('user_id', None)
|
||||
session.pop('temp_username', None)
|
||||
session.pop('temp_user_id', None)
|
||||
session.pop('awaiting_2fa', None)
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@auth_bp.route("/verify-2fa", methods=["GET", "POST"])
|
||||
def verify_2fa():
|
||||
if not session.get('awaiting_2fa'):
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if request.method == "POST":
|
||||
token = request.form.get('token', '').replace(' ', '')
|
||||
username = session.get('temp_username')
|
||||
user_id = session.get('temp_user_id')
|
||||
|
||||
if not username or not user_id:
|
||||
flash('Session expired. Please login again.', 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# Check if it's a backup code
|
||||
if len(token) == 8 and token.isupper():
|
||||
# Try backup code
|
||||
backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else []
|
||||
if verify_backup_code(token, backup_codes):
|
||||
# Remove used backup code
|
||||
code_hash = hash_backup_code(token)
|
||||
backup_codes.remove(code_hash)
|
||||
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s",
|
||||
(json.dumps(backup_codes), user_id))
|
||||
|
||||
# Complete login
|
||||
session.permanent = True
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
session['user_id'] = user_id
|
||||
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||||
session.pop('temp_username', None)
|
||||
session.pop('temp_user_id', None)
|
||||
session.pop('awaiting_2fa', None)
|
||||
|
||||
flash('Login successful using backup code. Please generate new backup codes.', 'warning')
|
||||
log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code")
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
else:
|
||||
# Try TOTP token
|
||||
if verify_totp(user['totp_secret'], token):
|
||||
# Complete login
|
||||
session.permanent = True
|
||||
session['logged_in'] = True
|
||||
session['username'] = username
|
||||
session['user_id'] = user_id
|
||||
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||||
session.pop('temp_username', None)
|
||||
session.pop('temp_user_id', None)
|
||||
session.pop('awaiting_2fa', None)
|
||||
|
||||
log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful")
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
|
||||
# Failed verification
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s",
|
||||
(datetime.now(), user_id))
|
||||
|
||||
flash('Invalid authentication code. Please try again.', 'error')
|
||||
log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt")
|
||||
|
||||
return render_template('verify_2fa.html')
|
||||
|
||||
|
||||
@auth_bp.route("/profile")
|
||||
@login_required
|
||||
def profile():
|
||||
user = get_user_by_username(session['username'])
|
||||
if not user:
|
||||
# For environment-based users, redirect with message
|
||||
flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
return render_template('profile.html', user=user)
|
||||
|
||||
|
||||
@auth_bp.route("/profile/change-password", methods=["POST"])
|
||||
@login_required
|
||||
def change_password():
|
||||
current_password = request.form.get('current_password')
|
||||
new_password = request.form.get('new_password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
|
||||
user = get_user_by_username(session['username'])
|
||||
|
||||
# Verify current password
|
||||
if not verify_password(current_password, user['password_hash']):
|
||||
flash('Current password is incorrect.', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Check new password
|
||||
if new_password != confirm_password:
|
||||
flash('New passwords do not match.', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
if len(new_password) < 8:
|
||||
flash('Password must be at least 8 characters long.', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Update password
|
||||
new_hash = hash_password(new_password)
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s",
|
||||
(new_hash, datetime.now(), user['id']))
|
||||
|
||||
log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'],
|
||||
additional_info="Password changed successfully")
|
||||
flash('Password changed successfully.', 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
|
||||
@auth_bp.route("/profile/setup-2fa")
|
||||
@login_required
|
||||
def setup_2fa():
|
||||
user = get_user_by_username(session['username'])
|
||||
|
||||
if user['totp_enabled']:
|
||||
flash('2FA is already enabled for your account.', 'info')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Generate new TOTP secret
|
||||
totp_secret = generate_totp_secret()
|
||||
session['temp_totp_secret'] = totp_secret
|
||||
|
||||
# Generate QR code
|
||||
qr_code = generate_qr_code(user['username'], totp_secret)
|
||||
|
||||
return render_template('setup_2fa.html',
|
||||
totp_secret=totp_secret,
|
||||
qr_code=qr_code)
|
||||
|
||||
|
||||
@auth_bp.route("/profile/enable-2fa", methods=["POST"])
|
||||
@login_required
|
||||
def enable_2fa():
|
||||
token = request.form.get('token', '').replace(' ', '')
|
||||
totp_secret = session.get('temp_totp_secret')
|
||||
|
||||
if not totp_secret:
|
||||
flash('2FA setup session expired. Please try again.', 'error')
|
||||
return redirect(url_for('auth.setup_2fa'))
|
||||
|
||||
# Verify the token
|
||||
if not verify_totp(totp_secret, token):
|
||||
flash('Invalid authentication code. Please try again.', 'error')
|
||||
return redirect(url_for('auth.setup_2fa'))
|
||||
|
||||
# Generate backup codes
|
||||
backup_codes = generate_backup_codes()
|
||||
backup_codes_hashed = [hash_backup_code(code) for code in backup_codes]
|
||||
|
||||
# Enable 2FA for user
|
||||
user = get_user_by_username(session['username'])
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("""
|
||||
UPDATE users
|
||||
SET totp_secret = %s, totp_enabled = true, backup_codes = %s
|
||||
WHERE id = %s
|
||||
""", (totp_secret, json.dumps(backup_codes_hashed), user['id']))
|
||||
|
||||
# Clear temp secret
|
||||
session.pop('temp_totp_secret', None)
|
||||
|
||||
log_audit('2FA_ENABLED', 'user', entity_id=user['id'],
|
||||
additional_info="2FA successfully enabled")
|
||||
|
||||
# Show backup codes
|
||||
return render_template('backup_codes.html', backup_codes=backup_codes)
|
||||
|
||||
|
||||
@auth_bp.route("/profile/disable-2fa", methods=["POST"])
|
||||
@login_required
|
||||
def disable_2fa():
|
||||
password = request.form.get('password')
|
||||
|
||||
user = get_user_by_username(session['username'])
|
||||
|
||||
# Verify password
|
||||
if not verify_password(password, user['password_hash']):
|
||||
flash('Incorrect password. 2FA was not disabled.', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
# Disable 2FA
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("""
|
||||
UPDATE users
|
||||
SET totp_enabled = false, totp_secret = NULL, backup_codes = NULL
|
||||
WHERE id = %s
|
||||
""", (user['id'],))
|
||||
|
||||
log_audit('2FA_DISABLED', 'user', entity_id=user['id'],
|
||||
additional_info="2FA disabled by user")
|
||||
flash('2FA has been disabled for your account.', 'success')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
|
||||
@auth_bp.route("/heartbeat", methods=['POST'])
|
||||
@login_required
|
||||
def heartbeat():
|
||||
"""Endpoint für Session Keep-Alive - aktualisiert last_activity"""
|
||||
# Aktualisiere last_activity nur wenn explizit angefordert
|
||||
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
|
||||
# Force session save
|
||||
session.modified = True
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'last_activity': session['last_activity'],
|
||||
'username': session.get('username')
|
||||
})
|
||||
@ -0,0 +1,377 @@
|
||||
import os
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from utils.network import get_client_ip
|
||||
from utils.export import create_batch_export
|
||||
from db import get_connection, get_db_connection, get_db_cursor
|
||||
from models import get_customers
|
||||
|
||||
# Create Blueprint
|
||||
batch_bp = Blueprint('batch', __name__)
|
||||
|
||||
|
||||
def generate_license_key():
|
||||
"""Generiert einen zufälligen Lizenzschlüssel"""
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
return '-'.join([''.join(secrets.choice(chars) for _ in range(4)) for _ in range(4)])
|
||||
|
||||
|
||||
@batch_bp.route("/batch", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def batch_create():
|
||||
"""Batch-Erstellung von Lizenzen"""
|
||||
customers = get_customers()
|
||||
|
||||
if request.method == "POST":
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Form data
|
||||
customer_id = int(request.form['customer_id'])
|
||||
license_type = request.form['license_type']
|
||||
count = int(request.form['count'])
|
||||
valid_from = request.form['valid_from']
|
||||
valid_until = request.form['valid_until']
|
||||
device_limit = int(request.form['device_limit'])
|
||||
is_test = 'is_test' in request.form
|
||||
|
||||
# Validierung
|
||||
if count < 1 or count > 100:
|
||||
flash('Anzahl muss zwischen 1 und 100 liegen!', 'error')
|
||||
return redirect(url_for('batch.batch_create'))
|
||||
|
||||
# Hole Kundendaten
|
||||
cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,))
|
||||
customer = cur.fetchone()
|
||||
if not customer:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('batch.batch_create'))
|
||||
|
||||
created_licenses = []
|
||||
|
||||
# Erstelle Lizenzen
|
||||
for i in range(count):
|
||||
license_key = generate_license_key()
|
||||
|
||||
# Prüfe ob Schlüssel bereits existiert
|
||||
while True:
|
||||
cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,))
|
||||
if not cur.fetchone():
|
||||
break
|
||||
license_key = generate_license_key()
|
||||
|
||||
# Erstelle Lizenz
|
||||
cur.execute("""
|
||||
INSERT INTO licenses (
|
||||
license_key, customer_id, customer_name, customer_email,
|
||||
license_type, valid_from, valid_until, device_limit,
|
||||
is_test, created_at, created_by
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
license_key, customer_id, customer[0], customer[1],
|
||||
license_type, valid_from, valid_until, device_limit,
|
||||
is_test, datetime.now(), session['username']
|
||||
))
|
||||
|
||||
license_id = cur.fetchone()[0]
|
||||
created_licenses.append({
|
||||
'id': license_id,
|
||||
'license_key': license_key
|
||||
})
|
||||
|
||||
# Audit-Log
|
||||
log_audit('CREATE', 'license', license_id,
|
||||
new_values={
|
||||
'license_key': license_key,
|
||||
'customer_name': customer[0],
|
||||
'batch_creation': True
|
||||
})
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Speichere erstellte Lizenzen in Session für Export
|
||||
session['batch_created_licenses'] = created_licenses
|
||||
|
||||
flash(f'{count} Lizenzen erfolgreich erstellt!', 'success')
|
||||
|
||||
# Weiterleitung zum Export
|
||||
return redirect(url_for('batch.batch_export'))
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler bei Batch-Erstellung: {str(e)}")
|
||||
flash('Fehler bei der Batch-Erstellung!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return render_template("batch_create.html", customers=customers)
|
||||
|
||||
|
||||
@batch_bp.route("/batch/export")
|
||||
@login_required
|
||||
def batch_export():
|
||||
"""Exportiert die zuletzt erstellten Batch-Lizenzen"""
|
||||
created_licenses = session.get('batch_created_licenses', [])
|
||||
|
||||
if not created_licenses:
|
||||
flash('Keine Lizenzen zum Exportieren gefunden!', 'error')
|
||||
return redirect(url_for('batch.batch_create'))
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole vollständige Lizenzdaten
|
||||
license_ids = [l['id'] for l in created_licenses]
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
l.license_key, l.customer_name, l.customer_email,
|
||||
l.license_type, l.valid_from, l.valid_until,
|
||||
l.device_limit, l.is_test, l.created_at
|
||||
FROM licenses l
|
||||
WHERE l.id = ANY(%s)
|
||||
ORDER BY l.id
|
||||
""", (license_ids,))
|
||||
|
||||
licenses = []
|
||||
for row in cur.fetchall():
|
||||
licenses.append({
|
||||
'license_key': row[0],
|
||||
'customer_name': row[1],
|
||||
'customer_email': row[2],
|
||||
'license_type': row[3],
|
||||
'valid_from': row[4],
|
||||
'valid_until': row[5],
|
||||
'device_limit': row[6],
|
||||
'is_test': row[7],
|
||||
'created_at': row[8]
|
||||
})
|
||||
|
||||
# Erstelle Excel-Export
|
||||
excel_file = create_batch_export(licenses)
|
||||
|
||||
# Lösche aus Session
|
||||
session.pop('batch_created_licenses', None)
|
||||
|
||||
# Sende Datei
|
||||
filename = f"batch_licenses_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return send_file(
|
||||
excel_file,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
flash('Fehler beim Exportieren der Lizenzen!', 'error')
|
||||
return redirect(url_for('batch.batch_create'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@batch_bp.route("/batch/update", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def batch_update():
|
||||
"""Batch-Update von Lizenzen"""
|
||||
if request.method == "POST":
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Form data
|
||||
license_keys = request.form.get('license_keys', '').strip().split('\n')
|
||||
license_keys = [key.strip() for key in license_keys if key.strip()]
|
||||
|
||||
if not license_keys:
|
||||
flash('Keine Lizenzschlüssel angegeben!', 'error')
|
||||
return redirect(url_for('batch.batch_update'))
|
||||
|
||||
# Update-Parameter
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if 'update_valid_until' in request.form and request.form['valid_until']:
|
||||
updates.append("valid_until = %s")
|
||||
params.append(request.form['valid_until'])
|
||||
|
||||
if 'update_device_limit' in request.form and request.form['device_limit']:
|
||||
updates.append("device_limit = %s")
|
||||
params.append(int(request.form['device_limit']))
|
||||
|
||||
if 'update_active' in request.form:
|
||||
updates.append("active = %s")
|
||||
params.append('active' in request.form)
|
||||
|
||||
if not updates:
|
||||
flash('Keine Änderungen angegeben!', 'error')
|
||||
return redirect(url_for('batch.batch_update'))
|
||||
|
||||
# Führe Updates aus
|
||||
updated_count = 0
|
||||
not_found = []
|
||||
|
||||
for license_key in license_keys:
|
||||
# Prüfe ob Lizenz existiert
|
||||
cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,))
|
||||
result = cur.fetchone()
|
||||
|
||||
if not result:
|
||||
not_found.append(license_key)
|
||||
continue
|
||||
|
||||
license_id = result[0]
|
||||
|
||||
# Update ausführen
|
||||
update_params = params + [license_id]
|
||||
cur.execute(f"""
|
||||
UPDATE licenses
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = %s
|
||||
""", update_params)
|
||||
|
||||
# Audit-Log
|
||||
log_audit('BATCH_UPDATE', 'license', license_id,
|
||||
additional_info=f"Batch-Update: {', '.join(updates)}")
|
||||
|
||||
updated_count += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Feedback
|
||||
flash(f'{updated_count} Lizenzen erfolgreich aktualisiert!', 'success')
|
||||
|
||||
if not_found:
|
||||
flash(f'{len(not_found)} Lizenzen nicht gefunden: {", ".join(not_found[:5])}{"..." if len(not_found) > 5 else ""}', 'warning')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler bei Batch-Update: {str(e)}")
|
||||
flash('Fehler beim Batch-Update!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return render_template("batch_update.html")
|
||||
|
||||
|
||||
@batch_bp.route("/batch/import", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def batch_import():
|
||||
"""Import von Lizenzen aus CSV/Excel"""
|
||||
if request.method == "POST":
|
||||
if 'file' not in request.files:
|
||||
flash('Keine Datei ausgewählt!', 'error')
|
||||
return redirect(url_for('batch.batch_import'))
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
flash('Keine Datei ausgewählt!', 'error')
|
||||
return redirect(url_for('batch.batch_import'))
|
||||
|
||||
# Verarbeite Datei
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
# Lese Datei
|
||||
if file.filename.endswith('.csv'):
|
||||
df = pd.read_csv(file)
|
||||
elif file.filename.endswith(('.xlsx', '.xls')):
|
||||
df = pd.read_excel(file)
|
||||
else:
|
||||
flash('Ungültiges Dateiformat! Nur CSV und Excel erlaubt.', 'error')
|
||||
return redirect(url_for('batch.batch_import'))
|
||||
|
||||
# Validiere Spalten
|
||||
required_columns = ['customer_email', 'license_type', 'valid_from', 'valid_until', 'device_limit']
|
||||
missing_columns = [col for col in required_columns if col not in df.columns]
|
||||
|
||||
if missing_columns:
|
||||
flash(f'Fehlende Spalten: {", ".join(missing_columns)}', 'error')
|
||||
return redirect(url_for('batch.batch_import'))
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
imported_count = 0
|
||||
errors = []
|
||||
|
||||
for index, row in df.iterrows():
|
||||
try:
|
||||
# Finde oder erstelle Kunde
|
||||
email = row['customer_email']
|
||||
cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,))
|
||||
customer = cur.fetchone()
|
||||
|
||||
if not customer:
|
||||
# Erstelle neuen Kunden
|
||||
name = row.get('customer_name', email.split('@')[0])
|
||||
cur.execute("""
|
||||
INSERT INTO customers (name, email, created_at)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id
|
||||
""", (name, email, datetime.now()))
|
||||
customer_id = cur.fetchone()[0]
|
||||
customer_name = name
|
||||
else:
|
||||
customer_id = customer[0]
|
||||
customer_name = customer[1]
|
||||
|
||||
# Generiere Lizenzschlüssel
|
||||
license_key = row.get('license_key', generate_license_key())
|
||||
|
||||
# Erstelle Lizenz
|
||||
cur.execute("""
|
||||
INSERT INTO licenses (
|
||||
license_key, customer_id, customer_name, customer_email,
|
||||
license_type, valid_from, valid_until, device_limit,
|
||||
is_test, created_at, created_by
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
license_key, customer_id, customer_name, email,
|
||||
row['license_type'], row['valid_from'], row['valid_until'],
|
||||
int(row['device_limit']), row.get('is_test', False),
|
||||
datetime.now(), session['username']
|
||||
))
|
||||
|
||||
license_id = cur.fetchone()[0]
|
||||
imported_count += 1
|
||||
|
||||
# Audit-Log
|
||||
log_audit('IMPORT', 'license', license_id,
|
||||
additional_info=f"Importiert aus {file.filename}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Zeile {index + 2}: {str(e)}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Feedback
|
||||
flash(f'{imported_count} Lizenzen erfolgreich importiert!', 'success')
|
||||
|
||||
if errors:
|
||||
flash(f'{len(errors)} Fehler aufgetreten. Erste Fehler: {"; ".join(errors[:3])}', 'warning')
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Import: {str(e)}")
|
||||
flash(f'Fehler beim Import: {str(e)}', 'error')
|
||||
finally:
|
||||
if 'conn' in locals():
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return render_template("batch_import.html")
|
||||
@ -0,0 +1,338 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from db import get_connection, get_db_connection, get_db_cursor
|
||||
from models import get_customers, get_customer_by_id
|
||||
|
||||
# Create Blueprint
|
||||
customer_bp = Blueprint('customers', __name__)
|
||||
|
||||
|
||||
@customer_bp.route("/customers")
|
||||
@login_required
|
||||
def customers():
|
||||
customers_list = get_customers()
|
||||
return render_template("customers.html", customers=customers_list)
|
||||
|
||||
|
||||
@customer_bp.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_customer(customer_id):
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
# Get current customer data for comparison
|
||||
current_customer = get_customer_by_id(customer_id)
|
||||
if not current_customer:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('customers.customers'))
|
||||
|
||||
# Update customer data
|
||||
new_values = {
|
||||
'name': request.form['name'],
|
||||
'email': request.form['email'],
|
||||
'phone': request.form.get('phone', ''),
|
||||
'address': request.form.get('address', ''),
|
||||
'notes': request.form.get('notes', '')
|
||||
}
|
||||
|
||||
cur.execute("""
|
||||
UPDATE customers
|
||||
SET name = %s, email = %s, phone = %s, address = %s, notes = %s
|
||||
WHERE id = %s
|
||||
""", (
|
||||
new_values['name'],
|
||||
new_values['email'],
|
||||
new_values['phone'],
|
||||
new_values['address'],
|
||||
new_values['notes'],
|
||||
customer_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Log changes
|
||||
log_audit('UPDATE', 'customer', customer_id,
|
||||
old_values={
|
||||
'name': current_customer['name'],
|
||||
'email': current_customer['email'],
|
||||
'phone': current_customer.get('phone', ''),
|
||||
'address': current_customer.get('address', ''),
|
||||
'notes': current_customer.get('notes', '')
|
||||
},
|
||||
new_values=new_values)
|
||||
|
||||
flash('Kunde erfolgreich aktualisiert!', 'success')
|
||||
return redirect(url_for('customers.customers'))
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Aktualisieren des Kunden: {str(e)}")
|
||||
flash('Fehler beim Aktualisieren des Kunden!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# GET request
|
||||
customer_data = get_customer_by_id(customer_id)
|
||||
if not customer_data:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('customers.customers'))
|
||||
|
||||
return render_template("edit_customer.html", customer=customer_data)
|
||||
|
||||
|
||||
@customer_bp.route("/customer/create", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_customer():
|
||||
if request.method == "POST":
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Insert new customer
|
||||
name = request.form['name']
|
||||
email = request.form['email']
|
||||
phone = request.form.get('phone', '')
|
||||
address = request.form.get('address', '')
|
||||
notes = request.form.get('notes', '')
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO customers (name, email, phone, address, notes, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (name, email, phone, address, notes, datetime.now()))
|
||||
|
||||
customer_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
|
||||
# Log creation
|
||||
log_audit('CREATE', 'customer', customer_id,
|
||||
new_values={
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'address': address,
|
||||
'notes': notes
|
||||
})
|
||||
|
||||
flash(f'Kunde {name} erfolgreich erstellt!', 'success')
|
||||
return redirect(url_for('customers.customers'))
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Erstellen des Kunden: {str(e)}")
|
||||
flash('Fehler beim Erstellen des Kunden!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return render_template("create_customer.html")
|
||||
|
||||
|
||||
@customer_bp.route("/customer/delete/<int:customer_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_customer(customer_id):
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Get customer data before deletion
|
||||
customer_data = get_customer_by_id(customer_id)
|
||||
if not customer_data:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('customers.customers'))
|
||||
|
||||
# Check if customer has licenses
|
||||
cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,))
|
||||
license_count = cur.fetchone()[0]
|
||||
|
||||
if license_count > 0:
|
||||
flash(f'Kunde kann nicht gelöscht werden - hat noch {license_count} Lizenz(en)!', 'error')
|
||||
return redirect(url_for('customers.customers'))
|
||||
|
||||
# Delete the customer
|
||||
cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Log deletion
|
||||
log_audit('DELETE', 'customer', customer_id,
|
||||
old_values={
|
||||
'name': customer_data['name'],
|
||||
'email': customer_data['email']
|
||||
})
|
||||
|
||||
flash(f'Kunde {customer_data["name"]} erfolgreich gelöscht!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Löschen des Kunden: {str(e)}")
|
||||
flash('Fehler beim Löschen des Kunden!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('customers.customers'))
|
||||
|
||||
|
||||
@customer_bp.route("/customers-licenses")
|
||||
@login_required
|
||||
def customers_licenses():
|
||||
"""Zeigt die Übersicht von Kunden und deren Lizenzen"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole alle Kunden mit ihren Lizenzen
|
||||
cur.execute("""
|
||||
SELECT
|
||||
c.id as customer_id,
|
||||
c.name as customer_name,
|
||||
c.email as customer_email,
|
||||
c.created_at as customer_created,
|
||||
COUNT(l.id) as license_count,
|
||||
COUNT(CASE WHEN l.active = true THEN 1 END) as active_licenses,
|
||||
COUNT(CASE WHEN l.is_test = true THEN 1 END) as test_licenses,
|
||||
MAX(l.created_at) as last_license_created
|
||||
FROM customers c
|
||||
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||
GROUP BY c.id, c.name, c.email, c.created_at
|
||||
ORDER BY c.name
|
||||
""")
|
||||
|
||||
customers = []
|
||||
for row in cur.fetchall():
|
||||
customers.append({
|
||||
'id': row[0],
|
||||
'name': row[1],
|
||||
'email': row[2],
|
||||
'created_at': row[3],
|
||||
'license_count': row[4],
|
||||
'active_licenses': row[5],
|
||||
'test_licenses': row[6],
|
||||
'last_license_created': row[7]
|
||||
})
|
||||
|
||||
return render_template("customers_licenses.html", customers=customers)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Kunden-Lizenz-Übersicht: {str(e)}")
|
||||
flash('Fehler beim Laden der Daten!', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@customer_bp.route("/api/customer/<int:customer_id>/licenses")
|
||||
@login_required
|
||||
def api_customer_licenses(customer_id):
|
||||
"""API-Endpunkt für die Lizenzen eines Kunden"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole Kundeninformationen
|
||||
customer = get_customer_by_id(customer_id)
|
||||
if not customer:
|
||||
return jsonify({'error': 'Kunde nicht gefunden'}), 404
|
||||
|
||||
# Hole alle Lizenzen des Kunden
|
||||
cur.execute("""
|
||||
SELECT
|
||||
l.id,
|
||||
l.license_key,
|
||||
l.license_type,
|
||||
l.active,
|
||||
l.is_test,
|
||||
l.valid_from,
|
||||
l.valid_until,
|
||||
l.device_limit,
|
||||
l.created_at,
|
||||
(SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.active = true) as active_sessions,
|
||||
(SELECT COUNT(DISTINCT device_id) FROM sessions s WHERE s.license_key = l.license_key) as registered_devices,
|
||||
CASE
|
||||
WHEN l.valid_until < CURRENT_DATE THEN 'expired'
|
||||
WHEN l.active = false THEN 'inactive'
|
||||
ELSE 'active'
|
||||
END as status
|
||||
FROM licenses l
|
||||
WHERE l.customer_id = %s
|
||||
ORDER BY l.created_at DESC
|
||||
""", (customer_id,))
|
||||
|
||||
licenses = []
|
||||
for row in cur.fetchall():
|
||||
licenses.append({
|
||||
'id': row[0],
|
||||
'license_key': row[1],
|
||||
'license_type': row[2],
|
||||
'active': row[3],
|
||||
'is_test': row[4],
|
||||
'valid_from': row[5].strftime('%Y-%m-%d') if row[5] else None,
|
||||
'valid_until': row[6].strftime('%Y-%m-%d') if row[6] else None,
|
||||
'device_limit': row[7],
|
||||
'created_at': row[8].strftime('%Y-%m-%d %H:%M:%S') if row[8] else None,
|
||||
'active_sessions': row[9],
|
||||
'registered_devices': row[10],
|
||||
'status': row[11]
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'customer': {
|
||||
'id': customer['id'],
|
||||
'name': customer['name'],
|
||||
'email': customer['email']
|
||||
},
|
||||
'licenses': licenses
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Kundenlizenzen: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Laden der Daten'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@customer_bp.route("/api/customer/<int:customer_id>/quick-stats")
|
||||
@login_required
|
||||
def api_customer_quick_stats(customer_id):
|
||||
"""Schnelle Statistiken für einen Kunden"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COUNT(l.id) as total_licenses,
|
||||
COUNT(CASE WHEN l.active = true THEN 1 END) as active_licenses,
|
||||
COUNT(CASE WHEN l.is_test = true THEN 1 END) as test_licenses,
|
||||
SUM(l.device_limit) as total_device_limit
|
||||
FROM licenses l
|
||||
WHERE l.customer_id = %s
|
||||
""", (customer_id,))
|
||||
|
||||
row = cur.fetchone()
|
||||
|
||||
return jsonify({
|
||||
'total_licenses': row[0] or 0,
|
||||
'active_licenses': row[1] or 0,
|
||||
'test_licenses': row[2] or 0,
|
||||
'total_device_limit': row[3] or 0
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Kundenstatistiken: {str(e)}")
|
||||
return jsonify({'error': 'Fehler beim Laden der Daten'}), 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
@ -0,0 +1,364 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, request, send_file
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.export import create_excel_export, prepare_audit_export_data
|
||||
from db import get_connection
|
||||
|
||||
# Create Blueprint
|
||||
export_bp = Blueprint('export', __name__, url_prefix='/export')
|
||||
|
||||
|
||||
@export_bp.route("/licenses")
|
||||
@login_required
|
||||
def export_licenses():
|
||||
"""Exportiert Lizenzen als Excel-Datei"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Filter aus Request
|
||||
show_test = request.args.get('show_test', 'false') == 'true'
|
||||
|
||||
# SQL Query mit optionalem Test-Filter
|
||||
if show_test:
|
||||
query = """
|
||||
SELECT
|
||||
l.id,
|
||||
l.license_key,
|
||||
c.name as customer_name,
|
||||
c.email as customer_email,
|
||||
l.license_type,
|
||||
l.valid_from,
|
||||
l.valid_until,
|
||||
l.active,
|
||||
l.device_limit,
|
||||
l.created_at,
|
||||
l.is_test,
|
||||
CASE
|
||||
WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen'
|
||||
WHEN l.active = false THEN 'Deaktiviert'
|
||||
ELSE 'Aktiv'
|
||||
END as status,
|
||||
(SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.active = true) as active_sessions,
|
||||
(SELECT COUNT(DISTINCT device_id) FROM sessions s WHERE s.license_key = l.license_key) as registered_devices
|
||||
FROM licenses l
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
ORDER BY l.created_at DESC
|
||||
"""
|
||||
else:
|
||||
query = """
|
||||
SELECT
|
||||
l.id,
|
||||
l.license_key,
|
||||
c.name as customer_name,
|
||||
c.email as customer_email,
|
||||
l.license_type,
|
||||
l.valid_from,
|
||||
l.valid_until,
|
||||
l.active,
|
||||
l.device_limit,
|
||||
l.created_at,
|
||||
l.is_test,
|
||||
CASE
|
||||
WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen'
|
||||
WHEN l.active = false THEN 'Deaktiviert'
|
||||
ELSE 'Aktiv'
|
||||
END as status,
|
||||
(SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.active = true) as active_sessions,
|
||||
(SELECT COUNT(DISTINCT device_id) FROM sessions s WHERE s.license_key = l.license_key) as registered_devices
|
||||
FROM licenses l
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE l.is_test = false
|
||||
ORDER BY l.created_at DESC
|
||||
"""
|
||||
|
||||
cur.execute(query)
|
||||
|
||||
# Daten für Export vorbereiten
|
||||
data = []
|
||||
columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', 'Gültig von',
|
||||
'Gültig bis', 'Aktiv', 'Gerätelimit', 'Erstellt am', 'Test-Lizenz',
|
||||
'Status', 'Aktive Sessions', 'Registrierte Geräte']
|
||||
|
||||
for row in cur.fetchall():
|
||||
data.append(list(row))
|
||||
|
||||
# Excel-Datei erstellen
|
||||
excel_file = create_excel_export(data, columns, 'Lizenzen')
|
||||
|
||||
# Datei senden
|
||||
filename = f"lizenzen_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return send_file(
|
||||
excel_file,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
return "Fehler beim Exportieren der Lizenzen", 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@export_bp.route("/audit")
|
||||
@login_required
|
||||
def export_audit():
|
||||
"""Exportiert Audit-Logs als Excel-Datei"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Filter aus Request
|
||||
days = int(request.args.get('days', 30))
|
||||
action_filter = request.args.get('action', '')
|
||||
entity_type_filter = request.args.get('entity_type', '')
|
||||
|
||||
# Daten für Export vorbereiten
|
||||
data = prepare_audit_export_data(days, action_filter, entity_type_filter)
|
||||
|
||||
# Excel-Datei erstellen
|
||||
columns = ['Zeitstempel', 'Benutzer', 'Aktion', 'Entität', 'Entität ID',
|
||||
'IP-Adresse', 'Alte Werte', 'Neue Werte', 'Zusatzinfo']
|
||||
|
||||
excel_file = create_excel_export(data, columns, 'Audit-Log')
|
||||
|
||||
# Datei senden
|
||||
filename = f"audit_log_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return send_file(
|
||||
excel_file,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
return "Fehler beim Exportieren der Audit-Logs", 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@export_bp.route("/customers")
|
||||
@login_required
|
||||
def export_customers():
|
||||
"""Exportiert Kunden als Excel-Datei"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# SQL Query
|
||||
cur.execute("""
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.email,
|
||||
c.phone,
|
||||
c.address,
|
||||
c.created_at,
|
||||
c.is_test,
|
||||
COUNT(l.id) as license_count,
|
||||
COUNT(CASE WHEN l.active = true THEN 1 END) as active_licenses,
|
||||
COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses
|
||||
FROM customers c
|
||||
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||
GROUP BY c.id, c.name, c.email, c.phone, c.address, c.created_at, c.is_test
|
||||
ORDER BY c.name
|
||||
""")
|
||||
|
||||
# Daten für Export vorbereiten
|
||||
data = []
|
||||
columns = ['ID', 'Name', 'E-Mail', 'Telefon', 'Adresse', 'Erstellt am',
|
||||
'Test-Kunde', 'Anzahl Lizenzen', 'Aktive Lizenzen', 'Abgelaufene Lizenzen']
|
||||
|
||||
for row in cur.fetchall():
|
||||
data.append(list(row))
|
||||
|
||||
# Excel-Datei erstellen
|
||||
excel_file = create_excel_export(data, columns, 'Kunden')
|
||||
|
||||
# Datei senden
|
||||
filename = f"kunden_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return send_file(
|
||||
excel_file,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
return "Fehler beim Exportieren der Kunden", 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@export_bp.route("/sessions")
|
||||
@login_required
|
||||
def export_sessions():
|
||||
"""Exportiert Sessions als Excel-Datei"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Filter aus Request
|
||||
days = int(request.args.get('days', 7))
|
||||
active_only = request.args.get('active_only', 'false') == 'true'
|
||||
|
||||
# SQL Query
|
||||
if active_only:
|
||||
query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.license_key,
|
||||
l.customer_name,
|
||||
s.username,
|
||||
s.device_id,
|
||||
s.login_time,
|
||||
s.logout_time,
|
||||
s.last_activity,
|
||||
s.active,
|
||||
l.license_type,
|
||||
l.is_test
|
||||
FROM sessions s
|
||||
LEFT JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE s.active = true
|
||||
ORDER BY s.login_time DESC
|
||||
"""
|
||||
cur.execute(query)
|
||||
else:
|
||||
query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.license_key,
|
||||
l.customer_name,
|
||||
s.username,
|
||||
s.device_id,
|
||||
s.login_time,
|
||||
s.logout_time,
|
||||
s.last_activity,
|
||||
s.active,
|
||||
l.license_type,
|
||||
l.is_test
|
||||
FROM sessions s
|
||||
LEFT JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE s.login_time >= CURRENT_TIMESTAMP - INTERVAL '%s days'
|
||||
ORDER BY s.login_time DESC
|
||||
"""
|
||||
cur.execute(query, (days,))
|
||||
|
||||
# Daten für Export vorbereiten
|
||||
data = []
|
||||
columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'Benutzer', 'Geräte-ID',
|
||||
'Login-Zeit', 'Logout-Zeit', 'Letzte Aktivität', 'Aktiv',
|
||||
'Lizenztyp', 'Test-Lizenz']
|
||||
|
||||
for row in cur.fetchall():
|
||||
data.append(list(row))
|
||||
|
||||
# Excel-Datei erstellen
|
||||
excel_file = create_excel_export(data, columns, 'Sessions')
|
||||
|
||||
# Datei senden
|
||||
filename = f"sessions_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return send_file(
|
||||
excel_file,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
return "Fehler beim Exportieren der Sessions", 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@export_bp.route("/resources")
|
||||
@login_required
|
||||
def export_resources():
|
||||
"""Exportiert Ressourcen als Excel-Datei"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Filter aus Request
|
||||
resource_type = request.args.get('type', 'all')
|
||||
status_filter = request.args.get('status', 'all')
|
||||
show_test = request.args.get('show_test', 'false') == 'true'
|
||||
|
||||
# SQL Query aufbauen
|
||||
query = """
|
||||
SELECT
|
||||
rp.id,
|
||||
rp.resource_type,
|
||||
rp.resource_value,
|
||||
rp.status,
|
||||
rp.is_test,
|
||||
l.license_key,
|
||||
c.name as customer_name,
|
||||
rp.created_at,
|
||||
rp.created_by,
|
||||
rp.status_changed_at,
|
||||
rp.status_changed_by,
|
||||
rp.quarantine_reason
|
||||
FROM resource_pools rp
|
||||
LEFT JOIN licenses l ON rp.allocated_to_license = l.id
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = []
|
||||
|
||||
if resource_type != 'all':
|
||||
query += " AND rp.resource_type = %s"
|
||||
params.append(resource_type)
|
||||
|
||||
if status_filter != 'all':
|
||||
query += " AND rp.status = %s"
|
||||
params.append(status_filter)
|
||||
|
||||
if not show_test:
|
||||
query += " AND rp.is_test = false"
|
||||
|
||||
query += " ORDER BY rp.resource_type, rp.resource_value"
|
||||
|
||||
cur.execute(query, params)
|
||||
|
||||
# Daten für Export vorbereiten
|
||||
data = []
|
||||
columns = ['ID', 'Typ', 'Wert', 'Status', 'Test-Ressource', 'Lizenzschlüssel',
|
||||
'Kunde', 'Erstellt am', 'Erstellt von', 'Status geändert am',
|
||||
'Status geändert von', 'Quarantäne-Grund']
|
||||
|
||||
for row in cur.fetchall():
|
||||
data.append(list(row))
|
||||
|
||||
# Excel-Datei erstellen
|
||||
excel_file = create_excel_export(data, columns, 'Ressourcen')
|
||||
|
||||
# Datei senden
|
||||
filename = f"ressourcen_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return send_file(
|
||||
excel_file,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Export: {str(e)}")
|
||||
return "Fehler beim Exportieren der Ressourcen", 500
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
@ -0,0 +1,374 @@
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from utils.network import get_client_ip
|
||||
from utils.license import validate_license_key
|
||||
from db import get_connection, get_db_connection, get_db_cursor
|
||||
from models import get_licenses, get_license_by_id
|
||||
|
||||
# Create Blueprint
|
||||
license_bp = Blueprint('licenses', __name__)
|
||||
|
||||
|
||||
@license_bp.route("/licenses")
|
||||
@login_required
|
||||
def licenses():
|
||||
show_test = request.args.get('show_test', 'false') == 'true'
|
||||
licenses_list = get_licenses(show_test=show_test)
|
||||
return render_template("licenses.html", licenses=licenses_list, show_test=show_test)
|
||||
|
||||
|
||||
@license_bp.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_license(license_id):
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
# Get current license data for comparison
|
||||
current_license = get_license_by_id(license_id)
|
||||
if not current_license:
|
||||
flash('Lizenz nicht gefunden!', 'error')
|
||||
return redirect(url_for('licenses.licenses'))
|
||||
|
||||
# Update license data
|
||||
new_values = {
|
||||
'customer_name': request.form['customer_name'],
|
||||
'customer_email': request.form['customer_email'],
|
||||
'valid_from': request.form['valid_from'],
|
||||
'valid_until': request.form['valid_until'],
|
||||
'device_limit': int(request.form['device_limit']),
|
||||
'active': 'active' in request.form
|
||||
}
|
||||
|
||||
cur.execute("""
|
||||
UPDATE licenses
|
||||
SET customer_name = %s, customer_email = %s, valid_from = %s,
|
||||
valid_until = %s, device_limit = %s, active = %s
|
||||
WHERE id = %s
|
||||
""", (
|
||||
new_values['customer_name'],
|
||||
new_values['customer_email'],
|
||||
new_values['valid_from'],
|
||||
new_values['valid_until'],
|
||||
new_values['device_limit'],
|
||||
new_values['active'],
|
||||
license_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Log changes
|
||||
log_audit('UPDATE', 'license', license_id,
|
||||
old_values={
|
||||
'customer_name': current_license['customer_name'],
|
||||
'customer_email': current_license['customer_email'],
|
||||
'valid_from': str(current_license['valid_from']),
|
||||
'valid_until': str(current_license['valid_until']),
|
||||
'device_limit': current_license['device_limit'],
|
||||
'active': current_license['active']
|
||||
},
|
||||
new_values=new_values)
|
||||
|
||||
flash('Lizenz erfolgreich aktualisiert!', 'success')
|
||||
|
||||
# Preserve show_test parameter if present
|
||||
show_test = request.args.get('show_test', 'false')
|
||||
return redirect(url_for('licenses.licenses', show_test=show_test))
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Aktualisieren der Lizenz: {str(e)}")
|
||||
flash('Fehler beim Aktualisieren der Lizenz!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# GET request
|
||||
license_data = get_license_by_id(license_id)
|
||||
if not license_data:
|
||||
flash('Lizenz nicht gefunden!', 'error')
|
||||
return redirect(url_for('licenses.licenses'))
|
||||
|
||||
return render_template("edit_license.html", license=license_data)
|
||||
|
||||
|
||||
@license_bp.route("/license/delete/<int:license_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_license(license_id):
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Get license data before deletion
|
||||
license_data = get_license_by_id(license_id)
|
||||
if not license_data:
|
||||
flash('Lizenz nicht gefunden!', 'error')
|
||||
return redirect(url_for('licenses.licenses'))
|
||||
|
||||
# Delete from sessions first
|
||||
cur.execute("DELETE FROM sessions WHERE license_key = %s", (license_data['license_key'],))
|
||||
|
||||
# Delete the license
|
||||
cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Log deletion
|
||||
log_audit('DELETE', 'license', license_id,
|
||||
old_values={
|
||||
'license_key': license_data['license_key'],
|
||||
'customer_name': license_data['customer_name'],
|
||||
'customer_email': license_data['customer_email']
|
||||
})
|
||||
|
||||
flash(f'Lizenz {license_data["license_key"]} erfolgreich gelöscht!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Löschen der Lizenz: {str(e)}")
|
||||
flash('Fehler beim Löschen der Lizenz!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Preserve show_test parameter if present
|
||||
show_test = request.args.get('show_test', 'false')
|
||||
return redirect(url_for('licenses.licenses', show_test=show_test))
|
||||
|
||||
|
||||
@license_bp.route("/create", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_license():
|
||||
if request.method == "POST":
|
||||
customer_id = request.form.get("customer_id")
|
||||
license_key = request.form["license_key"].upper() # Immer Großbuchstaben
|
||||
license_type = request.form["license_type"]
|
||||
valid_from = request.form["valid_from"]
|
||||
is_test = request.form.get("is_test") == "on" # Checkbox value
|
||||
|
||||
# Berechne valid_until basierend auf Laufzeit
|
||||
duration = int(request.form.get("duration", 1))
|
||||
duration_type = request.form.get("duration_type", "years")
|
||||
|
||||
start_date = datetime.strptime(valid_from, "%Y-%m-%d")
|
||||
|
||||
if duration_type == "days":
|
||||
end_date = start_date + timedelta(days=duration)
|
||||
elif duration_type == "months":
|
||||
end_date = start_date + relativedelta(months=duration)
|
||||
else: # years
|
||||
end_date = start_date + relativedelta(years=duration)
|
||||
|
||||
# Ein Tag abziehen, da der Starttag mitgezählt wird
|
||||
end_date = end_date - timedelta(days=1)
|
||||
valid_until = end_date.strftime("%Y-%m-%d")
|
||||
|
||||
# Validiere License Key Format
|
||||
if not validate_license_key(license_key):
|
||||
flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error')
|
||||
return redirect(url_for('licenses.create_license'))
|
||||
|
||||
# Resource counts
|
||||
domain_count = int(request.form.get("domain_count", 1))
|
||||
ipv4_count = int(request.form.get("ipv4_count", 1))
|
||||
phone_count = int(request.form.get("phone_count", 1))
|
||||
device_limit = int(request.form.get("device_limit", 3))
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Prüfe ob neuer Kunde oder bestehender
|
||||
if customer_id == "new":
|
||||
# Neuer Kunde
|
||||
name = request.form.get("customer_name")
|
||||
email = request.form.get("email")
|
||||
|
||||
if not name:
|
||||
flash('Kundenname ist erforderlich!', 'error')
|
||||
return redirect(url_for('licenses.create_license'))
|
||||
|
||||
# Prüfe ob E-Mail bereits existiert
|
||||
if email:
|
||||
cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
|
||||
return redirect(url_for('licenses.create_license'))
|
||||
|
||||
# Kunde einfügen (erbt Test-Status von Lizenz)
|
||||
cur.execute("""
|
||||
INSERT INTO customers (name, email, is_test, created_at)
|
||||
VALUES (%s, %s, %s, NOW())
|
||||
RETURNING id
|
||||
""", (name, email, is_test))
|
||||
customer_id = cur.fetchone()[0]
|
||||
customer_info = {'name': name, 'email': email, 'is_test': is_test}
|
||||
|
||||
# Audit-Log für neuen Kunden
|
||||
log_audit('CREATE', 'customer', customer_id,
|
||||
new_values={'name': name, 'email': email, 'is_test': is_test})
|
||||
else:
|
||||
# Bestehender Kunde - hole Infos für Audit-Log
|
||||
cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
|
||||
customer_data = cur.fetchone()
|
||||
if not customer_data:
|
||||
flash('Kunde nicht gefunden!', 'error')
|
||||
return redirect(url_for('licenses.create_license'))
|
||||
customer_info = {'name': customer_data[0], 'email': customer_data[1]}
|
||||
|
||||
# Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren
|
||||
if customer_data[2]: # is_test des Kunden
|
||||
is_test = True
|
||||
|
||||
# Lizenz hinzufügen
|
||||
cur.execute("""
|
||||
INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active,
|
||||
domain_count, ipv4_count, phone_count, device_limit, is_test)
|
||||
VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (license_key, customer_id, license_type, valid_from, valid_until,
|
||||
domain_count, ipv4_count, phone_count, device_limit, is_test))
|
||||
license_id = cur.fetchone()[0]
|
||||
|
||||
# Ressourcen zuweisen
|
||||
try:
|
||||
# Prüfe Verfügbarkeit
|
||||
cur.execute("""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains,
|
||||
(SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s,
|
||||
(SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones
|
||||
""", (is_test, is_test, is_test))
|
||||
available = cur.fetchone()
|
||||
|
||||
if available[0] < domain_count:
|
||||
raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})")
|
||||
if available[1] < ipv4_count:
|
||||
raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})")
|
||||
if available[2] < phone_count:
|
||||
raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})")
|
||||
|
||||
# Domains zuweisen
|
||||
if domain_count > 0:
|
||||
cur.execute("""
|
||||
SELECT id FROM resource_pools
|
||||
WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s
|
||||
LIMIT %s FOR UPDATE
|
||||
""", (is_test, domain_count))
|
||||
for (resource_id,) in cur.fetchall():
|
||||
cur.execute("""
|
||||
UPDATE resource_pools
|
||||
SET status = 'allocated', allocated_to_license = %s,
|
||||
status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||||
WHERE id = %s
|
||||
""", (license_id, session['username'], resource_id))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (license_id, resource_id, session['username']))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||||
VALUES (%s, %s, 'allocated', %s, %s)
|
||||
""", (resource_id, license_id, session['username'], get_client_ip()))
|
||||
|
||||
# IPv4s zuweisen
|
||||
if ipv4_count > 0:
|
||||
cur.execute("""
|
||||
SELECT id FROM resource_pools
|
||||
WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s
|
||||
LIMIT %s FOR UPDATE
|
||||
""", (is_test, ipv4_count))
|
||||
for (resource_id,) in cur.fetchall():
|
||||
cur.execute("""
|
||||
UPDATE resource_pools
|
||||
SET status = 'allocated', allocated_to_license = %s,
|
||||
status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||||
WHERE id = %s
|
||||
""", (license_id, session['username'], resource_id))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (license_id, resource_id, session['username']))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||||
VALUES (%s, %s, 'allocated', %s, %s)
|
||||
""", (resource_id, license_id, session['username'], get_client_ip()))
|
||||
|
||||
# Telefonnummern zuweisen
|
||||
if phone_count > 0:
|
||||
cur.execute("""
|
||||
SELECT id FROM resource_pools
|
||||
WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s
|
||||
LIMIT %s FOR UPDATE
|
||||
""", (is_test, phone_count))
|
||||
for (resource_id,) in cur.fetchall():
|
||||
cur.execute("""
|
||||
UPDATE resource_pools
|
||||
SET status = 'allocated', allocated_to_license = %s,
|
||||
status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
|
||||
WHERE id = %s
|
||||
""", (license_id, session['username'], resource_id))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO license_resources (license_id, resource_id, assigned_by)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (license_id, resource_id, session['username']))
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||||
VALUES (%s, %s, 'allocated', %s, %s)
|
||||
""", (resource_id, license_id, session['username'], get_client_ip()))
|
||||
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
flash(str(e), 'error')
|
||||
return redirect(url_for('licenses.create_license'))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit-Log
|
||||
log_audit('CREATE', 'license', license_id,
|
||||
new_values={
|
||||
'license_key': license_key,
|
||||
'customer_name': customer_info['name'],
|
||||
'customer_email': customer_info['email'],
|
||||
'license_type': license_type,
|
||||
'valid_from': valid_from,
|
||||
'valid_until': valid_until,
|
||||
'device_limit': device_limit,
|
||||
'is_test': is_test
|
||||
})
|
||||
|
||||
flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}")
|
||||
flash('Fehler beim Erstellen der Lizenz!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# Preserve show_test parameter if present
|
||||
redirect_url = url_for('licenses.create_license')
|
||||
if request.args.get('show_test') == 'true':
|
||||
redirect_url += "?show_test=true"
|
||||
return redirect(redirect_url)
|
||||
|
||||
# Unterstützung für vorausgewählten Kunden
|
||||
preselected_customer_id = request.args.get('customer_id', type=int)
|
||||
return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id)
|
||||
@ -0,0 +1,617 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from utils.network import get_client_ip
|
||||
from db import get_connection, get_db_connection, get_db_cursor
|
||||
|
||||
# Create Blueprint
|
||||
resource_bp = Blueprint('resources', __name__)
|
||||
|
||||
|
||||
@resource_bp.route('/resources')
|
||||
@login_required
|
||||
def resources():
|
||||
"""Zeigt die Ressourcenpool-Übersicht"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Filter aus Query-Parametern
|
||||
resource_type = request.args.get('type', 'all')
|
||||
status_filter = request.args.get('status', 'all')
|
||||
search_query = request.args.get('search', '')
|
||||
show_test = request.args.get('show_test', 'false') == 'true'
|
||||
|
||||
# Basis-Query
|
||||
query = """
|
||||
SELECT
|
||||
rp.id,
|
||||
rp.resource_type,
|
||||
rp.resource_value,
|
||||
rp.status,
|
||||
rp.is_test,
|
||||
rp.allocated_to_license,
|
||||
rp.created_at,
|
||||
rp.status_changed_at,
|
||||
rp.status_changed_by,
|
||||
l.customer_name,
|
||||
l.license_type
|
||||
FROM resource_pools rp
|
||||
LEFT JOIN licenses l ON rp.allocated_to_license = l.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = []
|
||||
|
||||
# Filter anwenden
|
||||
if resource_type != 'all':
|
||||
query += " AND rp.resource_type = %s"
|
||||
params.append(resource_type)
|
||||
|
||||
if status_filter != 'all':
|
||||
query += " AND rp.status = %s"
|
||||
params.append(status_filter)
|
||||
|
||||
if search_query:
|
||||
query += " AND (rp.resource_value ILIKE %s OR l.customer_name ILIKE %s)"
|
||||
params.extend([f'%{search_query}%', f'%{search_query}%'])
|
||||
|
||||
if not show_test:
|
||||
query += " AND rp.is_test = false"
|
||||
|
||||
query += " ORDER BY rp.resource_type, rp.resource_value"
|
||||
|
||||
cur.execute(query, params)
|
||||
|
||||
resources_list = []
|
||||
for row in cur.fetchall():
|
||||
resources_list.append({
|
||||
'id': row[0],
|
||||
'resource_type': row[1],
|
||||
'resource_value': row[2],
|
||||
'status': row[3],
|
||||
'is_test': row[4],
|
||||
'allocated_to_license': row[5],
|
||||
'created_at': row[6],
|
||||
'status_changed_at': row[7],
|
||||
'status_changed_by': row[8],
|
||||
'customer_name': row[9],
|
||||
'license_type': row[10]
|
||||
})
|
||||
|
||||
# Statistiken
|
||||
cur.execute("""
|
||||
SELECT
|
||||
resource_type,
|
||||
status,
|
||||
is_test,
|
||||
COUNT(*) as count
|
||||
FROM resource_pools
|
||||
GROUP BY resource_type, status, is_test
|
||||
""")
|
||||
|
||||
stats = {}
|
||||
for row in cur.fetchall():
|
||||
res_type = row[0]
|
||||
status = row[1]
|
||||
is_test = row[2]
|
||||
count = row[3]
|
||||
|
||||
if res_type not in stats:
|
||||
stats[res_type] = {'available': 0, 'allocated': 0, 'quarantined': 0, 'test': 0, 'prod': 0}
|
||||
|
||||
stats[res_type][status] = stats[res_type].get(status, 0) + count
|
||||
if is_test:
|
||||
stats[res_type]['test'] += count
|
||||
else:
|
||||
stats[res_type]['prod'] += count
|
||||
|
||||
return render_template('resources.html',
|
||||
resources=resources_list,
|
||||
stats=stats,
|
||||
resource_type=resource_type,
|
||||
status_filter=status_filter,
|
||||
search_query=search_query,
|
||||
show_test=show_test)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Ressourcen: {str(e)}")
|
||||
flash('Fehler beim Laden der Ressourcen!', 'error')
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@resource_bp.route('/resources/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_resource():
|
||||
"""Neue Ressource hinzufügen"""
|
||||
if request.method == 'POST':
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
resource_type = request.form['resource_type']
|
||||
resource_value = request.form['resource_value'].strip()
|
||||
is_test = 'is_test' in request.form
|
||||
|
||||
# Prüfe ob Ressource bereits existiert
|
||||
cur.execute("""
|
||||
SELECT id FROM resource_pools
|
||||
WHERE resource_type = %s AND resource_value = %s
|
||||
""", (resource_type, resource_value))
|
||||
|
||||
if cur.fetchone():
|
||||
flash(f'Ressource {resource_value} existiert bereits!', 'error')
|
||||
return redirect(url_for('resources.add_resource'))
|
||||
|
||||
# Füge neue Ressource hinzu
|
||||
cur.execute("""
|
||||
INSERT INTO resource_pools (resource_type, resource_value, status, is_test, created_by)
|
||||
VALUES (%s, %s, 'available', %s, %s)
|
||||
RETURNING id
|
||||
""", (resource_type, resource_value, is_test, session['username']))
|
||||
|
||||
resource_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
|
||||
# Audit-Log
|
||||
log_audit('CREATE', 'resource', resource_id,
|
||||
new_values={
|
||||
'resource_type': resource_type,
|
||||
'resource_value': resource_value,
|
||||
'is_test': is_test
|
||||
})
|
||||
|
||||
flash(f'Ressource {resource_value} erfolgreich hinzugefügt!', 'success')
|
||||
return redirect(url_for('resources.resources'))
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Hinzufügen der Ressource: {str(e)}")
|
||||
flash('Fehler beim Hinzufügen der Ressource!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return render_template('add_resource.html')
|
||||
|
||||
|
||||
@resource_bp.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
|
||||
@login_required
|
||||
def quarantine_resource(resource_id):
|
||||
"""Ressource in Quarantäne versetzen"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
reason = request.form.get('reason', '')
|
||||
|
||||
# Hole aktuelle Ressourcen-Info
|
||||
cur.execute("""
|
||||
SELECT resource_value, status, allocated_to_license
|
||||
FROM resource_pools WHERE id = %s
|
||||
""", (resource_id,))
|
||||
resource = cur.fetchone()
|
||||
|
||||
if not resource:
|
||||
flash('Ressource nicht gefunden!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
|
||||
# Setze Status auf quarantined
|
||||
cur.execute("""
|
||||
UPDATE resource_pools
|
||||
SET status = 'quarantined',
|
||||
allocated_to_license = NULL,
|
||||
status_changed_at = CURRENT_TIMESTAMP,
|
||||
status_changed_by = %s,
|
||||
quarantine_reason = %s
|
||||
WHERE id = %s
|
||||
""", (session['username'], reason, resource_id))
|
||||
|
||||
# Wenn die Ressource zugewiesen war, entferne die Zuweisung
|
||||
if resource[2]: # allocated_to_license
|
||||
cur.execute("""
|
||||
DELETE FROM license_resources
|
||||
WHERE license_id = %s AND resource_id = %s
|
||||
""", (resource[2], resource_id))
|
||||
|
||||
# History-Eintrag
|
||||
cur.execute("""
|
||||
INSERT INTO resource_history (resource_id, license_id, action, action_by, notes, ip_address)
|
||||
VALUES (%s, %s, 'quarantined', %s, %s, %s)
|
||||
""", (resource_id, resource[2], session['username'], reason, get_client_ip()))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit-Log
|
||||
log_audit('QUARANTINE', 'resource', resource_id,
|
||||
old_values={'status': resource[1]},
|
||||
new_values={'status': 'quarantined', 'reason': reason})
|
||||
|
||||
flash(f'Ressource {resource[0]} in Quarantäne versetzt!', 'warning')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Quarantänisieren der Ressource: {str(e)}")
|
||||
flash('Fehler beim Quarantänisieren der Ressource!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('resources.resources'))
|
||||
|
||||
|
||||
@resource_bp.route('/resources/release', methods=['POST'])
|
||||
@login_required
|
||||
def release_resources():
|
||||
"""Ressourcen aus Quarantäne freigeben oder von Lizenz entfernen"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
resource_ids = request.form.getlist('resource_ids[]')
|
||||
action = request.form.get('action', 'release')
|
||||
|
||||
if not resource_ids:
|
||||
flash('Keine Ressourcen ausgewählt!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
|
||||
for resource_id in resource_ids:
|
||||
# Hole aktuelle Ressourcen-Info
|
||||
cur.execute("""
|
||||
SELECT resource_value, status, allocated_to_license
|
||||
FROM resource_pools WHERE id = %s
|
||||
""", (resource_id,))
|
||||
resource = cur.fetchone()
|
||||
|
||||
if resource:
|
||||
# Setze Status auf available
|
||||
cur.execute("""
|
||||
UPDATE resource_pools
|
||||
SET status = 'available',
|
||||
allocated_to_license = NULL,
|
||||
status_changed_at = CURRENT_TIMESTAMP,
|
||||
status_changed_by = %s,
|
||||
quarantine_reason = NULL
|
||||
WHERE id = %s
|
||||
""", (session['username'], resource_id))
|
||||
|
||||
# Entferne Lizenz-Zuweisung wenn vorhanden
|
||||
if resource[2]: # allocated_to_license
|
||||
cur.execute("""
|
||||
DELETE FROM license_resources
|
||||
WHERE license_id = %s AND resource_id = %s
|
||||
""", (resource[2], resource_id))
|
||||
|
||||
# History-Eintrag
|
||||
cur.execute("""
|
||||
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
|
||||
VALUES (%s, %s, 'released', %s, %s)
|
||||
""", (resource_id, resource[2], session['username'], get_client_ip()))
|
||||
|
||||
# Audit-Log
|
||||
log_audit('RELEASE', 'resource', resource_id,
|
||||
old_values={'status': resource[1]},
|
||||
new_values={'status': 'available'})
|
||||
|
||||
conn.commit()
|
||||
flash(f'{len(resource_ids)} Ressource(n) freigegeben!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Freigeben der Ressourcen: {str(e)}")
|
||||
flash('Fehler beim Freigeben der Ressourcen!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('resources.resources'))
|
||||
|
||||
|
||||
@resource_bp.route('/resources/history/<int:resource_id>')
|
||||
@login_required
|
||||
def resource_history(resource_id):
|
||||
"""Zeigt die Historie einer Ressource"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Hole Ressourcen-Info
|
||||
cur.execute("""
|
||||
SELECT resource_type, resource_value, status, is_test
|
||||
FROM resource_pools WHERE id = %s
|
||||
""", (resource_id,))
|
||||
resource = cur.fetchone()
|
||||
|
||||
if not resource:
|
||||
flash('Ressource nicht gefunden!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
|
||||
# Hole Historie
|
||||
cur.execute("""
|
||||
SELECT
|
||||
rh.action,
|
||||
rh.action_timestamp,
|
||||
rh.action_by,
|
||||
rh.notes,
|
||||
rh.ip_address,
|
||||
l.license_key,
|
||||
c.name as customer_name
|
||||
FROM resource_history rh
|
||||
LEFT JOIN licenses l ON rh.license_id = l.id
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE rh.resource_id = %s
|
||||
ORDER BY rh.action_timestamp DESC
|
||||
""", (resource_id,))
|
||||
|
||||
history = []
|
||||
for row in cur.fetchall():
|
||||
history.append({
|
||||
'action': row[0],
|
||||
'timestamp': row[1],
|
||||
'by': row[2],
|
||||
'notes': row[3],
|
||||
'ip_address': row[4],
|
||||
'license_key': row[5],
|
||||
'customer_name': row[6]
|
||||
})
|
||||
|
||||
return render_template('resource_history.html',
|
||||
resource={
|
||||
'id': resource_id,
|
||||
'type': resource[0],
|
||||
'value': resource[1],
|
||||
'status': resource[2],
|
||||
'is_test': resource[3]
|
||||
},
|
||||
history=history)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Ressourcen-Historie: {str(e)}")
|
||||
flash('Fehler beim Laden der Historie!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@resource_bp.route('/resources/metrics')
|
||||
@login_required
|
||||
def resource_metrics():
|
||||
"""Zeigt Metriken und Statistiken zu Ressourcen"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Allgemeine Statistiken
|
||||
cur.execute("""
|
||||
SELECT
|
||||
resource_type,
|
||||
status,
|
||||
is_test,
|
||||
COUNT(*) as count
|
||||
FROM resource_pools
|
||||
GROUP BY resource_type, status, is_test
|
||||
ORDER BY resource_type, status
|
||||
""")
|
||||
|
||||
general_stats = {}
|
||||
for row in cur.fetchall():
|
||||
res_type = row[0]
|
||||
if res_type not in general_stats:
|
||||
general_stats[res_type] = {
|
||||
'total': 0,
|
||||
'available': 0,
|
||||
'allocated': 0,
|
||||
'quarantined': 0,
|
||||
'test': 0,
|
||||
'production': 0
|
||||
}
|
||||
|
||||
general_stats[res_type]['total'] += row[3]
|
||||
general_stats[res_type][row[1]] += row[3]
|
||||
if row[2]:
|
||||
general_stats[res_type]['test'] += row[3]
|
||||
else:
|
||||
general_stats[res_type]['production'] += row[3]
|
||||
|
||||
# Zuweisungs-Statistiken
|
||||
cur.execute("""
|
||||
SELECT
|
||||
rp.resource_type,
|
||||
COUNT(DISTINCT l.customer_id) as unique_customers,
|
||||
COUNT(DISTINCT rp.allocated_to_license) as unique_licenses
|
||||
FROM resource_pools rp
|
||||
JOIN licenses l ON rp.allocated_to_license = l.id
|
||||
WHERE rp.status = 'allocated'
|
||||
GROUP BY rp.resource_type
|
||||
""")
|
||||
|
||||
allocation_stats = {}
|
||||
for row in cur.fetchall():
|
||||
allocation_stats[row[0]] = {
|
||||
'unique_customers': row[1],
|
||||
'unique_licenses': row[2]
|
||||
}
|
||||
|
||||
# Historische Daten (letzte 30 Tage)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
DATE(action_timestamp) as date,
|
||||
action,
|
||||
COUNT(*) as count
|
||||
FROM resource_history
|
||||
WHERE action_timestamp >= CURRENT_DATE - INTERVAL '30 days'
|
||||
GROUP BY DATE(action_timestamp), action
|
||||
ORDER BY date, action
|
||||
""")
|
||||
|
||||
historical_data = {}
|
||||
for row in cur.fetchall():
|
||||
date_str = row[0].strftime('%Y-%m-%d')
|
||||
if date_str not in historical_data:
|
||||
historical_data[date_str] = {}
|
||||
historical_data[date_str][row[1]] = row[2]
|
||||
|
||||
# Top-Kunden nach Ressourcennutzung
|
||||
cur.execute("""
|
||||
SELECT
|
||||
c.name,
|
||||
rp.resource_type,
|
||||
COUNT(*) as count
|
||||
FROM resource_pools rp
|
||||
JOIN licenses l ON rp.allocated_to_license = l.id
|
||||
JOIN customers c ON l.customer_id = c.id
|
||||
WHERE rp.status = 'allocated'
|
||||
GROUP BY c.name, rp.resource_type
|
||||
ORDER BY count DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
|
||||
top_customers = []
|
||||
for row in cur.fetchall():
|
||||
top_customers.append({
|
||||
'customer': row[0],
|
||||
'resource_type': row[1],
|
||||
'count': row[2]
|
||||
})
|
||||
|
||||
return render_template('resource_metrics.html',
|
||||
general_stats=general_stats,
|
||||
allocation_stats=allocation_stats,
|
||||
historical_data=historical_data,
|
||||
top_customers=top_customers)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Ressourcen-Metriken: {str(e)}")
|
||||
flash('Fehler beim Laden der Metriken!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@resource_bp.route('/resources/report', methods=['GET'])
|
||||
@login_required
|
||||
def resource_report():
|
||||
"""Generiert einen Ressourcen-Report"""
|
||||
from io import BytesIO
|
||||
import xlsxwriter
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Erstelle Excel-Datei im Speicher
|
||||
output = BytesIO()
|
||||
workbook = xlsxwriter.Workbook(output)
|
||||
|
||||
# Formatierungen
|
||||
header_format = workbook.add_format({
|
||||
'bold': True,
|
||||
'bg_color': '#4CAF50',
|
||||
'font_color': 'white',
|
||||
'border': 1
|
||||
})
|
||||
|
||||
date_format = workbook.add_format({'num_format': 'dd.mm.yyyy hh:mm'})
|
||||
|
||||
# Sheet 1: Übersicht
|
||||
overview_sheet = workbook.add_worksheet('Übersicht')
|
||||
|
||||
# Header
|
||||
headers = ['Ressourcen-Typ', 'Gesamt', 'Verfügbar', 'Zugewiesen', 'Quarantäne', 'Test', 'Produktion']
|
||||
for col, header in enumerate(headers):
|
||||
overview_sheet.write(0, col, header, header_format)
|
||||
|
||||
# Daten
|
||||
cur.execute("""
|
||||
SELECT
|
||||
resource_type,
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN status = 'available' THEN 1 END) as available,
|
||||
COUNT(CASE WHEN status = 'allocated' THEN 1 END) as allocated,
|
||||
COUNT(CASE WHEN status = 'quarantined' THEN 1 END) as quarantined,
|
||||
COUNT(CASE WHEN is_test = true THEN 1 END) as test,
|
||||
COUNT(CASE WHEN is_test = false THEN 1 END) as production
|
||||
FROM resource_pools
|
||||
GROUP BY resource_type
|
||||
ORDER BY resource_type
|
||||
""")
|
||||
|
||||
row = 1
|
||||
for data in cur.fetchall():
|
||||
for col, value in enumerate(data):
|
||||
overview_sheet.write(row, col, value)
|
||||
row += 1
|
||||
|
||||
# Sheet 2: Detailliste
|
||||
detail_sheet = workbook.add_worksheet('Detailliste')
|
||||
|
||||
# Header
|
||||
headers = ['Typ', 'Wert', 'Status', 'Test', 'Kunde', 'Lizenz', 'Zugewiesen am', 'Zugewiesen von']
|
||||
for col, header in enumerate(headers):
|
||||
detail_sheet.write(0, col, header, header_format)
|
||||
|
||||
# Daten
|
||||
cur.execute("""
|
||||
SELECT
|
||||
rp.resource_type,
|
||||
rp.resource_value,
|
||||
rp.status,
|
||||
rp.is_test,
|
||||
c.name as customer_name,
|
||||
l.license_key,
|
||||
rp.status_changed_at,
|
||||
rp.status_changed_by
|
||||
FROM resource_pools rp
|
||||
LEFT JOIN licenses l ON rp.allocated_to_license = l.id
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
ORDER BY rp.resource_type, rp.resource_value
|
||||
""")
|
||||
|
||||
row = 1
|
||||
for data in cur.fetchall():
|
||||
for col, value in enumerate(data):
|
||||
if col == 6 and value: # Datum
|
||||
detail_sheet.write_datetime(row, col, value, date_format)
|
||||
else:
|
||||
detail_sheet.write(row, col, value if value is not None else '')
|
||||
row += 1
|
||||
|
||||
# Spaltenbreiten anpassen
|
||||
overview_sheet.set_column('A:A', 20)
|
||||
overview_sheet.set_column('B:G', 12)
|
||||
|
||||
detail_sheet.set_column('A:A', 15)
|
||||
detail_sheet.set_column('B:B', 30)
|
||||
detail_sheet.set_column('C:D', 12)
|
||||
detail_sheet.set_column('E:F', 25)
|
||||
detail_sheet.set_column('G:H', 20)
|
||||
|
||||
workbook.close()
|
||||
output.seek(0)
|
||||
|
||||
# Sende Datei
|
||||
filename = f"ressourcen_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Generieren des Reports: {str(e)}")
|
||||
flash('Fehler beim Generieren des Reports!', 'error')
|
||||
return redirect(url_for('resources.resources'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
@ -0,0 +1,388 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Blueprint, render_template, request, redirect, session, url_for, flash
|
||||
|
||||
import config
|
||||
from auth.decorators import login_required
|
||||
from utils.audit import log_audit
|
||||
from utils.network import get_client_ip
|
||||
from db import get_connection, get_db_connection, get_db_cursor
|
||||
from models import get_active_sessions
|
||||
|
||||
# Create Blueprint
|
||||
session_bp = Blueprint('sessions', __name__)
|
||||
|
||||
|
||||
@session_bp.route("/sessions")
|
||||
@login_required
|
||||
def sessions():
|
||||
active_sessions = get_active_sessions()
|
||||
return render_template("sessions.html", sessions=active_sessions)
|
||||
|
||||
|
||||
@session_bp.route("/sessions/history")
|
||||
@login_required
|
||||
def session_history():
|
||||
"""Zeigt die Session-Historie"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Query parameters
|
||||
license_key = request.args.get('license_key', '')
|
||||
username = request.args.get('username', '')
|
||||
days = int(request.args.get('days', 7))
|
||||
|
||||
# Base query
|
||||
query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.license_key,
|
||||
s.username,
|
||||
s.device_id,
|
||||
s.login_time,
|
||||
s.logout_time,
|
||||
s.last_activity,
|
||||
s.active,
|
||||
l.customer_name,
|
||||
l.license_type,
|
||||
l.is_test
|
||||
FROM sessions s
|
||||
LEFT JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = []
|
||||
|
||||
# Apply filters
|
||||
if license_key:
|
||||
query += " AND s.license_key = %s"
|
||||
params.append(license_key)
|
||||
|
||||
if username:
|
||||
query += " AND s.username ILIKE %s"
|
||||
params.append(f'%{username}%')
|
||||
|
||||
# Time filter
|
||||
query += " AND s.login_time >= CURRENT_TIMESTAMP - INTERVAL '%s days'"
|
||||
params.append(days)
|
||||
|
||||
query += " ORDER BY s.login_time DESC LIMIT 1000"
|
||||
|
||||
cur.execute(query, params)
|
||||
|
||||
sessions_list = []
|
||||
for row in cur.fetchall():
|
||||
session_duration = None
|
||||
if row[4] and row[5]: # login_time and logout_time
|
||||
duration = row[5] - row[4]
|
||||
hours = int(duration.total_seconds() // 3600)
|
||||
minutes = int((duration.total_seconds() % 3600) // 60)
|
||||
session_duration = f"{hours}h {minutes}m"
|
||||
elif row[4] and row[7]: # login_time and active
|
||||
duration = datetime.now(ZoneInfo("UTC")) - row[4]
|
||||
hours = int(duration.total_seconds() // 3600)
|
||||
minutes = int((duration.total_seconds() % 3600) // 60)
|
||||
session_duration = f"{hours}h {minutes}m (aktiv)"
|
||||
|
||||
sessions_list.append({
|
||||
'id': row[0],
|
||||
'license_key': row[1],
|
||||
'username': row[2],
|
||||
'device_id': row[3],
|
||||
'login_time': row[4],
|
||||
'logout_time': row[5],
|
||||
'last_activity': row[6],
|
||||
'active': row[7],
|
||||
'customer_name': row[8],
|
||||
'license_type': row[9],
|
||||
'is_test': row[10],
|
||||
'duration': session_duration
|
||||
})
|
||||
|
||||
# Get unique license keys for filter dropdown
|
||||
cur.execute("""
|
||||
SELECT DISTINCT s.license_key, l.customer_name
|
||||
FROM sessions s
|
||||
LEFT JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE s.login_time >= CURRENT_TIMESTAMP - INTERVAL '30 days'
|
||||
ORDER BY l.customer_name, s.license_key
|
||||
""")
|
||||
|
||||
available_licenses = []
|
||||
for row in cur.fetchall():
|
||||
available_licenses.append({
|
||||
'license_key': row[0],
|
||||
'customer_name': row[1] or 'Unbekannt'
|
||||
})
|
||||
|
||||
return render_template("session_history.html",
|
||||
sessions=sessions_list,
|
||||
available_licenses=available_licenses,
|
||||
filters={
|
||||
'license_key': license_key,
|
||||
'username': username,
|
||||
'days': days
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Session-Historie: {str(e)}")
|
||||
flash('Fehler beim Laden der Session-Historie!', 'error')
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@session_bp.route("/session/terminate/<int:session_id>", methods=["POST"])
|
||||
@login_required
|
||||
def terminate_session(session_id):
|
||||
"""Beendet eine aktive Session"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Get session info
|
||||
cur.execute("""
|
||||
SELECT license_key, username, device_id
|
||||
FROM sessions
|
||||
WHERE id = %s AND active = true
|
||||
""", (session_id,))
|
||||
|
||||
session_info = cur.fetchone()
|
||||
if not session_info:
|
||||
flash('Session nicht gefunden oder bereits beendet!', 'error')
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
|
||||
# Terminate session
|
||||
cur.execute("""
|
||||
UPDATE sessions
|
||||
SET active = false, logout_time = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""", (session_id,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit log
|
||||
log_audit('SESSION_TERMINATE', 'session', session_id,
|
||||
additional_info=f"Session beendet für {session_info[1]} auf Lizenz {session_info[0]}")
|
||||
|
||||
flash('Session erfolgreich beendet!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Beenden der Session: {str(e)}")
|
||||
flash('Fehler beim Beenden der Session!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
|
||||
|
||||
@session_bp.route("/sessions/terminate-all/<license_key>", methods=["POST"])
|
||||
@login_required
|
||||
def terminate_all_sessions(license_key):
|
||||
"""Beendet alle aktiven Sessions einer Lizenz"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Count active sessions
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM sessions
|
||||
WHERE license_key = %s AND active = true
|
||||
""", (license_key,))
|
||||
|
||||
active_count = cur.fetchone()[0]
|
||||
|
||||
if active_count == 0:
|
||||
flash('Keine aktiven Sessions gefunden!', 'info')
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
|
||||
# Terminate all sessions
|
||||
cur.execute("""
|
||||
UPDATE sessions
|
||||
SET active = false, logout_time = CURRENT_TIMESTAMP
|
||||
WHERE license_key = %s AND active = true
|
||||
""", (license_key,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit log
|
||||
log_audit('SESSION_TERMINATE_ALL', 'license', None,
|
||||
additional_info=f"{active_count} Sessions beendet für Lizenz {license_key}")
|
||||
|
||||
flash(f'{active_count} Sessions erfolgreich beendet!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Beenden der Sessions: {str(e)}")
|
||||
flash('Fehler beim Beenden der Sessions!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
|
||||
|
||||
@session_bp.route("/sessions/cleanup", methods=["POST"])
|
||||
@login_required
|
||||
def cleanup_sessions():
|
||||
"""Bereinigt alte inaktive Sessions"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
days = int(request.form.get('days', 30))
|
||||
|
||||
# Delete old inactive sessions
|
||||
cur.execute("""
|
||||
DELETE FROM sessions
|
||||
WHERE active = false
|
||||
AND logout_time < CURRENT_TIMESTAMP - INTERVAL '%s days'
|
||||
RETURNING id
|
||||
""", (days,))
|
||||
|
||||
deleted_ids = [row[0] for row in cur.fetchall()]
|
||||
deleted_count = len(deleted_ids)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Audit log
|
||||
if deleted_count > 0:
|
||||
log_audit('SESSION_CLEANUP', 'system', None,
|
||||
additional_info=f"{deleted_count} Sessions älter als {days} Tage gelöscht")
|
||||
|
||||
flash(f'{deleted_count} alte Sessions bereinigt!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Fehler beim Bereinigen der Sessions: {str(e)}")
|
||||
flash('Fehler beim Bereinigen der Sessions!', 'error')
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('sessions.session_history'))
|
||||
|
||||
|
||||
@session_bp.route("/sessions/statistics")
|
||||
@login_required
|
||||
def session_statistics():
|
||||
"""Zeigt Session-Statistiken"""
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
# Aktuelle Statistiken
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COUNT(DISTINCT s.license_key) as active_licenses,
|
||||
COUNT(DISTINCT s.username) as unique_users,
|
||||
COUNT(DISTINCT s.device_id) as unique_devices,
|
||||
COUNT(*) as total_active_sessions
|
||||
FROM sessions s
|
||||
WHERE s.active = true
|
||||
""")
|
||||
|
||||
current_stats = cur.fetchone()
|
||||
|
||||
# Sessions nach Lizenztyp
|
||||
cur.execute("""
|
||||
SELECT
|
||||
l.license_type,
|
||||
COUNT(*) as session_count
|
||||
FROM sessions s
|
||||
JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE s.active = true
|
||||
GROUP BY l.license_type
|
||||
ORDER BY session_count DESC
|
||||
""")
|
||||
|
||||
sessions_by_type = []
|
||||
for row in cur.fetchall():
|
||||
sessions_by_type.append({
|
||||
'license_type': row[0],
|
||||
'count': row[1]
|
||||
})
|
||||
|
||||
# Top 10 Lizenzen nach aktiven Sessions
|
||||
cur.execute("""
|
||||
SELECT
|
||||
s.license_key,
|
||||
l.customer_name,
|
||||
COUNT(*) as session_count,
|
||||
l.device_limit
|
||||
FROM sessions s
|
||||
JOIN licenses l ON s.license_key = l.license_key
|
||||
WHERE s.active = true
|
||||
GROUP BY s.license_key, l.customer_name, l.device_limit
|
||||
ORDER BY session_count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
|
||||
top_licenses = []
|
||||
for row in cur.fetchall():
|
||||
top_licenses.append({
|
||||
'license_key': row[0],
|
||||
'customer_name': row[1],
|
||||
'session_count': row[2],
|
||||
'device_limit': row[3]
|
||||
})
|
||||
|
||||
# Session-Verlauf (letzte 7 Tage)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
DATE(login_time) as date,
|
||||
COUNT(*) as login_count,
|
||||
COUNT(DISTINCT license_key) as unique_licenses,
|
||||
COUNT(DISTINCT username) as unique_users
|
||||
FROM sessions
|
||||
WHERE login_time >= CURRENT_DATE - INTERVAL '7 days'
|
||||
GROUP BY DATE(login_time)
|
||||
ORDER BY date
|
||||
""")
|
||||
|
||||
session_history = []
|
||||
for row in cur.fetchall():
|
||||
session_history.append({
|
||||
'date': row[0].strftime('%Y-%m-%d'),
|
||||
'login_count': row[1],
|
||||
'unique_licenses': row[2],
|
||||
'unique_users': row[3]
|
||||
})
|
||||
|
||||
# Durchschnittliche Session-Dauer
|
||||
cur.execute("""
|
||||
SELECT
|
||||
AVG(EXTRACT(EPOCH FROM (logout_time - login_time))/3600) as avg_duration_hours
|
||||
FROM sessions
|
||||
WHERE active = false
|
||||
AND logout_time IS NOT NULL
|
||||
AND logout_time - login_time < INTERVAL '24 hours'
|
||||
AND login_time >= CURRENT_DATE - INTERVAL '30 days'
|
||||
""")
|
||||
|
||||
avg_duration = cur.fetchone()[0] or 0
|
||||
|
||||
return render_template("session_statistics.html",
|
||||
current_stats={
|
||||
'active_licenses': current_stats[0],
|
||||
'unique_users': current_stats[1],
|
||||
'unique_devices': current_stats[2],
|
||||
'total_sessions': current_stats[3]
|
||||
},
|
||||
sessions_by_type=sessions_by_type,
|
||||
top_licenses=top_licenses,
|
||||
session_history=session_history,
|
||||
avg_duration=round(avg_duration, 1))
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Fehler beim Laden der Session-Statistiken: {str(e)}")
|
||||
flash('Fehler beim Laden der Statistiken!', 'error')
|
||||
return redirect(url_for('sessions.sessions'))
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
@ -0,0 +1,439 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Ressourcen hinzufügen{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Card Styling */
|
||||
.main-card {
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Preview Section */
|
||||
.preview-card {
|
||||
background-color: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.preview-card.active {
|
||||
border-color: #28a745;
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
|
||||
/* Format Examples */
|
||||
.example-card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.example-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.example-code {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Resource Type Selector */
|
||||
.resource-type-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.resource-type-option {
|
||||
padding: 1.5rem;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #fff;
|
||||
}
|
||||
.resource-type-option:hover {
|
||||
border-color: #007bff;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.resource-type-option.selected {
|
||||
border-color: #007bff;
|
||||
background-color: #e7f3ff;
|
||||
}
|
||||
.resource-type-option .icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Textarea Styling */
|
||||
.resource-input {
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: #f8f9fa;
|
||||
border: 2px solid #dee2e6;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
.resource-input:focus {
|
||||
background-color: #fff;
|
||||
border-color: #80bdff;
|
||||
}
|
||||
|
||||
/* Stats Display */
|
||||
.stats-display {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">Ressourcen hinzufügen</h1>
|
||||
<p class="text-muted mb-0">Fügen Sie neue Domains, IPs oder Telefonnummern zum Pool hinzu</p>
|
||||
</div>
|
||||
<a href="{{ url_for('resources', show_test=show_test) }}" class="btn btn-secondary">
|
||||
← Zurück zur Übersicht
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('add_resources', show_test=show_test) }}" id="addResourceForm">
|
||||
<!-- Resource Type Selection -->
|
||||
<div class="card main-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">1️⃣ Ressourcentyp wählen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<input type="hidden" name="resource_type" id="resource_type" required>
|
||||
<div class="resource-type-selector">
|
||||
<div class="resource-type-option" data-type="domain">
|
||||
<div class="icon">🌐</div>
|
||||
<h6 class="mb-0">Domain</h6>
|
||||
<small class="text-muted">Webseiten-Adressen</small>
|
||||
</div>
|
||||
<div class="resource-type-option" data-type="ipv4">
|
||||
<div class="icon">🖥️</div>
|
||||
<h6 class="mb-0">IPv4</h6>
|
||||
<small class="text-muted">IP-Adressen</small>
|
||||
</div>
|
||||
<div class="resource-type-option" data-type="phone">
|
||||
<div class="icon">📱</div>
|
||||
<h6 class="mb-0">Telefon</h6>
|
||||
<small class="text-muted">Telefonnummern</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Input -->
|
||||
<div class="card main-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">2️⃣ Ressourcen eingeben</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="resources_text" class="form-label">
|
||||
Ressourcen (eine pro Zeile)
|
||||
</label>
|
||||
<textarea name="resources_text"
|
||||
id="resources_text"
|
||||
class="form-control resource-input"
|
||||
rows="12"
|
||||
required
|
||||
placeholder="Bitte wählen Sie zuerst einen Ressourcentyp aus..."></textarea>
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Geben Sie jede Ressource in eine neue Zeile ein. Duplikate werden automatisch übersprungen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Preview -->
|
||||
<div class="preview-card p-3" id="preview">
|
||||
<h6 class="mb-3">📊 Live-Vorschau</h6>
|
||||
<div class="stats-display">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="validCount">0</div>
|
||||
<div class="stat-label">Gültig</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value text-warning" id="duplicateCount">0</div>
|
||||
<div class="stat-label">Duplikate</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value text-danger" id="invalidCount">0</div>
|
||||
<div class="stat-label">Ungültig</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="errorList" class="mt-3" style="display: none;">
|
||||
<div class="alert alert-danger">
|
||||
<strong>Fehler gefunden:</strong>
|
||||
<ul id="errorMessages" class="mb-0"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Format Examples -->
|
||||
<div class="card main-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">💡 Format-Beispiele</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card example-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<span class="text-primary">🌐</span> Domains
|
||||
</h6>
|
||||
<pre class="example-code">example.com
|
||||
test-domain.net
|
||||
meine-seite.de
|
||||
subdomain.example.org
|
||||
my-website.io</pre>
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<strong>Format:</strong> Ohne http(s)://<br>
|
||||
<strong>Erlaubt:</strong> Buchstaben, Zahlen, Punkt, Bindestrich
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card example-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<span class="text-primary">🖥️</span> IPv4-Adressen
|
||||
</h6>
|
||||
<pre class="example-code">192.168.1.10
|
||||
192.168.1.11
|
||||
10.0.0.1
|
||||
172.16.0.5
|
||||
8.8.8.8</pre>
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<strong>Format:</strong> xxx.xxx.xxx.xxx<br>
|
||||
<strong>Bereich:</strong> 0-255 pro Oktett
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card example-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<span class="text-primary">📱</span> Telefonnummern
|
||||
</h6>
|
||||
<pre class="example-code">+491701234567
|
||||
+493012345678
|
||||
+33123456789
|
||||
+441234567890
|
||||
+12125551234</pre>
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<strong>Format:</strong> Mit Ländervorwahl<br>
|
||||
<strong>Start:</strong> Immer mit +
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Data Option -->
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="is_test" name="is_test" {% if show_test %}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_test">
|
||||
Als Testdaten markieren
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="button" class="btn btn-secondary" onclick="window.location.href='{{ url_for('resources', show_test=show_test) }}'">
|
||||
<i class="fas fa-times"></i> Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success btn-lg" id="submitBtn" disabled>
|
||||
<i class="fas fa-plus-circle"></i> Ressourcen hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const typeOptions = document.querySelectorAll('.resource-type-option');
|
||||
const typeInput = document.getElementById('resource_type');
|
||||
const textArea = document.getElementById('resources_text');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const form = document.getElementById('addResourceForm');
|
||||
|
||||
// Preview elements
|
||||
const validCount = document.getElementById('validCount');
|
||||
const duplicateCount = document.getElementById('duplicateCount');
|
||||
const invalidCount = document.getElementById('invalidCount');
|
||||
const errorList = document.getElementById('errorList');
|
||||
const errorMessages = document.getElementById('errorMessages');
|
||||
const preview = document.getElementById('preview');
|
||||
|
||||
let selectedType = null;
|
||||
|
||||
// Placeholder texts for different types
|
||||
const placeholders = {
|
||||
domain: `example.com
|
||||
test-site.net
|
||||
my-domain.org
|
||||
subdomain.example.com`,
|
||||
ipv4: `192.168.1.1
|
||||
10.0.0.1
|
||||
172.16.0.1
|
||||
8.8.8.8`,
|
||||
phone: `+491234567890
|
||||
+4930123456
|
||||
+33123456789
|
||||
+12125551234`
|
||||
};
|
||||
|
||||
// Resource type selection
|
||||
typeOptions.forEach(option => {
|
||||
option.addEventListener('click', function() {
|
||||
typeOptions.forEach(opt => opt.classList.remove('selected'));
|
||||
this.classList.add('selected');
|
||||
selectedType = this.dataset.type;
|
||||
typeInput.value = selectedType;
|
||||
textArea.placeholder = placeholders[selectedType] || '';
|
||||
textArea.disabled = false;
|
||||
updatePreview();
|
||||
});
|
||||
});
|
||||
|
||||
// Validation functions
|
||||
function validateDomain(domain) {
|
||||
const domainRegex = /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9][a-zA-Z0-9-_]+\.[a-zA-Z]{2,}$/;
|
||||
return domainRegex.test(domain);
|
||||
}
|
||||
|
||||
function validateIPv4(ip) {
|
||||
const ipRegex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
return ipRegex.test(ip);
|
||||
}
|
||||
|
||||
function validatePhone(phone) {
|
||||
const phoneRegex = /^\+[1-9]\d{6,14}$/;
|
||||
return phoneRegex.test(phone);
|
||||
}
|
||||
|
||||
// Update preview function
|
||||
function updatePreview() {
|
||||
if (!selectedType) {
|
||||
preview.classList.remove('active');
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = textArea.value.split('\n').filter(line => line.trim() !== '');
|
||||
const uniqueResources = new Set();
|
||||
const errors = [];
|
||||
let valid = 0;
|
||||
let duplicates = 0;
|
||||
let invalid = 0;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmed = line.trim();
|
||||
if (uniqueResources.has(trimmed)) {
|
||||
duplicates++;
|
||||
return;
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
switch(selectedType) {
|
||||
case 'domain':
|
||||
isValid = validateDomain(trimmed);
|
||||
break;
|
||||
case 'ipv4':
|
||||
isValid = validateIPv4(trimmed);
|
||||
break;
|
||||
case 'phone':
|
||||
isValid = validatePhone(trimmed);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
valid++;
|
||||
uniqueResources.add(trimmed);
|
||||
} else {
|
||||
invalid++;
|
||||
errors.push(`Zeile ${index + 1}: "${trimmed}"`);
|
||||
}
|
||||
});
|
||||
|
||||
// Update counts
|
||||
validCount.textContent = valid;
|
||||
duplicateCount.textContent = duplicates;
|
||||
invalidCount.textContent = invalid;
|
||||
|
||||
// Show/hide error list
|
||||
if (errors.length > 0) {
|
||||
errorList.style.display = 'block';
|
||||
errorMessages.innerHTML = errors.map(err => `<li>${err}</li>`).join('');
|
||||
} else {
|
||||
errorList.style.display = 'none';
|
||||
}
|
||||
|
||||
// Enable/disable submit button
|
||||
submitBtn.disabled = valid === 0 || invalid > 0;
|
||||
|
||||
// Update preview appearance
|
||||
if (lines.length > 0) {
|
||||
preview.classList.add('active');
|
||||
} else {
|
||||
preview.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Live validation
|
||||
textArea.addEventListener('input', updatePreview);
|
||||
|
||||
// Form submission
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (submitBtn.disabled) {
|
||||
e.preventDefault();
|
||||
alert('Bitte beheben Sie alle Fehler bevor Sie fortfahren.');
|
||||
}
|
||||
});
|
||||
|
||||
// Initial state
|
||||
textArea.disabled = true;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,318 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Log{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('audit_log', sort=field, order='desc' if current_order == 'asc' else 'asc', user=filter_user, action=filter_action, entity=filter_entity, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('audit_log', sort=field, order='asc', user=filter_user, action=filter_action, entity=filter_entity, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.audit-details {
|
||||
font-size: 0.85em;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.json-display {
|
||||
background-color: #f8f9fa;
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.8em;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.action-CREATE { color: #28a745; }
|
||||
.action-UPDATE { color: #007bff; }
|
||||
.action-DELETE { color: #dc3545; }
|
||||
.action-LOGIN { color: #17a2b8; }
|
||||
.action-LOGOUT { color: #6c757d; }
|
||||
.action-AUTO_LOGOUT { color: #fd7e14; }
|
||||
.action-EXPORT { color: #ffc107; }
|
||||
.action-GENERATE_KEY { color: #20c997; }
|
||||
.action-CREATE_BATCH { color: #6610f2; }
|
||||
.action-BACKUP { color: #5a67d8; }
|
||||
.action-LOGIN_2FA_SUCCESS { color: #00a86b; }
|
||||
.action-LOGIN_2FA_BACKUP { color: #059862; }
|
||||
.action-LOGIN_2FA_FAILED { color: #e53e3e; }
|
||||
.action-LOGIN_BLOCKED { color: #b91c1c; }
|
||||
.action-RESTORE { color: #4299e1; }
|
||||
.action-PASSWORD_CHANGE { color: #805ad5; }
|
||||
.action-2FA_ENABLED { color: #38a169; }
|
||||
.action-2FA_DISABLED { color: #e53e3e; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="mb-4">
|
||||
<h2>📝 Log</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" action="/audit" id="auditFilterForm">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label for="user" class="form-label">Benutzer</label>
|
||||
<input type="text" class="form-control" id="user" name="user"
|
||||
placeholder="Benutzername..." value="{{ filter_user }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="action" class="form-label">Aktion</label>
|
||||
<select class="form-select" id="action" name="action">
|
||||
<option value="">Alle Aktionen</option>
|
||||
<option value="CREATE" {% if filter_action == 'CREATE' %}selected{% endif %}>➕ Erstellt</option>
|
||||
<option value="UPDATE" {% if filter_action == 'UPDATE' %}selected{% endif %}>✏️ Bearbeitet</option>
|
||||
<option value="DELETE" {% if filter_action == 'DELETE' %}selected{% endif %}>🗑️ Gelöscht</option>
|
||||
<option value="LOGIN" {% if filter_action == 'LOGIN' %}selected{% endif %}>🔑 Anmeldung</option>
|
||||
<option value="LOGOUT" {% if filter_action == 'LOGOUT' %}selected{% endif %}>🚪 Abmeldung</option>
|
||||
<option value="AUTO_LOGOUT" {% if filter_action == 'AUTO_LOGOUT' %}selected{% endif %}>⏰ Auto-Logout</option>
|
||||
<option value="EXPORT" {% if filter_action == 'EXPORT' %}selected{% endif %}>📥 Export</option>
|
||||
<option value="GENERATE_KEY" {% if filter_action == 'GENERATE_KEY' %}selected{% endif %}>🔑 Key generiert</option>
|
||||
<option value="CREATE_BATCH" {% if filter_action == 'CREATE_BATCH' %}selected{% endif %}>🔑 Batch erstellt</option>
|
||||
<option value="BACKUP" {% if filter_action == 'BACKUP' %}selected{% endif %}>💾 Backup</option>
|
||||
<option value="LOGIN_2FA_SUCCESS" {% if filter_action == 'LOGIN_2FA_SUCCESS' %}selected{% endif %}>🔐 2FA-Anmeldung</option>
|
||||
<option value="LOGIN_2FA_BACKUP" {% if filter_action == 'LOGIN_2FA_BACKUP' %}selected{% endif %}>🔒 2FA-Backup-Code</option>
|
||||
<option value="LOGIN_2FA_FAILED" {% if filter_action == 'LOGIN_2FA_FAILED' %}selected{% endif %}>⛔ 2FA-Fehlgeschlagen</option>
|
||||
<option value="LOGIN_BLOCKED" {% if filter_action == 'LOGIN_BLOCKED' %}selected{% endif %}>🚫 Login-Blockiert</option>
|
||||
<option value="RESTORE" {% if filter_action == 'RESTORE' %}selected{% endif %}>🔄 Wiederhergestellt</option>
|
||||
<option value="PASSWORD_CHANGE" {% if filter_action == 'PASSWORD_CHANGE' %}selected{% endif %}>🔐 Passwort geändert</option>
|
||||
<option value="2FA_ENABLED" {% if filter_action == '2FA_ENABLED' %}selected{% endif %}>✅ 2FA aktiviert</option>
|
||||
<option value="2FA_DISABLED" {% if filter_action == '2FA_DISABLED' %}selected{% endif %}>❌ 2FA deaktiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="entity" class="form-label">Entität</label>
|
||||
<select class="form-select" id="entity" name="entity">
|
||||
<option value="">Alle Entitäten</option>
|
||||
<option value="license" {% if filter_entity == 'license' %}selected{% endif %}>Lizenz</option>
|
||||
<option value="customer" {% if filter_entity == 'customer' %}selected{% endif %}>Kunde</option>
|
||||
<option value="user" {% if filter_entity == 'user' %}selected{% endif %}>Benutzer</option>
|
||||
<option value="session" {% if filter_entity == 'session' %}selected{% endif %}>Session</option>
|
||||
<option value="database" {% if filter_entity == 'database' %}selected{% endif %}>Datenbank</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/audit" class="btn btn-outline-secondary">Zurücksetzen</a>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-download"></i> Export
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="/export/audit?format=excel&user={{ filter_user }}&action={{ filter_action }}&entity={{ filter_entity }}">
|
||||
<i class="bi bi-file-earmark-excel text-success"></i> Excel Export</a></li>
|
||||
<li><a class="dropdown-item" href="/export/audit?format=csv&user={{ filter_user }}&action={{ filter_action }}&entity={{ filter_entity }}">
|
||||
<i class="bi bi-file-earmark-text"></i> CSV Export</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Log Tabelle -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ sortable_header('Zeitstempel', 'timestamp', sort, order) }}
|
||||
{{ sortable_header('Benutzer', 'username', sort, order) }}
|
||||
{{ sortable_header('Aktion', 'action', sort, order) }}
|
||||
{{ sortable_header('Entität', 'entity', sort, order) }}
|
||||
<th>Details</th>
|
||||
{{ sortable_header('IP-Adresse', 'ip', sort, order) }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log[1].strftime('%d.%m.%Y %H:%M:%S') }}</td>
|
||||
<td><strong>{{ log[2] }}</strong></td>
|
||||
<td>
|
||||
<span class="action-{{ log[3] }}">
|
||||
{% if log[3] == 'CREATE' %}➕ Erstellt
|
||||
{% elif log[3] == 'UPDATE' %}✏️ Bearbeitet
|
||||
{% elif log[3] == 'DELETE' %}🗑️ Gelöscht
|
||||
{% elif log[3] == 'LOGIN' %}🔑 Anmeldung
|
||||
{% elif log[3] == 'LOGOUT' %}🚪 Abmeldung
|
||||
{% elif log[3] == 'AUTO_LOGOUT' %}⏰ Auto-Logout
|
||||
{% elif log[3] == 'EXPORT' %}📥 Export
|
||||
{% elif log[3] == 'GENERATE_KEY' %}🔑 Key generiert
|
||||
{% elif log[3] == 'CREATE_BATCH' %}🔑 Batch erstellt
|
||||
{% elif log[3] == 'BACKUP' %}💾 Backup erstellt
|
||||
{% elif log[3] == 'LOGIN_2FA_SUCCESS' %}🔐 2FA-Anmeldung
|
||||
{% elif log[3] == 'LOGIN_2FA_BACKUP' %}🔒 2FA-Backup-Code
|
||||
{% elif log[3] == 'LOGIN_2FA_FAILED' %}⛔ 2FA-Fehlgeschlagen
|
||||
{% elif log[3] == 'LOGIN_BLOCKED' %}🚫 Login-Blockiert
|
||||
{% elif log[3] == 'RESTORE' %}🔄 Wiederhergestellt
|
||||
{% elif log[3] == 'PASSWORD_CHANGE' %}🔐 Passwort geändert
|
||||
{% elif log[3] == '2FA_ENABLED' %}✅ 2FA aktiviert
|
||||
{% elif log[3] == '2FA_DISABLED' %}❌ 2FA deaktiviert
|
||||
{% else %}{{ log[3] }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ log[4] }}
|
||||
{% if log[5] %}
|
||||
<small class="text-muted">#{{ log[5] }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="audit-details">
|
||||
{% if log[10] %}
|
||||
<div class="mb-1"><small class="text-muted">{{ log[10] }}</small></div>
|
||||
{% endif %}
|
||||
|
||||
{% if log[6] and log[3] == 'DELETE' %}
|
||||
<details>
|
||||
<summary>Gelöschte Werte</summary>
|
||||
<div class="json-display">
|
||||
{% for key, value in log[6].items() %}
|
||||
<strong>{{ key }}:</strong> {{ value }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elif log[6] and log[7] and log[3] == 'UPDATE' %}
|
||||
<details>
|
||||
<summary>Änderungen anzeigen</summary>
|
||||
<div class="json-display">
|
||||
<strong>Vorher:</strong><br>
|
||||
{% for key, value in log[6].items() %}
|
||||
{% if log[7][key] != value %}
|
||||
{{ key }}: {{ value }}<br>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<hr class="my-1">
|
||||
<strong>Nachher:</strong><br>
|
||||
{% for key, value in log[7].items() %}
|
||||
{% if log[6][key] != value %}
|
||||
{{ key }}: {{ value }}<br>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elif log[7] and log[3] == 'CREATE' %}
|
||||
<details>
|
||||
<summary>Erstellte Werte</summary>
|
||||
<div class="json-display">
|
||||
{% for key, value in log[7].items() %}
|
||||
<strong>{{ key }}:</strong> {{ value }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{ log[8] or '-' }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not logs %}
|
||||
<div class="text-center py-5">
|
||||
<p class="text-muted">Keine Audit-Log-Einträge gefunden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav aria-label="Seitennavigation" class="mt-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
<!-- Erste Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">Erste</a>
|
||||
</li>
|
||||
|
||||
<!-- Vorherige Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=page-1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">←</a>
|
||||
</li>
|
||||
|
||||
<!-- Seitenzahlen -->
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p >= page - 2 and p <= page + 2 %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=p, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Nächste Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=page+1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">→</a>
|
||||
</li>
|
||||
|
||||
<!-- Letzte Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=total_pages, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">Letzte</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted">
|
||||
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Einträge
|
||||
</p>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Live Filtering für Audit Log
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const filterForm = document.getElementById('auditFilterForm');
|
||||
const userInput = document.getElementById('user');
|
||||
const actionSelect = document.getElementById('action');
|
||||
const entitySelect = document.getElementById('entity');
|
||||
|
||||
// Debounce timer für Textfelder
|
||||
let searchTimeout;
|
||||
|
||||
// Live-Filter für Benutzer-Textfeld (mit 300ms Verzögerung)
|
||||
userInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
filterForm.submit();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Live-Filter für Dropdowns (sofort)
|
||||
actionSelect.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
|
||||
entitySelect.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,228 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Backup-Codes{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.success-icon {
|
||||
font-size: 5rem;
|
||||
animation: bounce 0.5s ease-in-out;
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
.backup-code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
padding: 8px 15px;
|
||||
background-color: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
}
|
||||
.backup-codes-container {
|
||||
background-color: #fff;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.action-buttons .btn {
|
||||
margin: 5px;
|
||||
}
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
.backup-codes-container {
|
||||
border: 1px solid #000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<!-- Success Message -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="success-icon text-success">✅</div>
|
||||
<h1 class="mt-3">2FA erfolgreich aktiviert!</h1>
|
||||
<p class="lead text-muted">Ihre Zwei-Faktor-Authentifizierung ist jetzt aktiv.</p>
|
||||
</div>
|
||||
|
||||
<!-- Backup Codes Card -->
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h4 class="mb-0">
|
||||
<span style="font-size: 1.5rem; vertical-align: middle;">⚠️</span>
|
||||
Wichtig: Ihre Backup-Codes
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-4">
|
||||
<strong>Was sind Backup-Codes?</strong><br>
|
||||
Diese Codes ermöglichen Ihnen den Zugang zu Ihrem Account, falls Sie keinen Zugriff auf Ihre Authenticator-App haben.
|
||||
<strong>Jeder Code kann nur einmal verwendet werden.</strong>
|
||||
</div>
|
||||
|
||||
<!-- Backup Codes Display -->
|
||||
<div class="backup-codes-container text-center">
|
||||
<h5 class="mb-4">Ihre 8 Backup-Codes:</h5>
|
||||
<div class="row justify-content-center">
|
||||
{% for code in backup_codes %}
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="backup-code">{{ code }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="text-center action-buttons no-print">
|
||||
<button type="button" class="btn btn-primary btn-lg" onclick="downloadCodes()">
|
||||
💾 Als Datei speichern
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-lg" onclick="printCodes()">
|
||||
🖨️ Drucken
|
||||
</button>
|
||||
<button type="button" class="btn btn-info btn-lg" onclick="copyCodes()">
|
||||
📋 Alle kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Security Tips -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-danger">
|
||||
<h6>❌ Nicht empfohlen:</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li>Im selben Passwort-Manager wie Ihr Passwort</li>
|
||||
<li>Als Foto auf Ihrem Handy</li>
|
||||
<li>In einer unverschlüsselten Datei</li>
|
||||
<li>Per E-Mail an sich selbst</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-success">
|
||||
<h6>✅ Empfohlen:</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li>Ausgedruckt in einem Safe</li>
|
||||
<li>In einem separaten Passwort-Manager</li>
|
||||
<li>Verschlüsselt auf einem USB-Stick</li>
|
||||
<li>An einem sicheren Ort zu Hause</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation -->
|
||||
<div class="text-center mt-4 no-print">
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="confirmSaved" onchange="checkConfirmation()">
|
||||
<label class="form-check-label" for="confirmSaved">
|
||||
Ich habe die Backup-Codes sicher gespeichert
|
||||
</label>
|
||||
</div>
|
||||
<a href="{{ url_for('profile') }}" class="btn btn-lg btn-success" id="continueBtn" style="display: none;">
|
||||
✅ Weiter zum Profil
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const backupCodes = {{ backup_codes | tojson }};
|
||||
|
||||
function checkConfirmation() {
|
||||
const checkbox = document.getElementById('confirmSaved');
|
||||
const continueBtn = document.getElementById('continueBtn');
|
||||
continueBtn.style.display = checkbox.checked ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
function downloadCodes() {
|
||||
const content = `V2 Admin Panel - Backup Codes
|
||||
=====================================
|
||||
Generiert am: ${new Date().toLocaleString('de-DE')}
|
||||
|
||||
WICHTIG: Bewahren Sie diese Codes sicher auf!
|
||||
Jeder Code kann nur einmal verwendet werden.
|
||||
|
||||
Ihre 8 Backup-Codes:
|
||||
--------------------
|
||||
${backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}
|
||||
|
||||
Sicherheitshinweise:
|
||||
-------------------
|
||||
✓ Bewahren Sie diese Codes getrennt von Ihrem Passwort auf
|
||||
✓ Speichern Sie sie an einem sicheren physischen Ort
|
||||
✓ Teilen Sie diese Codes niemals mit anderen
|
||||
✓ Jeder Code funktioniert nur einmal
|
||||
|
||||
Bei Verlust Ihrer Authenticator-App:
|
||||
------------------------------------
|
||||
1. Gehen Sie zur Login-Seite
|
||||
2. Geben Sie Benutzername und Passwort ein
|
||||
3. Klicken Sie auf "Backup-Code verwenden"
|
||||
4. Geben Sie einen dieser Codes ein
|
||||
|
||||
Nach Verwendung eines Codes sollten Sie neue Backup-Codes generieren.`;
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `v2-backup-codes-${new Date().toISOString().split('T')[0]}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
// Visual feedback
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '✅ Heruntergeladen!';
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-success');
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-primary');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function printCodes() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
function copyCodes() {
|
||||
const codesText = backupCodes.join('\n');
|
||||
navigator.clipboard.writeText(codesText).then(function() {
|
||||
// Visual feedback
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '✅ Kopiert!';
|
||||
btn.classList.remove('btn-info');
|
||||
btn.classList.add('btn-success');
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-info');
|
||||
}, 2000);
|
||||
}).catch(function(err) {
|
||||
alert('Fehler beim Kopieren. Bitte manuell kopieren.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,301 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Backup-Verwaltung{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.status-success { color: #28a745; }
|
||||
.status-failed { color: #dc3545; }
|
||||
.status-in_progress { color: #ffc107; }
|
||||
.backup-actions { white-space: nowrap; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>💾 Backup-Verwaltung</h2>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup-Info -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">📅 Letztes erfolgreiches Backup</h5>
|
||||
{% if last_backup %}
|
||||
<p class="mb-1"><strong>Zeitpunkt:</strong> {{ last_backup[0].strftime('%d.%m.%Y %H:%M:%S') }}</p>
|
||||
<p class="mb-1"><strong>Größe:</strong> {{ (last_backup[1] / 1024 / 1024)|round(2) }} MB</p>
|
||||
<p class="mb-0"><strong>Dauer:</strong> {{ last_backup[2]|round(1) }} Sekunden</p>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">🔧 Backup-Aktionen</h5>
|
||||
<button id="createBackupBtn" class="btn btn-primary" onclick="createBackup()">
|
||||
💾 Backup jetzt erstellen
|
||||
</button>
|
||||
<p class="text-muted mt-2 mb-0">
|
||||
<small>Automatische Backups: Täglich um 03:00 Uhr</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup-Historie -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📋 Backup-Historie</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-type="date">Zeitstempel</th>
|
||||
<th class="sortable">Dateiname</th>
|
||||
<th class="sortable" data-type="numeric">Größe</th>
|
||||
<th class="sortable">Typ</th>
|
||||
<th class="sortable">Status</th>
|
||||
<th class="sortable">Erstellt von</th>
|
||||
<th>Details</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td>{{ backup[6].strftime('%d.%m.%Y %H:%M:%S') }}</td>
|
||||
<td>
|
||||
<small>{{ backup[1] }}</small>
|
||||
{% if backup[11] %}
|
||||
<span class="badge bg-info ms-1">🔒 Verschlüsselt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if backup[2] %}
|
||||
{{ (backup[2] / 1024 / 1024)|round(2) }} MB
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if backup[3] == 'manual' %}
|
||||
<span class="badge bg-primary">Manuell</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Automatisch</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if backup[4] == 'success' %}
|
||||
<span class="status-success">✅ Erfolgreich</span>
|
||||
{% elif backup[4] == 'failed' %}
|
||||
<span class="status-failed" title="{{ backup[5] }}">❌ Fehlgeschlagen</span>
|
||||
{% else %}
|
||||
<span class="status-in_progress">⏳ In Bearbeitung</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ backup[7] }}</td>
|
||||
<td>
|
||||
{% if backup[8] and backup[9] %}
|
||||
<small>
|
||||
{{ backup[8] }} Tabellen<br>
|
||||
{{ backup[9] }} Datensätze<br>
|
||||
{% if backup[10] %}
|
||||
{{ backup[10]|round(1) }}s
|
||||
{% endif %}
|
||||
</small>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="backup-actions">
|
||||
{% if backup[4] == 'success' %}
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="/backup/download/{{ backup[0] }}"
|
||||
class="btn btn-outline-primary"
|
||||
title="Backup herunterladen">
|
||||
📥 Download
|
||||
</a>
|
||||
<button class="btn btn-outline-success"
|
||||
onclick="restoreBackup({{ backup[0] }}, '{{ backup[1] }}')"
|
||||
title="Backup wiederherstellen">
|
||||
🔄 Wiederherstellen
|
||||
</button>
|
||||
<button class="btn btn-outline-danger"
|
||||
onclick="deleteBackup({{ backup[0] }}, '{{ backup[1] }}')"
|
||||
title="Backup löschen">
|
||||
🗑️ Löschen
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not backups %}
|
||||
<div class="text-center py-5">
|
||||
<p class="text-muted">Noch keine Backups vorhanden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wiederherstellungs-Modal -->
|
||||
<div class="modal fade" id="restoreModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">🔄 Backup wiederherstellen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<strong>⚠️ Warnung:</strong> Bei der Wiederherstellung werden alle aktuellen Daten überschrieben!
|
||||
</div>
|
||||
<p>Backup: <strong id="restoreFilename"></strong></p>
|
||||
<form id="restoreForm">
|
||||
<input type="hidden" id="restoreBackupId">
|
||||
<div class="mb-3">
|
||||
<label for="encryptionKey" class="form-label">Verschlüsselungs-Passwort (optional)</label>
|
||||
<input type="password" class="form-control" id="encryptionKey"
|
||||
placeholder="Leer lassen für Standard-Passwort">
|
||||
<small class="text-muted">
|
||||
Falls das Backup mit einem anderen Passwort verschlüsselt wurde
|
||||
</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-danger" onclick="confirmRestore()">
|
||||
⚠️ Wiederherstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function createBackup() {
|
||||
const btn = document.getElementById('createBackupBtn');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '⏳ Backup wird erstellt...';
|
||||
|
||||
fetch('/backup/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ ' + data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('❌ ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('❌ Fehler beim Erstellen des Backups: ' + error);
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
function restoreBackup(backupId, filename) {
|
||||
document.getElementById('restoreBackupId').value = backupId;
|
||||
document.getElementById('restoreFilename').textContent = filename;
|
||||
document.getElementById('encryptionKey').value = '';
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('restoreModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function confirmRestore() {
|
||||
if (!confirm('Wirklich wiederherstellen? Alle aktuellen Daten werden überschrieben!')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backupId = document.getElementById('restoreBackupId').value;
|
||||
const encryptionKey = document.getElementById('encryptionKey').value;
|
||||
|
||||
const formData = new FormData();
|
||||
if (encryptionKey) {
|
||||
formData.append('encryption_key', encryptionKey);
|
||||
}
|
||||
|
||||
// Modal schließen
|
||||
bootstrap.Modal.getInstance(document.getElementById('restoreModal')).hide();
|
||||
|
||||
// Loading anzeigen
|
||||
const loadingDiv = document.createElement('div');
|
||||
loadingDiv.className = 'position-fixed top-50 start-50 translate-middle';
|
||||
loadingDiv.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
|
||||
document.body.appendChild(loadingDiv);
|
||||
|
||||
fetch(`/backup/restore/${backupId}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ ' + data.message + '\n\nDie Seite wird neu geladen...');
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
alert('❌ ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('❌ Fehler bei der Wiederherstellung: ' + error);
|
||||
})
|
||||
.finally(() => {
|
||||
document.body.removeChild(loadingDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteBackup(backupId, filename) {
|
||||
if (!confirm(`Soll das Backup "${filename}" wirklich gelöscht werden?\n\nDieser Vorgang kann nicht rückgängig gemacht werden!`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/backup/delete/${backupId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ ' + data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('❌ ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('❌ Fehler beim Löschen des Backups: ' + error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,679 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin Panel{% endblock %} - Lizenzverwaltung</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||
{% block extra_css %}{% endblock %}
|
||||
<style>
|
||||
/* Global Status Colors */
|
||||
:root {
|
||||
--status-active: #28a745;
|
||||
--status-warning: #ffc107;
|
||||
--status-danger: #dc3545;
|
||||
--status-inactive: #6c757d;
|
||||
--status-info: #17a2b8;
|
||||
--sidebar-width: 250px;
|
||||
--sidebar-collapsed: 60px;
|
||||
}
|
||||
|
||||
/* Status Classes - Global */
|
||||
.status-aktiv { color: var(--status-active) !important; }
|
||||
.status-ablaufend { color: var(--status-warning) !important; }
|
||||
.status-abgelaufen { color: var(--status-danger) !important; }
|
||||
.status-deaktiviert { color: var(--status-inactive) !important; }
|
||||
|
||||
/* Badge Variants */
|
||||
.badge-aktiv { background-color: var(--status-active) !important; }
|
||||
.badge-ablaufend { background-color: var(--status-warning) !important; color: #000 !important; }
|
||||
.badge-abgelaufen { background-color: var(--status-danger) !important; }
|
||||
.badge-deaktiviert { background-color: var(--status-inactive) !important; }
|
||||
|
||||
/* Session Timer Styles */
|
||||
#session-timer {
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.timer-normal {
|
||||
background-color: var(--status-active);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timer-warning {
|
||||
background-color: var(--status-warning);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.timer-danger {
|
||||
background-color: var(--status-danger);
|
||||
color: white;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.timer-critical {
|
||||
background-color: var(--status-danger);
|
||||
color: white;
|
||||
animation: blink 0.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.session-warning-modal {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Table Improvements */
|
||||
.table-container {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-sticky thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #fff;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table-sticky thead th {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
/* Inline Actions */
|
||||
.btn-copy {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-copy:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.btn-copy.copied {
|
||||
background-color: var(--status-active);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.form-switch-custom {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.form-switch-custom .form-check-input {
|
||||
cursor: pointer;
|
||||
width: 3em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
.form-switch-custom .form-check-input:checked {
|
||||
background-color: var(--status-active);
|
||||
border-color: var(--status-active);
|
||||
}
|
||||
|
||||
/* Bulk Actions Bar */
|
||||
.bulk-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: #212529;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bulk-actions.show {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Checkbox Styling */
|
||||
.checkbox-cell {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.form-check-input-custom {
|
||||
cursor: pointer;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
}
|
||||
|
||||
/* Sortable Table Styles */
|
||||
.sortable-table th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
padding-right: 25px;
|
||||
}
|
||||
|
||||
.sortable-table th.sortable:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sortable-table th.sortable::after {
|
||||
content: '↕';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.sortable-table th.sortable.asc::after {
|
||||
content: '↑';
|
||||
opacity: 1;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
.sortable-table th.sortable.desc::after {
|
||||
content: '↓';
|
||||
opacity: 1;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
/* Server-side sortable styles */
|
||||
.server-sortable {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.server-sortable:hover {
|
||||
color: var(--bs-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
margin-left: 5px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.sort-indicator.active {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* Sidebar Navigation */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
height: calc(100vh - 56px);
|
||||
width: var(--sidebar-width);
|
||||
background-color: #f8f9fa;
|
||||
border-right: 1px solid #dee2e6;
|
||||
overflow-y: auto;
|
||||
transition: all 0.3s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: var(--sidebar-collapsed);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem;
|
||||
background-color: #e9ecef;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-item {
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link:hover {
|
||||
background-color: #e9ecef;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link.active {
|
||||
background-color: var(--bs-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link i {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-nav .nav-link span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-submenu {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: #f1f3f4;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar-submenu .nav-link {
|
||||
padding-left: 2.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Arrow indicator for items with submenus */
|
||||
.nav-link.has-submenu::after {
|
||||
content: '▾';
|
||||
float: right;
|
||||
opacity: 0.5;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Main Content with Sidebar */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
transition: all 0.3s;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.main-content.expanded {
|
||||
margin-left: var(--sidebar-collapsed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<nav class="navbar navbar-dark bg-dark navbar-expand-lg">
|
||||
<div class="container-fluid">
|
||||
<a href="/" class="navbar-brand text-decoration-none">🎛️ AccountForger - Admin Panel</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<!-- Navigation removed - access via sidebar -->
|
||||
</ul>
|
||||
<div class="d-flex align-items-center">
|
||||
<div id="session-timer" class="timer-normal me-3">
|
||||
⏱️ <span id="timer-display">5:00</span>
|
||||
</div>
|
||||
<span class="text-white me-3">Angemeldet als: {{ username }}</span>
|
||||
<a href="/profile" class="btn btn-outline-light btn-sm me-2">👤 Profil</a>
|
||||
<a href="/logout" class="btn btn-outline-light btn-sm">Abmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<ul class="sidebar-nav">
|
||||
<li class="nav-item {% if request.endpoint in ['customers_licenses', 'edit_customer', 'create_customer', 'edit_license', 'create_license', 'batch_licenses'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link has-submenu {% if request.endpoint == 'customers_licenses' %}active{% endif %}" href="/customers-licenses">
|
||||
<i class="bi bi-people"></i>
|
||||
<span>Kunden & Lizenzen</span>
|
||||
</a>
|
||||
<ul class="sidebar-submenu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'create_customer' %}active{% endif %}" href="/customer/create">
|
||||
<i class="bi bi-person-plus"></i>
|
||||
<span>Neuer Kunde</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'create_license' %}active{% endif %}" href="/create">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
<span>Neue Lizenz</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'batch_licenses' %}active{% endif %}" href="/batch">
|
||||
<i class="bi bi-stack"></i>
|
||||
<span>Batch-Erstellung</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item {% if request.endpoint in ['resources', 'add_resources'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link has-submenu {% if request.endpoint == 'resources' %}active{% endif %}" href="/resources">
|
||||
<i class="bi bi-box-seam"></i>
|
||||
<span>Resource Pool</span>
|
||||
</a>
|
||||
<ul class="sidebar-submenu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'add_resources' %}active{% endif %}" href="/resources/add">
|
||||
<i class="bi bi-plus-square"></i>
|
||||
<span>Ressourcen hinzufügen</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}" href="/audit">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
<span>Audit-Log</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'sessions' %}active{% endif %}" href="/sessions">
|
||||
<i class="bi bi-people"></i>
|
||||
<span>Sitzungen</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'backups' %}active{% endif %}" href="/backups">
|
||||
<i class="bi bi-cloud-download"></i>
|
||||
<span>Backups</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'blocked_ips' %}active{% endif %}" href="/security/blocked-ips">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
<span>Sicherheit</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-content" id="main-content">
|
||||
<!-- Page Content -->
|
||||
<div class="container-fluid p-4">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Warning Modal -->
|
||||
<div id="session-warning" class="session-warning-modal" style="display: none;">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<strong>⚠️ Session läuft ab!</strong><br>
|
||||
Ihre Session läuft in weniger als 1 Minute ab.<br>
|
||||
<button type="button" class="btn btn-sm btn-success mt-2" onclick="extendSession()">
|
||||
Session verlängern
|
||||
</button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Session-Timer Konfiguration
|
||||
const SESSION_TIMEOUT = 5 * 60; // 5 Minuten in Sekunden
|
||||
let timeRemaining = SESSION_TIMEOUT;
|
||||
let timerInterval;
|
||||
let warningShown = false;
|
||||
let lastActivity = Date.now();
|
||||
|
||||
// Timer Display Update
|
||||
function updateTimerDisplay() {
|
||||
const minutes = Math.floor(timeRemaining / 60);
|
||||
const seconds = timeRemaining % 60;
|
||||
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
document.getElementById('timer-display').textContent = display;
|
||||
|
||||
// Timer-Farbe ändern
|
||||
const timerElement = document.getElementById('session-timer');
|
||||
timerElement.className = timerElement.className.replace(/timer-\w+/, '');
|
||||
|
||||
if (timeRemaining <= 30) {
|
||||
timerElement.classList.add('timer-critical');
|
||||
} else if (timeRemaining <= 60) {
|
||||
timerElement.classList.add('timer-danger');
|
||||
if (!warningShown) {
|
||||
showSessionWarning();
|
||||
warningShown = true;
|
||||
}
|
||||
} else if (timeRemaining <= 120) {
|
||||
timerElement.classList.add('timer-warning');
|
||||
} else {
|
||||
timerElement.classList.add('timer-normal');
|
||||
warningShown = false;
|
||||
hideSessionWarning();
|
||||
}
|
||||
}
|
||||
|
||||
// Session Warning anzeigen
|
||||
function showSessionWarning() {
|
||||
document.getElementById('session-warning').style.display = 'block';
|
||||
}
|
||||
|
||||
// Session Warning verstecken
|
||||
function hideSessionWarning() {
|
||||
document.getElementById('session-warning').style.display = 'none';
|
||||
}
|
||||
|
||||
// Timer zurücksetzen
|
||||
function resetTimer() {
|
||||
timeRemaining = SESSION_TIMEOUT;
|
||||
lastActivity = Date.now();
|
||||
updateTimerDisplay();
|
||||
}
|
||||
|
||||
// Session verlängern
|
||||
function extendSession() {
|
||||
fetch('/heartbeat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
resetTimer();
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Heartbeat error:', error));
|
||||
}
|
||||
|
||||
// Timer Countdown
|
||||
function countdown() {
|
||||
timeRemaining--;
|
||||
updateTimerDisplay();
|
||||
|
||||
if (timeRemaining <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
window.location.href = '/logout';
|
||||
}
|
||||
}
|
||||
|
||||
// Aktivitäts-Tracking
|
||||
function trackActivity() {
|
||||
const now = Date.now();
|
||||
// Nur wenn mehr als 5 Sekunden seit letzter Aktivität
|
||||
if (now - lastActivity > 5000) {
|
||||
lastActivity = now;
|
||||
extendSession(); // resetTimer() wird in extendSession nach erfolgreicher Response aufgerufen
|
||||
}
|
||||
}
|
||||
|
||||
// Event Listeners für Benutzeraktivität
|
||||
document.addEventListener('click', trackActivity);
|
||||
document.addEventListener('keypress', trackActivity);
|
||||
document.addEventListener('mousemove', () => {
|
||||
const now = Date.now();
|
||||
// Mausbewegung nur alle 30 Sekunden tracken
|
||||
if (now - lastActivity > 30000) {
|
||||
trackActivity();
|
||||
}
|
||||
});
|
||||
|
||||
// AJAX Interceptor für automatische Session-Verlängerung
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
// Nur für non-heartbeat requests den Timer verlängern
|
||||
if (!args[0].includes('/heartbeat')) {
|
||||
trackActivity();
|
||||
}
|
||||
return originalFetch.apply(this, args);
|
||||
};
|
||||
|
||||
// Timer starten
|
||||
timerInterval = setInterval(countdown, 1000);
|
||||
updateTimerDisplay();
|
||||
|
||||
// Initial Heartbeat
|
||||
extendSession();
|
||||
|
||||
// Preserve show_test parameter across navigation
|
||||
function preserveShowTestParameter() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const showTest = urlParams.get('show_test');
|
||||
|
||||
if (showTest === 'true') {
|
||||
// Update all internal links to include show_test parameter
|
||||
document.querySelectorAll('a[href^="/"]').forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
// Skip if already has parameters or is just a fragment
|
||||
if (!href.includes('?') && !href.startsWith('#')) {
|
||||
link.setAttribute('href', href + '?show_test=true');
|
||||
} else if (href.includes('?') && !href.includes('show_test=')) {
|
||||
link.setAttribute('href', href + '&show_test=true');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side table sorting
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Preserve show_test parameter on page load
|
||||
preserveShowTestParameter();
|
||||
|
||||
// Initialize all sortable tables
|
||||
const sortableTables = document.querySelectorAll('.sortable-table');
|
||||
|
||||
sortableTables.forEach(table => {
|
||||
const headers = table.querySelectorAll('th.sortable');
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
header.addEventListener('click', function() {
|
||||
sortTable(table, index, header);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function sortTable(table, columnIndex, header) {
|
||||
const tbody = table.querySelector('tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
const isNumeric = header.dataset.type === 'numeric';
|
||||
const isDate = header.dataset.type === 'date';
|
||||
|
||||
// Determine sort direction
|
||||
let direction = 'asc';
|
||||
if (header.classList.contains('asc')) {
|
||||
direction = 'desc';
|
||||
}
|
||||
|
||||
// Remove all sort classes from headers
|
||||
table.querySelectorAll('th.sortable').forEach(th => {
|
||||
th.classList.remove('asc', 'desc');
|
||||
});
|
||||
|
||||
// Add appropriate class to clicked header
|
||||
header.classList.add(direction);
|
||||
|
||||
// Sort rows
|
||||
rows.sort((a, b) => {
|
||||
let aValue = a.cells[columnIndex].textContent.trim();
|
||||
let bValue = b.cells[columnIndex].textContent.trim();
|
||||
|
||||
// Handle different data types
|
||||
if (isNumeric) {
|
||||
aValue = parseFloat(aValue.replace(/[^0-9.-]/g, '')) || 0;
|
||||
bValue = parseFloat(bValue.replace(/[^0-9.-]/g, '')) || 0;
|
||||
} else if (isDate) {
|
||||
// Parse German date format (DD.MM.YYYY HH:MM)
|
||||
aValue = parseGermanDate(aValue);
|
||||
bValue = parseGermanDate(bValue);
|
||||
} else {
|
||||
// Text comparison with locale support for umlauts
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return direction === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Reorder rows in DOM
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
}
|
||||
|
||||
function parseGermanDate(dateStr) {
|
||||
// Handle DD.MM.YYYY HH:MM format
|
||||
const parts = dateStr.match(/(\d{2})\.(\d{2})\.(\d{4})\s*(\d{2}:\d{2})?/);
|
||||
if (parts) {
|
||||
const [_, day, month, year, time] = parts;
|
||||
const timeStr = time || '00:00';
|
||||
return new Date(`${year}-${month}-${day}T${timeStr}`);
|
||||
}
|
||||
return new Date(0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,464 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Batch-Lizenzen erstellen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>🔑 Batch-Lizenzen erstellen</h2>
|
||||
<a href="/customers-licenses" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>ℹ️ Batch-Generierung:</strong> Erstellen Sie mehrere Lizenzen auf einmal für einen Kunden.
|
||||
Die Lizenzen werden automatisch generiert und können anschließend als CSV exportiert werden.
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post" action="/batch" accept-charset="UTF-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label for="customerSelect" class="form-label">Kunde auswählen</label>
|
||||
<select class="form-select" id="customerSelect" name="customer_id" required>
|
||||
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
|
||||
<option value="new">➕ Neuer Kunde</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6" id="customerNameDiv" style="display: none;">
|
||||
<label for="customerName" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="customerName" name="customer_name"
|
||||
placeholder="Firma GmbH" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-md-6" id="emailDiv" style="display: none;">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
placeholder="kontakt@firma.de" accept-charset="UTF-8">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label for="quantity" class="form-label">Anzahl Lizenzen</label>
|
||||
<input type="number" class="form-control" id="quantity" name="quantity"
|
||||
min="1" max="100" value="10" required>
|
||||
<div class="form-text">Max. 100 Lizenzen pro Batch</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||
<select class="form-select" id="licenseType" name="license_type" required>
|
||||
<option value="full">Vollversion</option>
|
||||
<option value="test">Testversion</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="validFrom" class="form-label">Gültig ab</label>
|
||||
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-1">
|
||||
<label for="duration" class="form-label">Laufzeit</label>
|
||||
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="durationType" class="form-label">Einheit</label>
|
||||
<select class="form-select" id="durationType" name="duration_type" required>
|
||||
<option value="days">Tage</option>
|
||||
<option value="months">Monate</option>
|
||||
<option value="years" selected>Jahre</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="validUntil" class="form-label">Gültig bis</label>
|
||||
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Pool Allocation -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-server"></i> Ressourcen-Zuweisung pro Lizenz
|
||||
<small class="text-muted float-end" id="resourceStatus"></small>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="domainCount" class="form-label">
|
||||
<i class="fas fa-globe"></i> Domains
|
||||
</label>
|
||||
<select class="form-select" id="domainCount" name="domain_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
|
||||
| Benötigt: <span id="domainsNeeded" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="ipv4Count" class="form-label">
|
||||
<i class="fas fa-network-wired"></i> IPv4-Adressen
|
||||
</label>
|
||||
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
|
||||
| Benötigt: <span id="ipv4Needed" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="phoneCount" class="form-label">
|
||||
<i class="fas fa-phone"></i> Telefonnummern
|
||||
</label>
|
||||
<select class="form-select" id="phoneCount" name="phone_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
|
||||
| Benötigt: <span id="phoneNeeded" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-3 mb-0" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Batch-Ressourcen:</strong> Jede Lizenz erhält die angegebene Anzahl an Ressourcen.
|
||||
Bei 10 Lizenzen mit je 2 Domains werden insgesamt 20 Domains benötigt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Limit -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-laptop"></i> Gerätelimit pro Lizenz
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label for="deviceLimit" class="form-label">
|
||||
Maximale Anzahl Geräte pro Lizenz
|
||||
</label>
|
||||
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Jede generierte Lizenz kann auf maximal dieser Anzahl von Geräten gleichzeitig aktiviert werden.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Data Checkbox -->
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="isTest" name="is_test">
|
||||
<label class="form-check-label" for="isTest">
|
||||
<i class="fas fa-flask"></i> Als Testdaten markieren
|
||||
<small class="text-muted">(wird von der Software ignoriert)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
🔑 Batch generieren
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="previewKeys()">
|
||||
👁️ Vorschau
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Vorschau Modal -->
|
||||
<div class="modal fade" id="previewModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Vorschau der generierten Keys</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Es werden <strong id="previewQuantity">10</strong> Lizenzen im folgenden Format generiert:</p>
|
||||
<div class="bg-light p-3 rounded font-monospace" id="previewFormat">
|
||||
AF-F-YYYYMM-XXXX-YYYY-ZZZZ
|
||||
</div>
|
||||
<p class="mt-3 mb-0">Die Keys werden automatisch eindeutig generiert und in der Datenbank gespeichert.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Funktion zur Berechnung des Ablaufdatums
|
||||
function calculateValidUntil() {
|
||||
const validFrom = document.getElementById('validFrom').value;
|
||||
const duration = parseInt(document.getElementById('duration').value) || 1;
|
||||
const durationType = document.getElementById('durationType').value;
|
||||
|
||||
if (!validFrom) return;
|
||||
|
||||
const startDate = new Date(validFrom);
|
||||
let endDate = new Date(startDate);
|
||||
|
||||
switch(durationType) {
|
||||
case 'days':
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
break;
|
||||
case 'months':
|
||||
endDate.setMonth(endDate.getMonth() + duration);
|
||||
break;
|
||||
case 'years':
|
||||
endDate.setFullYear(endDate.getFullYear() + duration);
|
||||
break;
|
||||
}
|
||||
|
||||
// Ein Tag abziehen, da der Starttag mitgezählt wird
|
||||
endDate.setDate(endDate.getDate() - 1);
|
||||
|
||||
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Event Listener für Änderungen
|
||||
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
|
||||
document.getElementById('duration').addEventListener('input', calculateValidUntil);
|
||||
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
|
||||
|
||||
// Setze heutiges Datum als Standard
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('validFrom').value = today;
|
||||
|
||||
// Berechne initiales Ablaufdatum
|
||||
calculateValidUntil();
|
||||
|
||||
// Initialisiere Select2 für Kundenauswahl
|
||||
$('#customerSelect').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
|
||||
allowClear: true,
|
||||
ajax: {
|
||||
url: '/api/customers',
|
||||
dataType: 'json',
|
||||
delay: 250,
|
||||
data: function (params) {
|
||||
return {
|
||||
q: params.term,
|
||||
page: params.page || 1
|
||||
};
|
||||
},
|
||||
processResults: function (data, params) {
|
||||
params.page = params.page || 1;
|
||||
|
||||
// "Neuer Kunde" Option immer oben anzeigen
|
||||
const results = data.results || [];
|
||||
if (params.page === 1) {
|
||||
results.unshift({
|
||||
id: 'new',
|
||||
text: '➕ Neuer Kunde',
|
||||
isNew: true
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
results: results,
|
||||
pagination: data.pagination
|
||||
};
|
||||
},
|
||||
cache: true
|
||||
},
|
||||
minimumInputLength: 0,
|
||||
language: {
|
||||
inputTooShort: function() { return ''; },
|
||||
noResults: function() { return 'Keine Kunden gefunden'; },
|
||||
searching: function() { return 'Suche...'; },
|
||||
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
|
||||
}
|
||||
});
|
||||
|
||||
// Event Handler für Kundenauswahl
|
||||
$('#customerSelect').on('select2:select', function (e) {
|
||||
const selectedValue = e.params.data.id;
|
||||
const nameDiv = document.getElementById('customerNameDiv');
|
||||
const emailDiv = document.getElementById('emailDiv');
|
||||
const nameInput = document.getElementById('customerName');
|
||||
const emailInput = document.getElementById('email');
|
||||
|
||||
if (selectedValue === 'new') {
|
||||
// Zeige Eingabefelder für neuen Kunden
|
||||
nameDiv.style.display = 'block';
|
||||
emailDiv.style.display = 'block';
|
||||
nameInput.required = true;
|
||||
emailInput.required = true;
|
||||
} else {
|
||||
// Verstecke Eingabefelder bei bestehendem Kunden
|
||||
nameDiv.style.display = 'none';
|
||||
emailDiv.style.display = 'none';
|
||||
nameInput.required = false;
|
||||
emailInput.required = false;
|
||||
nameInput.value = '';
|
||||
emailInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Clear handler
|
||||
$('#customerSelect').on('select2:clear', function (e) {
|
||||
document.getElementById('customerNameDiv').style.display = 'none';
|
||||
document.getElementById('emailDiv').style.display = 'none';
|
||||
document.getElementById('customerName').required = false;
|
||||
document.getElementById('email').required = false;
|
||||
});
|
||||
|
||||
// Resource Availability Check
|
||||
checkResourceAvailability();
|
||||
|
||||
// Event Listener für Resource Count und Quantity Änderungen
|
||||
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('quantity').addEventListener('input', checkResourceAvailability);
|
||||
});
|
||||
|
||||
// Vorschau-Funktion
|
||||
function previewKeys() {
|
||||
const quantity = document.getElementById('quantity').value;
|
||||
const type = document.getElementById('licenseType').value;
|
||||
const typeChar = type === 'full' ? 'F' : 'T';
|
||||
const date = new Date();
|
||||
const dateStr = date.getFullYear() + ('0' + (date.getMonth() + 1)).slice(-2);
|
||||
|
||||
document.getElementById('previewQuantity').textContent = quantity;
|
||||
document.getElementById('previewFormat').textContent = `AF-${typeChar}-${dateStr}-XXXX-YYYY-ZZZZ`;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Validierung
|
||||
document.getElementById('quantity').addEventListener('input', function(e) {
|
||||
if (e.target.value > 100) {
|
||||
e.target.value = 100;
|
||||
}
|
||||
if (e.target.value < 1) {
|
||||
e.target.value = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit für Batch
|
||||
function checkResourceAvailability() {
|
||||
const quantity = parseInt(document.getElementById('quantity').value) || 1;
|
||||
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
|
||||
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
|
||||
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
|
||||
|
||||
// Berechne Gesamtbedarf
|
||||
const totalDomains = domainCount * quantity;
|
||||
const totalIpv4 = ipv4Count * quantity;
|
||||
const totalPhones = phoneCount * quantity;
|
||||
|
||||
// Update "Benötigt" Anzeigen
|
||||
document.getElementById('domainsNeeded').textContent = totalDomains;
|
||||
document.getElementById('ipv4Needed').textContent = totalIpv4;
|
||||
document.getElementById('phoneNeeded').textContent = totalPhones;
|
||||
|
||||
// API-Call zur Verfügbarkeitsprüfung
|
||||
fetch(`/api/resources/check-availability?domain=${totalDomains}&ipv4=${totalIpv4}&phone=${totalPhones}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update der Verfügbarkeitsanzeigen
|
||||
updateAvailabilityDisplay('domainsAvailable', data.domain_available, totalDomains);
|
||||
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, totalIpv4);
|
||||
updateAvailabilityDisplay('phoneAvailable', data.phone_available, totalPhones);
|
||||
|
||||
// Gesamtstatus aktualisieren
|
||||
updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Hilfsfunktion zur Anzeige der Verfügbarkeit
|
||||
function updateAvailabilityDisplay(elementId, available, requested) {
|
||||
const element = document.getElementById(elementId);
|
||||
element.textContent = available;
|
||||
|
||||
const neededElement = element.parentElement.querySelector('.fw-bold:last-child');
|
||||
|
||||
if (requested > 0 && available < requested) {
|
||||
element.classList.remove('text-success');
|
||||
element.classList.add('text-danger');
|
||||
neededElement.classList.add('text-danger');
|
||||
neededElement.classList.remove('text-success');
|
||||
} else if (available < 50) {
|
||||
element.classList.remove('text-success', 'text-danger');
|
||||
element.classList.add('text-warning');
|
||||
} else {
|
||||
element.classList.remove('text-danger', 'text-warning');
|
||||
element.classList.add('text-success');
|
||||
neededElement.classList.remove('text-danger');
|
||||
neededElement.classList.add('text-success');
|
||||
}
|
||||
}
|
||||
|
||||
// Gesamtstatus der Ressourcen-Verfügbarkeit für Batch
|
||||
function updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity) {
|
||||
const statusElement = document.getElementById('resourceStatus');
|
||||
let hasIssue = false;
|
||||
let message = '';
|
||||
|
||||
if (totalDomains > 0 && data.domain_available < totalDomains) {
|
||||
hasIssue = true;
|
||||
message = `⚠️ Nicht genügend Domains (${data.domain_available}/${totalDomains})`;
|
||||
} else if (totalIpv4 > 0 && data.ipv4_available < totalIpv4) {
|
||||
hasIssue = true;
|
||||
message = `⚠️ Nicht genügend IPv4-Adressen (${data.ipv4_available}/${totalIpv4})`;
|
||||
} else if (totalPhones > 0 && data.phone_available < totalPhones) {
|
||||
hasIssue = true;
|
||||
message = `⚠️ Nicht genügend Telefonnummern (${data.phone_available}/${totalPhones})`;
|
||||
} else {
|
||||
message = `✅ Ressourcen für ${quantity} Lizenzen verfügbar`;
|
||||
}
|
||||
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
|
||||
|
||||
// Disable submit button if not enough resources
|
||||
const submitButton = document.querySelector('button[type="submit"]');
|
||||
submitButton.disabled = hasIssue;
|
||||
if (hasIssue) {
|
||||
submitButton.classList.add('btn-secondary');
|
||||
submitButton.classList.remove('btn-primary');
|
||||
} else {
|
||||
submitButton.classList.add('btn-primary');
|
||||
submitButton.classList.remove('btn-secondary');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Batch-Lizenzen generiert{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>✅ Batch-Lizenzen erfolgreich generiert</h2>
|
||||
<div>
|
||||
<a href="/batch" class="btn btn-primary">🔑 Weitere Batch erstellen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success">
|
||||
<h5 class="alert-heading">🎉 {{ licenses|length }} Lizenzen wurden erfolgreich generiert!</h5>
|
||||
<p class="mb-0">Die Lizenzen wurden in der Datenbank gespeichert und dem Kunden zugeordnet.</p>
|
||||
</div>
|
||||
|
||||
<!-- Kunden-Info -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📋 Kundeninformationen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Kunde:</strong> {{ customer }}</p>
|
||||
<p><strong>E-Mail:</strong> {{ email or 'Nicht angegeben' }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Gültig von:</strong> {{ valid_from }}</p>
|
||||
<p><strong>Gültig bis:</strong> {{ valid_until }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export-Optionen -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📥 Export-Optionen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Exportieren Sie die generierten Lizenzen für den Kunden:</p>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/batch/export" class="btn btn-success">
|
||||
📄 Als CSV exportieren
|
||||
</a>
|
||||
<button class="btn btn-outline-primary" onclick="copyAllKeys()">
|
||||
📋 Alle Keys kopieren
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="window.print()">
|
||||
🖨️ Drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generierte Lizenzen -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">🔑 Generierte Lizenzen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">#</th>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th width="120">Typ</th>
|
||||
<th width="100">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in licenses %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td class="font-monospace">{{ license.key }}</td>
|
||||
<td>
|
||||
{% if license.type == 'full' %}
|
||||
<span class="badge bg-success">Vollversion</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">Testversion</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="copyKey('{{ license.key }}')"
|
||||
title="Kopieren">
|
||||
📋
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hinweis -->
|
||||
<div class="alert alert-info mt-4">
|
||||
<strong>💡 Tipp:</strong> Die generierten Lizenzen sind sofort aktiv und können verwendet werden.
|
||||
Sie finden alle Lizenzen auch in der <a href="/licenses?search={{ customer }}">Lizenzübersicht</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Textarea für Kopieren (unsichtbar) -->
|
||||
<textarea id="copyArea" style="position: absolute; left: -9999px;"></textarea>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
.btn, .alert-info, .card-header h5 { display: none !important; }
|
||||
.card { border: 1px solid #000 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Einzelnen Key kopieren
|
||||
function copyKey(key) {
|
||||
navigator.clipboard.writeText(key).then(function() {
|
||||
// Visuelles Feedback
|
||||
event.target.textContent = '✓';
|
||||
setTimeout(() => {
|
||||
event.target.textContent = '📋';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Alle Keys kopieren
|
||||
function copyAllKeys() {
|
||||
const keys = [];
|
||||
{% for license in licenses %}
|
||||
keys.push('{{ license.key }}');
|
||||
{% endfor %}
|
||||
|
||||
const text = keys.join('\n');
|
||||
const textarea = document.getElementById('copyArea');
|
||||
textarea.value = text;
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
// Visuelles Feedback
|
||||
event.target.textContent = '✓ Kopiert!';
|
||||
setTimeout(() => {
|
||||
event.target.textContent = '📋 Alle Keys kopieren';
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
// Fallback für moderne Browser
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Gesperrte IPs{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>🔒 Gesperrte IPs</h1>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">IP-Sperrverwaltung</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if blocked_ips %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">IP-Adresse</th>
|
||||
<th class="sortable" data-type="numeric">Versuche</th>
|
||||
<th class="sortable" data-type="date">Erster Versuch</th>
|
||||
<th class="sortable" data-type="date">Letzter Versuch</th>
|
||||
<th class="sortable" data-type="date">Gesperrt bis</th>
|
||||
<th class="sortable">Letzter User</th>
|
||||
<th class="sortable">Letzte Meldung</th>
|
||||
<th class="sortable">Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ip in blocked_ips %}
|
||||
<tr class="{% if ip.is_active %}table-danger{% else %}table-secondary{% endif %}">
|
||||
<td><code>{{ ip.ip_address }}</code></td>
|
||||
<td><span class="badge bg-danger">{{ ip.attempt_count }}</span></td>
|
||||
<td>{{ ip.first_attempt }}</td>
|
||||
<td>{{ ip.last_attempt }}</td>
|
||||
<td>{{ ip.blocked_until }}</td>
|
||||
<td>{{ ip.last_username or '-' }}</td>
|
||||
<td><strong>{{ ip.last_error or '-' }}</strong></td>
|
||||
<td>
|
||||
{% if ip.is_active %}
|
||||
<span class="badge bg-danger">GESPERRT</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">ABGELAUFEN</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if ip.is_active %}
|
||||
<form method="post" action="/security/unblock-ip" class="d-inline">
|
||||
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
|
||||
<button type="submit" class="btn btn-success"
|
||||
onclick="return confirm('IP {{ ip.ip_address }} wirklich entsperren?')">
|
||||
🔓 Entsperren
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/security/clear-attempts" class="d-inline ms-1">
|
||||
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
|
||||
<button type="submit" class="btn btn-warning"
|
||||
onclick="return confirm('Alle Versuche für IP {{ ip.ip_address }} zurücksetzen?')">
|
||||
🗑️ Reset
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<strong>Keine gesperrten IPs vorhanden.</strong>
|
||||
Das System läuft ohne Sicherheitsvorfälle.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">ℹ️ Informationen</h5>
|
||||
<ul class="mb-0">
|
||||
<li>IPs werden nach <strong>{{ 5 }} fehlgeschlagenen Login-Versuchen</strong> für <strong>24 Stunden</strong> gesperrt.</li>
|
||||
<li>Nach <strong>2 Versuchen</strong> wird ein CAPTCHA angezeigt.</li>
|
||||
<li>Bei <strong>5 Versuchen</strong> wird eine E-Mail-Benachrichtigung gesendet (wenn aktiviert).</li>
|
||||
<li>Gesperrte IPs können manuell entsperrt werden.</li>
|
||||
<li>Die Fehlermeldungen werden zufällig ausgewählt für zusätzliche Verwirrung.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Neuer Kunde{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>👤 Neuer Kunde anlegen</h2>
|
||||
<a href="/customers-licenses" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="/customer/create" accept-charset="UTF-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="name" class="form-label">Kundenname <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
placeholder="Firmenname oder Vor- und Nachname"
|
||||
accept-charset="UTF-8" required autofocus>
|
||||
<div class="form-text">Der Name des Kunden oder der Firma</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="email" class="form-label">E-Mail <span class="text-danger">*</span></label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
placeholder="kunde@beispiel.de"
|
||||
accept-charset="UTF-8" required>
|
||||
<div class="form-text">Kontakt-E-Mail-Adresse des Kunden</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="isTest" name="is_test">
|
||||
<label class="form-check-label" for="isTest">
|
||||
<i class="fas fa-flask"></i> Als Testdaten markieren
|
||||
<small class="text-muted">(Kunde wird von der Software ignoriert)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>Hinweis:</strong> Nach dem Anlegen des Kunden können Sie direkt Lizenzen für diesen Kunden erstellen.
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">💾 Kunde anlegen</button>
|
||||
<a href="/customers-licenses" class="btn btn-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
|
||||
{% for category, message in messages %}
|
||||
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
@ -0,0 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Kundenverwaltung{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('customers', sort=field, order='desc' if current_order == 'asc' else 'asc', search=search, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('customers', sort=field, order='asc', search=search, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="mb-4">
|
||||
<h2>Kundenverwaltung</h2>
|
||||
</div>
|
||||
|
||||
<!-- Suchformular -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" action="/customers" id="customerSearchForm" class="row g-3 align-items-end">
|
||||
<div class="col-md-10">
|
||||
<label for="search" class="form-label">🔍 Suchen</label>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
placeholder="Kundenname oder E-Mail..."
|
||||
value="{{ search }}" autofocus>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<a href="/customers" class="btn btn-outline-secondary w-100">Zurücksetzen</a>
|
||||
</div>
|
||||
</form>
|
||||
{% if search %}
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">Suchergebnisse für: <strong>{{ search }}</strong></small>
|
||||
<a href="/customers" class="btn btn-sm btn-outline-secondary ms-2">✖ Suche zurücksetzen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ sortable_header('ID', 'id', sort, order) }}
|
||||
{{ sortable_header('Name', 'name', sort, order) }}
|
||||
{{ sortable_header('E-Mail', 'email', sort, order) }}
|
||||
{{ sortable_header('Erstellt am', 'created_at', sort, order) }}
|
||||
{{ sortable_header('Lizenzen (Aktiv/Gesamt)', 'licenses', sort, order) }}
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in customers %}
|
||||
<tr>
|
||||
<td>{{ customer[0] }}</td>
|
||||
<td>
|
||||
{{ customer[1] }}
|
||||
{% if customer[4] %}
|
||||
<span class="badge bg-secondary ms-1" title="Testdaten">🧪</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ customer[2] or '-' }}</td>
|
||||
<td>{{ customer[3].strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ customer[6] }}/{{ customer[5] }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="/customer/edit/{{ customer[0] }}" class="btn btn-outline-primary">✏️ Bearbeiten</a>
|
||||
{% if customer[5] == 0 %}
|
||||
<form method="post" action="/customer/delete/{{ customer[0] }}" style="display: inline;" onsubmit="return confirm('Kunde wirklich löschen?');">
|
||||
<button type="submit" class="btn btn-outline-danger">🗑️ Löschen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-danger" disabled title="Kunde hat Lizenzen">🗑️ Löschen</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="text-center py-5">
|
||||
{% if search %}
|
||||
<p class="text-muted">Keine Kunden gefunden für: <strong>{{ search }}</strong></p>
|
||||
<a href="/customers" class="btn btn-secondary">Alle Kunden anzeigen</a>
|
||||
{% else %}
|
||||
<p class="text-muted">Noch keine Kunden vorhanden.</p>
|
||||
<a href="/create" class="btn btn-primary">Erste Lizenz erstellen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav aria-label="Seitennavigation" class="mt-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
<!-- Erste Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers', page=1, search=search, sort=sort, order=order) }}">Erste</a>
|
||||
</li>
|
||||
|
||||
<!-- Vorherige Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers', page=page-1, search=search, sort=sort, order=order) }}">←</a>
|
||||
</li>
|
||||
|
||||
<!-- Seitenzahlen -->
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p >= page - 2 and p <= page + 2 %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers', page=p, search=search, sort=sort, order=order) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Nächste Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers', page=page+1, search=search, sort=sort, order=order) }}">→</a>
|
||||
</li>
|
||||
|
||||
<!-- Letzte Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers', page=total_pages, search=search, sort=sort, order=order) }}">Letzte</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted">
|
||||
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Kunden
|
||||
</p>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Live Search für Kunden
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchForm = document.getElementById('customerSearchForm');
|
||||
const searchInput = document.getElementById('search');
|
||||
|
||||
// Debounce timer für Suchfeld
|
||||
let searchTimeout;
|
||||
|
||||
// Live-Suche mit 300ms Verzögerung
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchForm.submit();
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@ -0,0 +1,488 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Kunden & Lizenzen - AccountForger Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Kunden & Lizenzen</h2>
|
||||
<div>
|
||||
<a href="/create" class="btn btn-success">
|
||||
<i class="bi bi-plus-circle"></i> Neue Lizenz
|
||||
</a>
|
||||
<a href="/batch" class="btn btn-primary">
|
||||
<i class="bi bi-stack"></i> Batch-Lizenzen
|
||||
</a>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-info dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-download"></i> Export
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="/export/licenses?format=excel"><i class="bi bi-file-earmark-excel"></i> Excel</a></li>
|
||||
<li><a class="dropdown-item" href="/export/licenses?format=csv"><i class="bi bi-file-earmark-csv"></i> CSV</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Kundenliste (Links) -->
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-people"></i> Kunden
|
||||
<span class="badge bg-secondary float-end">{{ customers|length if customers else 0 }}</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<!-- Suchfeld -->
|
||||
<div class="p-3 border-bottom">
|
||||
<input type="text" class="form-control mb-2" id="customerSearch"
|
||||
placeholder="Kunde suchen..." autocomplete="off">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="showTestCustomers"
|
||||
{% if request.args.get('show_test', 'false').lower() == 'true' %}checked{% endif %}
|
||||
onchange="toggleTestCustomers()">
|
||||
<label class="form-check-label" for="showTestCustomers">
|
||||
<small class="text-muted">Testkunden anzeigen</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kundenliste -->
|
||||
<div class="customer-list" style="max-height: 600px; overflow-y: auto;">
|
||||
{% if customers %}
|
||||
{% for customer in customers %}
|
||||
<div class="customer-item p-3 border-bottom {% if customer[0] == selected_customer_id %}active{% endif %}"
|
||||
data-customer-id="{{ customer[0] }}"
|
||||
data-customer-name="{{ customer[1]|lower }}"
|
||||
data-customer-email="{{ customer[2]|lower }}"
|
||||
onclick="loadCustomerLicenses({{ customer[0] }})"
|
||||
style="cursor: pointer;">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">{{ customer[1] }}</h6>
|
||||
<small class="text-muted">{{ customer[2] }}</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge bg-primary">{{ customer[4] }}</span>
|
||||
{% if customer[5] > 0 %}
|
||||
<span class="badge bg-success">{{ customer[5] }}</span>
|
||||
{% endif %}
|
||||
{% if customer[6] > 0 %}
|
||||
<span class="badge bg-danger">{{ customer[6] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="p-4 text-center text-muted">
|
||||
<i class="bi bi-inbox" style="font-size: 3rem; opacity: 0.3;"></i>
|
||||
<p class="mt-3 mb-2">Keine Kunden vorhanden</p>
|
||||
<small class="d-block mb-3">Erstellen Sie eine neue Lizenz, um automatisch einen Kunden anzulegen.</small>
|
||||
<a href="/create" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Neue Lizenz erstellen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lizenzdetails (Rechts) -->
|
||||
<div class="col-md-8 col-lg-9">
|
||||
<div class="card">
|
||||
<div class="card-header bg-light">
|
||||
{% if selected_customer %}
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">{{ selected_customer[1] }}</h5>
|
||||
<small class="text-muted">{{ selected_customer[2] }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/customer/edit/{{ selected_customer[0] }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i> Bearbeiten
|
||||
</a>
|
||||
<button class="btn btn-sm btn-success" onclick="showNewLicenseModal({{ selected_customer[0] }})">
|
||||
<i class="bi bi-plus"></i> Neue Lizenz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h5 class="mb-0">Wählen Sie einen Kunden aus</h5>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="licenseContainer">
|
||||
{% if selected_customer %}
|
||||
{% if licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Typ</th>
|
||||
<th>Gültig von</th>
|
||||
<th>Gültig bis</th>
|
||||
<th>Status</th>
|
||||
<th>Ressourcen</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in licenses %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ license[1] }}</code>
|
||||
<button class="btn btn-sm btn-link" onclick="copyToClipboard('{{ license[1] }}')">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {% if license[2] == 'full' %}bg-primary{% else %}bg-secondary{% endif %}">
|
||||
{{ license[2]|upper }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ license[3].strftime('%d.%m.%Y') if license[3] else '-' }}</td>
|
||||
<td>{{ license[4].strftime('%d.%m.%Y') if license[4] else '-' }}</td>
|
||||
<td>
|
||||
<span class="badge
|
||||
{% if license[6] == 'aktiv' %}bg-success
|
||||
{% elif license[6] == 'läuft bald ab' %}bg-warning
|
||||
{% elif license[6] == 'abgelaufen' %}bg-danger
|
||||
{% else %}bg-secondary{% endif %}">
|
||||
{{ license[6] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if license[7] > 0 %}🌐 {{ license[7] }}{% endif %}
|
||||
{% if license[8] > 0 %}📡 {{ license[8] }}{% endif %}
|
||||
{% if license[9] > 0 %}📱 {{ license[9] }}{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary" onclick="toggleLicenseStatus({{ license[0] }}, {{ license[5] }})">
|
||||
<i class="bi bi-power"></i>
|
||||
</button>
|
||||
<a href="/license/edit/{{ license[0] }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
||||
<p class="text-muted mt-3">Keine Lizenzen für diesen Kunden vorhanden</p>
|
||||
<button class="btn btn-success" onclick="showNewLicenseModal({{ selected_customer[0] }})">
|
||||
<i class="bi bi-plus"></i> Erste Lizenz erstellen
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-arrow-left text-muted" style="font-size: 3rem;"></i>
|
||||
<p class="text-muted mt-3">Wählen Sie einen Kunden aus der Liste aus</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal für neue Lizenz -->
|
||||
<div class="modal fade" id="newLicenseModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Neue Lizenz erstellen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Möchten Sie eine neue Lizenz für <strong id="modalCustomerName"></strong> erstellen?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-success" id="createLicenseBtn">Zur Lizenzerstellung</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.customer-item {
|
||||
transition: all 0.2s ease;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.customer-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-left-color: #dee2e6;
|
||||
}
|
||||
.customer-item.active {
|
||||
background-color: #e7f3ff;
|
||||
border-left-color: #0d6efd;
|
||||
}
|
||||
.card {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
|
||||
}
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Globale Variablen und Funktionen
|
||||
let currentCustomerId = {{ selected_customer_id or 'null' }};
|
||||
|
||||
// Lade Lizenzen eines Kunden
|
||||
function loadCustomerLicenses(customerId) {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
const customerItems = document.querySelectorAll('.customer-item');
|
||||
|
||||
customerItems.forEach(item => {
|
||||
const name = item.dataset.customerName;
|
||||
const email = item.dataset.customerEmail;
|
||||
if (name.includes(searchTerm) || email.includes(searchTerm)) {
|
||||
item.style.display = 'block';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
// Aktiven Status aktualisieren
|
||||
document.querySelectorAll('.customer-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-customer-id="${customerId}"]`).classList.add('active');
|
||||
|
||||
// URL aktualisieren ohne Reload (behalte show_test Parameter)
|
||||
const currentUrl = new URL(window.location);
|
||||
currentUrl.searchParams.set('customer_id', customerId);
|
||||
window.history.pushState({}, '', currentUrl.toString());
|
||||
|
||||
// Lade Lizenzen via AJAX
|
||||
const container = document.getElementById('licenseContainer');
|
||||
const cardHeader = document.querySelector('.card-header.bg-light');
|
||||
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary" role="status"></div></div>';
|
||||
|
||||
fetch(`/api/customer/${customerId}/licenses`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Update header with customer info
|
||||
const customerItem = document.querySelector(`[data-customer-id="${customerId}"]`);
|
||||
const customerName = customerItem.querySelector('h6').textContent;
|
||||
const customerEmail = customerItem.querySelector('small').textContent;
|
||||
|
||||
cardHeader.innerHTML = `
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">${customerName}</h5>
|
||||
<small class="text-muted">${customerEmail}</small>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/customer/edit/${customerId}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i> Bearbeiten
|
||||
</a>
|
||||
<button class="btn btn-sm btn-success" onclick="showNewLicenseModal(${customerId})">
|
||||
<i class="bi bi-plus"></i> Neue Lizenz
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
updateLicenseView(customerId, data.licenses);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
container.innerHTML = '<div class="alert alert-danger">Fehler beim Laden der Lizenzen</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Aktualisiere Lizenzansicht
|
||||
function updateLicenseView(customerId, licenses) {
|
||||
currentCustomerId = customerId;
|
||||
const container = document.getElementById('licenseContainer');
|
||||
|
||||
if (licenses.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
||||
<p class="text-muted mt-3">Keine Lizenzen für diesen Kunden vorhanden</p>
|
||||
<button class="btn btn-success" onclick="showNewLicenseModal(${customerId})">
|
||||
<i class="bi bi-plus"></i> Erste Lizenz erstellen
|
||||
</button>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Typ</th>
|
||||
<th>Gültig von</th>
|
||||
<th>Gültig bis</th>
|
||||
<th>Status</th>
|
||||
<th>Ressourcen</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
licenses.forEach(license => {
|
||||
const statusClass = license.status === 'aktiv' ? 'bg-success' :
|
||||
license.status === 'läuft bald ab' ? 'bg-warning' :
|
||||
license.status === 'abgelaufen' ? 'bg-danger' : 'bg-secondary';
|
||||
|
||||
const typeClass = license.license_type === 'full' ? 'bg-primary' : 'bg-secondary';
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>
|
||||
<code>${license.license_key}</code>
|
||||
<button class="btn btn-sm btn-link" onclick="copyToClipboard('${license.license_key}')">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td><span class="badge ${typeClass}">${license.license_type.toUpperCase()}</span></td>
|
||||
<td>${license.valid_from || '-'}</td>
|
||||
<td>${license.valid_until || '-'}</td>
|
||||
<td><span class="badge ${statusClass}">${license.status}</span></td>
|
||||
<td>
|
||||
${license.domain_count > 0 ? '🌐 ' + license.domain_count : ''}
|
||||
${license.ipv4_count > 0 ? '📡 ' + license.ipv4_count : ''}
|
||||
${license.phone_count > 0 ? '📱 ' + license.phone_count : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary" onclick="toggleLicenseStatus(${license.id}, ${license.is_active})">
|
||||
<i class="bi bi-power"></i>
|
||||
</button>
|
||||
<a href="/license/edit/${license.id}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Toggle Lizenzstatus
|
||||
function toggleLicenseStatus(licenseId, currentStatus) {
|
||||
const newStatus = !currentStatus;
|
||||
|
||||
fetch(`/api/license/${licenseId}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_active: newStatus })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Reload current customer licenses
|
||||
if (currentCustomerId) {
|
||||
loadCustomerLicenses(currentCustomerId);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
}
|
||||
|
||||
// Zeige Modal für neue Lizenz
|
||||
function showNewLicenseModal(customerId) {
|
||||
const customerItem = document.querySelector(`[data-customer-id="${customerId}"]`);
|
||||
if (!customerItem) {
|
||||
console.error('Kunde nicht gefunden:', customerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const customerName = customerItem.querySelector('h6').textContent;
|
||||
|
||||
document.getElementById('modalCustomerName').textContent = customerName;
|
||||
document.getElementById('createLicenseBtn').onclick = function() {
|
||||
window.location.href = `/create?customer_id=${customerId}`;
|
||||
};
|
||||
|
||||
// Check if bootstrap is loaded
|
||||
if (typeof bootstrap === 'undefined') {
|
||||
console.error('Bootstrap nicht geladen!');
|
||||
// Fallback: Direkt zur Erstellung
|
||||
if (confirm(`Neue Lizenz für ${customerName} erstellen?`)) {
|
||||
window.location.href = `/create?customer_id=${customerId}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const modalElement = document.getElementById('newLicenseModal');
|
||||
const modal = new bootstrap.Modal(modalElement);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Copy to clipboard
|
||||
function copyToClipboard(text) {
|
||||
const button = event.currentTarget;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// Zeige kurz Feedback
|
||||
button.innerHTML = '<i class="bi bi-check"></i>';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = '<i class="bi bi-clipboard"></i>';
|
||||
}, 1000);
|
||||
}).catch(err => {
|
||||
console.error('Fehler beim Kopieren:', err);
|
||||
alert('Konnte nicht in die Zwischenablage kopieren');
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle Testkunden
|
||||
function toggleTestCustomers() {
|
||||
const showTest = document.getElementById('showTestCustomers').checked;
|
||||
const currentUrl = new URL(window.location);
|
||||
currentUrl.searchParams.set('show_test', showTest);
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.target.id === 'customerSearch') return; // Nicht bei Suche
|
||||
|
||||
const activeItem = document.querySelector('.customer-item.active');
|
||||
if (!activeItem) return;
|
||||
|
||||
let targetItem = null;
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
targetItem = activeItem.previousElementSibling;
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
targetItem = activeItem.nextElementSibling;
|
||||
}
|
||||
|
||||
if (targetItem && targetItem.classList.contains('customer-item')) {
|
||||
e.preventDefault();
|
||||
const customerId = parseInt(targetItem.dataset.customerId);
|
||||
loadCustomerLicenses(customerId);
|
||||
}
|
||||
});
|
||||
|
||||
}); // Ende DOMContentLoaded
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,433 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.stat-card {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.stat-card .card-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.stat-card .card-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.stat-card .card-label {
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
a:hover .stat-card {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.text-decoration-none:hover {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* Session pulse effect */
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.8; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.pulse-effect {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* Progress bar styles */
|
||||
.progress-custom {
|
||||
height: 8px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar-custom {
|
||||
background-color: #28a745;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<h1 class="mb-4">Dashboard</h1>
|
||||
|
||||
<!-- Statistik-Karten -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<a href="/customers-licenses" class="text-decoration-none">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon text-primary">👥</div>
|
||||
<div class="card-value text-primary">{{ stats.total_customers }}</div>
|
||||
<div class="card-label text-muted">Kunden Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<a href="/customers-licenses" class="text-decoration-none">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon text-info">📋</div>
|
||||
<div class="card-value text-info">{{ stats.total_licenses }}</div>
|
||||
<div class="card-label text-muted">Lizenzen Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<a href="/sessions" class="text-decoration-none">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon text-success{% if stats.active_sessions > 0 %} pulse-effect{% endif %}">🟢</div>
|
||||
<div class="card-value text-success">{{ stats.active_sessions }}</div>
|
||||
<div class="card-label text-muted">Aktive Sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lizenztypen -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Lizenztypen</h5>
|
||||
<div class="row">
|
||||
<div class="col-6 text-center">
|
||||
<h3 class="text-success">{{ stats.full_licenses }}</h3>
|
||||
<p class="text-muted">Vollversionen</p>
|
||||
</div>
|
||||
<div class="col-6 text-center">
|
||||
<h3 class="text-warning">{{ stats.test_licenses }}</h3>
|
||||
<p class="text-muted">Testversionen</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if stats.test_data_count > 0 or stats.test_customers_count > 0 or stats.test_resources_count > 0 %}
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<i class="fas fa-flask"></i> Testdaten:
|
||||
{{ stats.test_data_count }} Lizenzen,
|
||||
{{ stats.test_customers_count }} Kunden,
|
||||
{{ stats.test_resources_count }} Ressourcen
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Lizenzstatus</h5>
|
||||
<div class="row">
|
||||
<div class="col-4 text-center">
|
||||
<h3 class="text-success">{{ stats.active_licenses }}</h3>
|
||||
<p class="text-muted">Aktiv</p>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<h3 class="text-danger">{{ stats.expired_licenses }}</h3>
|
||||
<p class="text-muted">Abgelaufen</p>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<h3 class="text-secondary">{{ stats.inactive_licenses }}</h3>
|
||||
<p class="text-muted">Deaktiviert</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup-Status und Sicherheit nebeneinander -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<a href="/backups" class="text-decoration-none">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">💾 Backup-Status</h5>
|
||||
{% if stats.last_backup %}
|
||||
{% if stats.last_backup[4] == 'success' %}
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="text-success me-2">✅</span>
|
||||
<small>{{ stats.last_backup[0].strftime('%d.%m.%Y %H:%M') }}</small>
|
||||
</div>
|
||||
<div class="progress-custom">
|
||||
<div class="progress-bar-custom" style="width: 100%;"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-1 d-block">
|
||||
{{ (stats.last_backup[1] / 1024 / 1024)|round(1) }} MB • {{ stats.last_backup[2]|round(0)|int }}s
|
||||
</small>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-danger me-2">❌</span>
|
||||
<small>Backup fehlgeschlagen</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Sicherheitsstatus -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">🔒 Sicherheitsstatus</h5>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span>Sicherheitslevel:</span>
|
||||
<span class="badge bg-{{ stats.security_level }} fs-6">{{ stats.security_level_text }}</span>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<h4 class="text-danger mb-0">{{ stats.blocked_ips_count }}</h4>
|
||||
<small class="text-muted">Gesperrte IPs</small>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h4 class="text-warning mb-0">{{ stats.failed_attempts_today }}</h4>
|
||||
<small class="text-muted">Fehlversuche heute</small>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/security/blocked-ips" class="btn btn-sm btn-outline-danger mt-3">IP-Verwaltung →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sicherheitsereignisse -->
|
||||
{% if stats.recent_security_events %}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark text-white">
|
||||
<h6 class="mb-0">🚨 Letzte Sicherheitsereignisse</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-type="date">Zeit</th>
|
||||
<th class="sortable">IP-Adresse</th>
|
||||
<th class="sortable" data-type="numeric">Versuche</th>
|
||||
<th class="sortable">Fehlermeldung</th>
|
||||
<th class="sortable">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in stats.recent_security_events %}
|
||||
<tr>
|
||||
<td>{{ event.last_attempt }}</td>
|
||||
<td><code>{{ event.ip_address }}</code></td>
|
||||
<td><span class="badge bg-secondary">{{ event.attempt_count }}</span></td>
|
||||
<td><strong class="text-danger">{{ event.error_message }}</strong></td>
|
||||
<td>
|
||||
{% if event.blocked_until %}
|
||||
<span class="badge bg-danger">Gesperrt bis {{ event.blocked_until }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Aktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Resource Pool Status -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-server"></i> Resource Pool Status
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% if resource_stats %}
|
||||
{% for type, data in resource_stats.items() %}
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3">
|
||||
<a href="/resources?type={{ type }}{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
|
||||
class="text-decoration-none">
|
||||
<i class="fas fa-{{ 'globe' if type == 'domain' else ('network-wired' if type == 'ipv4' else 'phone') }} fa-2x text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">
|
||||
<a href="/resources?type={{ type }}{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
|
||||
class="text-decoration-none text-dark">{{ type|upper }}</a>
|
||||
</h6>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong class="text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">{{ data.available }}</strong> / {{ data.total }} verfügbar
|
||||
</span>
|
||||
<span class="badge bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">
|
||||
{{ data.available_percent }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress mt-1" style="height: 8px;">
|
||||
<div class="progress-bar bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"
|
||||
style="width: {{ data.available_percent }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.available }} von {{ data.total }} verfügbar"></div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<small class="text-muted">
|
||||
<a href="/resources?type={{ type }}&status=allocated{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
|
||||
class="text-decoration-none text-muted">
|
||||
{{ data.allocated }} zugeteilt
|
||||
</a>
|
||||
</small>
|
||||
{% if data.quarantine > 0 %}
|
||||
<small>
|
||||
<a href="/resources?type={{ type }}&status=quarantine{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
|
||||
class="text-decoration-none text-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i> {{ data.quarantine }} in Quarantäne
|
||||
</a>
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="col-12 text-center text-muted">
|
||||
<i class="fas fa-inbox fa-3x mb-3"></i>
|
||||
<p>Keine Ressourcen im Pool vorhanden.</p>
|
||||
<a href="/resources/add{{ '?show_test=true' if request.args.get('show_test') == 'true' else '' }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Ressourcen hinzufügen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if resource_warning %}
|
||||
<div class="alert alert-danger mt-3 mb-0 d-flex justify-content-between align-items-center" role="alert">
|
||||
<div>
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Kritisch:</strong> {{ resource_warning }}
|
||||
</div>
|
||||
<a href="/resources/add{{ '?show_test=true' if request.args.get('show_test') == 'true' else '' }}"
|
||||
class="btn btn-sm btn-danger">
|
||||
<i class="bi bi-plus"></i> Ressourcen auffüllen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Bald ablaufende Lizenzen -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h5 class="mb-0">⏰ Bald ablaufende Lizenzen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.expiring_licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Kunde</th>
|
||||
<th class="sortable">Lizenz</th>
|
||||
<th class="sortable" data-type="numeric">Tage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in stats.expiring_licenses %}
|
||||
<tr>
|
||||
<td>{{ license[2] }}</td>
|
||||
<td><small><code>{{ license[1][:8] }}...</code></small></td>
|
||||
<td><span class="badge bg-warning">{{ license[4] }} Tage</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine Lizenzen laufen in den nächsten 30 Tagen ab.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzte Lizenzen -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">🆕 Zuletzt erstellte Lizenzen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.recent_licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Kunde</th>
|
||||
<th class="sortable">Lizenz</th>
|
||||
<th class="sortable">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in stats.recent_licenses %}
|
||||
<tr>
|
||||
<td>{{ license[2] }}</td>
|
||||
<td><small><code>{{ license[1][:8] }}...</code></small></td>
|
||||
<td>
|
||||
{% if license[4] == 'deaktiviert' %}
|
||||
<span class="status-deaktiviert">🚫 Deaktiviert</span>
|
||||
{% elif license[4] == 'abgelaufen' %}
|
||||
<span class="status-abgelaufen">⚠️ Abgelaufen</span>
|
||||
{% elif license[4] == 'läuft bald ab' %}
|
||||
<span class="status-ablaufend">⏰ Läuft bald ab</span>
|
||||
{% else %}
|
||||
<span class="status-aktiv">✅ Aktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Noch keine Lizenzen erstellt.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Kunde bearbeiten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Kunde bearbeiten</h2>
|
||||
<div>
|
||||
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">👥 Zurück zur Übersicht</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="post" action="/customer/edit/{{ customer[0] }}" accept-charset="UTF-8">
|
||||
{% if request.args.get('show_test') == 'true' %}
|
||||
<input type="hidden" name="show_test" value="true">
|
||||
{% endif %}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="name" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{ customer[1] }}" accept-charset="UTF-8" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ customer[2] or '' }}" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label text-muted">Erstellt am</label>
|
||||
<p class="form-control-plaintext">{{ customer[3].strftime('%d.%m.%Y %H:%M') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="isTest" name="is_test" {% if customer[4] %}checked{% endif %}>
|
||||
<label class="form-check-label" for="isTest">
|
||||
<i class="fas fa-flask"></i> Als Testdaten markieren
|
||||
<small class="text-muted">(Kunde und seine Lizenzen werden von der Software ignoriert)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
|
||||
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Lizenzen des Kunden</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Typ</th>
|
||||
<th>Gültig von</th>
|
||||
<th>Gültig bis</th>
|
||||
<th>Aktiv</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in licenses %}
|
||||
<tr>
|
||||
<td><code>{{ license[1] }}</code></td>
|
||||
<td>
|
||||
{% if license[2] == 'full' %}
|
||||
<span class="badge bg-success">Vollversion</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Testversion</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ license[3].strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ license[4].strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
{% if license[5] %}
|
||||
<span class="text-success">✓</span>
|
||||
{% else %}
|
||||
<span class="text-danger">✗</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/license/edit/{{ license[0] }}" class="btn btn-outline-primary btn-sm">Bearbeiten</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Dieser Kunde hat noch keine Lizenzen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,84 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Lizenz bearbeiten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Lizenz bearbeiten</h2>
|
||||
<div>
|
||||
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">📋 Zurück zur Übersicht</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="/license/edit/{{ license[0] }}" accept-charset="UTF-8">
|
||||
{% if request.args.get('show_test') == 'true' %}
|
||||
<input type="hidden" name="show_test" value="true">
|
||||
{% endif %}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Kunde</label>
|
||||
<input type="text" class="form-control" value="{{ license[2] }}" disabled>
|
||||
<small class="text-muted">Kunde kann nicht geändert werden</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" value="{{ license[3] or '-' }}" disabled>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
|
||||
<input type="text" class="form-control" id="licenseKey" name="license_key" value="{{ license[1] }}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||
<select class="form-select" id="licenseType" name="license_type" required>
|
||||
<option value="full" {% if license[4] == 'full' %}selected{% endif %}>Vollversion</option>
|
||||
<option value="test" {% if license[4] == 'test' %}selected{% endif %}>Testversion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="validFrom" class="form-label">Gültig von</label>
|
||||
<input type="date" class="form-control" id="validFrom" name="valid_from" value="{{ license[5].strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="validUntil" class="form-label">Gültig bis</label>
|
||||
<input type="date" class="form-control" id="validUntil" name="valid_until" value="{{ license[6].strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="isActive" name="is_active" {% if license[7] %}checked{% endif %}>
|
||||
<label class="form-check-label" for="isActive">
|
||||
Lizenz ist aktiv
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="deviceLimit" class="form-label">Gerätelimit</label>
|
||||
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }}" {% if license[10] == i %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">Maximale Anzahl gleichzeitig aktiver Geräte</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="isTest" name="is_test" {% if license[9] %}checked{% endif %}>
|
||||
<label class="form-check-label" for="isTest">
|
||||
<i class="fas fa-flask"></i> Als Testdaten markieren
|
||||
<small class="text-muted">(wird von der Software ignoriert)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
|
||||
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,533 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Panel{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Neue Lizenz erstellen</h2>
|
||||
<a href="/customers-licenses" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/create" accept-charset="UTF-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label for="customerSelect" class="form-label">Kunde auswählen</label>
|
||||
<select class="form-select" id="customerSelect" name="customer_id" required>
|
||||
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
|
||||
<option value="new">➕ Neuer Kunde</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6" id="customerNameDiv" style="display: none;">
|
||||
<label for="customerName" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="customerName" name="customer_name" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-md-6" id="emailDiv" style="display: none;">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="licenseKey" name="license_key"
|
||||
placeholder="AF-F-YYYYMM-XXXX-YYYY-ZZZZ" required
|
||||
pattern="AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}"
|
||||
title="Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="generateLicenseKey()">
|
||||
🔑 Generieren
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||
<select class="form-select" id="licenseType" name="license_type" required>
|
||||
<option value="full">Vollversion</option>
|
||||
<option value="test">Testversion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="validFrom" class="form-label">Kaufdatum</label>
|
||||
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label for="duration" class="form-label">Laufzeit</label>
|
||||
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label for="durationType" class="form-label">Einheit</label>
|
||||
<select class="form-select" id="durationType" name="duration_type" required>
|
||||
<option value="days">Tage</option>
|
||||
<option value="months">Monate</option>
|
||||
<option value="years" selected>Jahre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="validUntil" class="form-label">Ablaufdatum</label>
|
||||
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Pool Allocation -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-server"></i> Ressourcen-Zuweisung
|
||||
<small class="text-muted float-end" id="resourceStatus"></small>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="domainCount" class="form-label">
|
||||
<i class="fas fa-globe"></i> Domains
|
||||
</label>
|
||||
<select class="form-select" id="domainCount" name="domain_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="ipv4Count" class="form-label">
|
||||
<i class="fas fa-network-wired"></i> IPv4-Adressen
|
||||
</label>
|
||||
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="phoneCount" class="form-label">
|
||||
<i class="fas fa-phone"></i> Telefonnummern
|
||||
</label>
|
||||
<select class="form-select" id="phoneCount" name="phone_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info mt-3 mb-0" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Die Ressourcen werden bei der Lizenzerstellung automatisch aus dem Pool zugewiesen.
|
||||
Wählen Sie 0, wenn für diesen Typ keine Ressourcen benötigt werden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Limit -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-laptop"></i> Gerätelimit
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label for="deviceLimit" class="form-label">
|
||||
Maximale Anzahl Geräte
|
||||
</label>
|
||||
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Data Checkbox -->
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="isTest" name="is_test">
|
||||
<label class="form-check-label" for="isTest">
|
||||
<i class="fas fa-flask"></i> Als Testdaten markieren
|
||||
<small class="text-muted">(wird von der Software ignoriert)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">➕ Lizenz erstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
|
||||
{% for category, message in messages %}
|
||||
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<script>
|
||||
// License Key Generator
|
||||
function generateLicenseKey() {
|
||||
const licenseType = document.getElementById('licenseType').value;
|
||||
|
||||
// Zeige Ladeindikator
|
||||
const button = event.target;
|
||||
const originalText = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.innerHTML = '⏳ Generiere...';
|
||||
|
||||
// API-Call
|
||||
fetch('/api/generate-license-key', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({type: licenseType})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('licenseKey').value = data.key;
|
||||
// Visuelles Feedback
|
||||
document.getElementById('licenseKey').classList.add('border-success');
|
||||
setTimeout(() => {
|
||||
document.getElementById('licenseKey').classList.remove('border-success');
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('Fehler bei der Key-Generierung: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler:', error);
|
||||
alert('Netzwerkfehler bei der Key-Generierung');
|
||||
})
|
||||
.finally(() => {
|
||||
// Button zurücksetzen
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
// Event Listener für Lizenztyp-Änderung
|
||||
document.getElementById('licenseType').addEventListener('change', function() {
|
||||
const keyField = document.getElementById('licenseKey');
|
||||
if (keyField.value && keyField.value.startsWith('AF-')) {
|
||||
// Prüfe ob der Key zum neuen Typ passt
|
||||
const currentType = this.value;
|
||||
const keyType = keyField.value.charAt(3); // Position des F/T im Key (AF-F-...)
|
||||
|
||||
if ((currentType === 'full' && keyType === 'T') ||
|
||||
(currentType === 'test' && keyType === 'F')) {
|
||||
if (confirm('Der aktuelle Key passt nicht zum gewählten Lizenztyp. Neuen Key generieren?')) {
|
||||
generateLicenseKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-Uppercase für License Key Input
|
||||
document.getElementById('licenseKey').addEventListener('input', function(e) {
|
||||
e.target.value = e.target.value.toUpperCase();
|
||||
});
|
||||
|
||||
// Funktion zur Berechnung des Ablaufdatums
|
||||
function calculateValidUntil() {
|
||||
const validFrom = document.getElementById('validFrom').value;
|
||||
const duration = parseInt(document.getElementById('duration').value) || 1;
|
||||
const durationType = document.getElementById('durationType').value;
|
||||
|
||||
if (!validFrom) return;
|
||||
|
||||
const startDate = new Date(validFrom);
|
||||
let endDate = new Date(startDate);
|
||||
|
||||
switch(durationType) {
|
||||
case 'days':
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
break;
|
||||
case 'months':
|
||||
endDate.setMonth(endDate.getMonth() + duration);
|
||||
break;
|
||||
case 'years':
|
||||
endDate.setFullYear(endDate.getFullYear() + duration);
|
||||
break;
|
||||
}
|
||||
|
||||
// Ein Tag abziehen, da der Starttag mitgezählt wird
|
||||
endDate.setDate(endDate.getDate() - 1);
|
||||
|
||||
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Event Listener für Änderungen
|
||||
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
|
||||
document.getElementById('duration').addEventListener('input', calculateValidUntil);
|
||||
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
|
||||
|
||||
// Setze heutiges Datum als Standard für valid_from
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('validFrom').value = today;
|
||||
|
||||
// Berechne initiales Ablaufdatum
|
||||
calculateValidUntil();
|
||||
|
||||
// Initialisiere Select2 für Kundenauswahl
|
||||
$('#customerSelect').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
|
||||
allowClear: true,
|
||||
ajax: {
|
||||
url: '/api/customers',
|
||||
dataType: 'json',
|
||||
delay: 250,
|
||||
data: function (params) {
|
||||
return {
|
||||
q: params.term,
|
||||
page: params.page || 1
|
||||
};
|
||||
},
|
||||
processResults: function (data, params) {
|
||||
params.page = params.page || 1;
|
||||
|
||||
// "Neuer Kunde" Option immer oben anzeigen
|
||||
const results = data.results || [];
|
||||
if (params.page === 1) {
|
||||
results.unshift({
|
||||
id: 'new',
|
||||
text: '➕ Neuer Kunde',
|
||||
isNew: true
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
results: results,
|
||||
pagination: data.pagination
|
||||
};
|
||||
},
|
||||
cache: true
|
||||
},
|
||||
minimumInputLength: 0,
|
||||
language: {
|
||||
inputTooShort: function() { return ''; },
|
||||
noResults: function() { return 'Keine Kunden gefunden'; },
|
||||
searching: function() { return 'Suche...'; },
|
||||
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
|
||||
}
|
||||
});
|
||||
|
||||
// Vorausgewählten Kunden setzen (falls von kombinierter Ansicht kommend)
|
||||
{% if preselected_customer_id %}
|
||||
// Lade Kundendetails und setze Auswahl
|
||||
fetch('/api/customers?id={{ preselected_customer_id }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.results && data.results.length > 0) {
|
||||
const customer = data.results[0];
|
||||
// Erstelle Option und setze sie als ausgewählt
|
||||
const option = new Option(customer.text, customer.id, true, true);
|
||||
$('#customerSelect').append(option).trigger('change');
|
||||
// Verstecke die Eingabefelder
|
||||
document.getElementById('customerNameDiv').style.display = 'none';
|
||||
document.getElementById('emailDiv').style.display = 'none';
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
// Event Handler für Kundenauswahl
|
||||
$('#customerSelect').on('select2:select', function (e) {
|
||||
const selectedValue = e.params.data.id;
|
||||
const nameDiv = document.getElementById('customerNameDiv');
|
||||
const emailDiv = document.getElementById('emailDiv');
|
||||
const nameInput = document.getElementById('customerName');
|
||||
const emailInput = document.getElementById('email');
|
||||
|
||||
if (selectedValue === 'new') {
|
||||
// Zeige Eingabefelder für neuen Kunden
|
||||
nameDiv.style.display = 'block';
|
||||
emailDiv.style.display = 'block';
|
||||
nameInput.required = true;
|
||||
emailInput.required = true;
|
||||
} else {
|
||||
// Verstecke Eingabefelder bei bestehendem Kunden
|
||||
nameDiv.style.display = 'none';
|
||||
emailDiv.style.display = 'none';
|
||||
nameInput.required = false;
|
||||
emailInput.required = false;
|
||||
nameInput.value = '';
|
||||
emailInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Clear handler
|
||||
$('#customerSelect').on('select2:clear', function (e) {
|
||||
document.getElementById('customerNameDiv').style.display = 'none';
|
||||
document.getElementById('emailDiv').style.display = 'none';
|
||||
document.getElementById('customerName').required = false;
|
||||
document.getElementById('email').required = false;
|
||||
});
|
||||
|
||||
// Resource Availability Check
|
||||
checkResourceAvailability();
|
||||
|
||||
// Event Listener für Resource Count Änderungen
|
||||
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
|
||||
});
|
||||
|
||||
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit
|
||||
function checkResourceAvailability() {
|
||||
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
|
||||
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
|
||||
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
|
||||
|
||||
// API-Call zur Verfügbarkeitsprüfung
|
||||
fetch(`/api/resources/check-availability?domain=${domainCount}&ipv4=${ipv4Count}&phone=${phoneCount}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update der Verfügbarkeitsanzeigen
|
||||
updateAvailabilityDisplay('domainsAvailable', data.domain_available, domainCount);
|
||||
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, ipv4Count);
|
||||
updateAvailabilityDisplay('phoneAvailable', data.phone_available, phoneCount);
|
||||
|
||||
// Gesamtstatus aktualisieren
|
||||
updateResourceStatus(data, domainCount, ipv4Count, phoneCount);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Hilfsfunktion zur Anzeige der Verfügbarkeit
|
||||
function updateAvailabilityDisplay(elementId, available, requested) {
|
||||
const element = document.getElementById(elementId);
|
||||
const container = element.parentElement;
|
||||
|
||||
// Verfügbarkeit mit Prozentanzeige
|
||||
const percent = Math.round((available / (available + requested + 50)) * 100);
|
||||
let statusHtml = `<strong>${available}</strong>`;
|
||||
|
||||
if (requested > 0 && available < requested) {
|
||||
element.classList.remove('text-success', 'text-warning');
|
||||
element.classList.add('text-danger');
|
||||
statusHtml += ` <i class="bi bi-exclamation-triangle"></i>`;
|
||||
|
||||
// Füge Warnung hinzu
|
||||
if (!container.querySelector('.availability-warning')) {
|
||||
const warning = document.createElement('div');
|
||||
warning.className = 'availability-warning text-danger small mt-1';
|
||||
warning.innerHTML = `<i class="bi bi-x-circle"></i> Nicht genügend Ressourcen verfügbar!`;
|
||||
container.appendChild(warning);
|
||||
}
|
||||
} else {
|
||||
// Entferne Warnung wenn vorhanden
|
||||
const warning = container.querySelector('.availability-warning');
|
||||
if (warning) warning.remove();
|
||||
|
||||
if (available < 20) {
|
||||
element.classList.remove('text-success');
|
||||
element.classList.add('text-danger');
|
||||
statusHtml += ` <span class="badge bg-danger">Kritisch</span>`;
|
||||
} else if (available < 50) {
|
||||
element.classList.remove('text-success', 'text-danger');
|
||||
element.classList.add('text-warning');
|
||||
statusHtml += ` <span class="badge bg-warning text-dark">Niedrig</span>`;
|
||||
} else {
|
||||
element.classList.remove('text-danger', 'text-warning');
|
||||
element.classList.add('text-success');
|
||||
statusHtml += ` <span class="badge bg-success">OK</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
element.innerHTML = statusHtml;
|
||||
|
||||
// Zeige Fortschrittsbalken
|
||||
updateResourceProgressBar(elementId.replace('Available', ''), available, requested);
|
||||
}
|
||||
|
||||
// Fortschrittsbalken für Ressourcen
|
||||
function updateResourceProgressBar(resourceType, available, requested) {
|
||||
const progressId = `${resourceType}Progress`;
|
||||
let progressBar = document.getElementById(progressId);
|
||||
|
||||
// Erstelle Fortschrittsbalken wenn nicht vorhanden
|
||||
if (!progressBar) {
|
||||
const container = document.querySelector(`#${resourceType}Available`).parentElement.parentElement;
|
||||
const progressDiv = document.createElement('div');
|
||||
progressDiv.className = 'mt-2';
|
||||
progressDiv.innerHTML = `
|
||||
<div class="progress" style="height: 20px;" id="${progressId}">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: 0%">
|
||||
<span class="progress-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(progressDiv);
|
||||
progressBar = document.getElementById(progressId);
|
||||
}
|
||||
|
||||
const total = available + requested;
|
||||
const availablePercent = total > 0 ? (available / total) * 100 : 100;
|
||||
const bar = progressBar.querySelector('.progress-bar');
|
||||
const text = progressBar.querySelector('.progress-text');
|
||||
|
||||
// Setze Farbe basierend auf Verfügbarkeit
|
||||
bar.classList.remove('bg-success', 'bg-warning', 'bg-danger');
|
||||
if (requested > 0 && available < requested) {
|
||||
bar.classList.add('bg-danger');
|
||||
} else if (availablePercent < 30) {
|
||||
bar.classList.add('bg-warning');
|
||||
} else {
|
||||
bar.classList.add('bg-success');
|
||||
}
|
||||
|
||||
// Animiere Fortschrittsbalken
|
||||
bar.style.width = `${availablePercent}%`;
|
||||
text.textContent = requested > 0 ? `${available} von ${total}` : `${available} verfügbar`;
|
||||
}
|
||||
|
||||
// Gesamtstatus der Ressourcen-Verfügbarkeit
|
||||
function updateResourceStatus(data, domainCount, ipv4Count, phoneCount) {
|
||||
const statusElement = document.getElementById('resourceStatus');
|
||||
let hasIssue = false;
|
||||
let message = '';
|
||||
|
||||
if (domainCount > 0 && data.domain_available < domainCount) {
|
||||
hasIssue = true;
|
||||
message = '⚠️ Nicht genügend Domains';
|
||||
} else if (ipv4Count > 0 && data.ipv4_available < ipv4Count) {
|
||||
hasIssue = true;
|
||||
message = '⚠️ Nicht genügend IPv4-Adressen';
|
||||
} else if (phoneCount > 0 && data.phone_available < phoneCount) {
|
||||
hasIssue = true;
|
||||
message = '⚠️ Nicht genügend Telefonnummern';
|
||||
} else {
|
||||
message = '✅ Alle Ressourcen verfügbar';
|
||||
}
|
||||
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,375 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Lizenzübersicht{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('licenses', sort=field, order='desc' if current_order == 'asc' else 'asc', search=search, type=filter_type, status=filter_status, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('licenses', sort=field, order='asc', search=search, type=filter_type, status=filter_status, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block extra_css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="mb-4">
|
||||
<h2>Lizenzübersicht</h2>
|
||||
</div>
|
||||
|
||||
<!-- Such- und Filterformular -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" action="/licenses" id="filterForm">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label for="search" class="form-label">🔍 Suchen</label>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
placeholder="Lizenzschlüssel, Kunde, E-Mail..."
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="type" class="form-label">Typ</label>
|
||||
<select class="form-select" id="type" name="type">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="full" {% if filter_type == 'full' %}selected{% endif %}>Vollversion</option>
|
||||
<option value="test" {% if filter_type == 'test' %}selected{% endif %}>Testversion</option>
|
||||
<option value="test_data" {% if filter_type == 'test_data' %}selected{% endif %}>🧪 Testdaten</option>
|
||||
<option value="live_data" {% if filter_type == 'live_data' %}selected{% endif %}>🚀 Live-Daten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="active" {% if filter_status == 'active' %}selected{% endif %}>✅ Aktiv</option>
|
||||
<option value="expiring" {% if filter_status == 'expiring' %}selected{% endif %}>⏰ Läuft bald ab</option>
|
||||
<option value="expired" {% if filter_status == 'expired' %}selected{% endif %}>⚠️ Abgelaufen</option>
|
||||
<option value="inactive" {% if filter_status == 'inactive' %}selected{% endif %}>❌ Deaktiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<a href="/licenses" class="btn btn-outline-secondary">Zurücksetzen</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% if search or filter_type or filter_status %}
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">
|
||||
Gefiltert: {{ total }} Ergebnisse
|
||||
{% if search %} | Suche: <strong>{{ search }}</strong>{% endif %}
|
||||
{% if filter_type %} | Typ: <strong>{{ 'Vollversion' if filter_type == 'full' else 'Testversion' }}</strong>{% endif %}
|
||||
{% if filter_status %} | Status: <strong>{{ filter_status }}</strong>{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-container">
|
||||
<table class="table table-hover table-sticky mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-cell">
|
||||
<input type="checkbox" class="form-check-input form-check-input-custom" id="selectAll">
|
||||
</th>
|
||||
{{ sortable_header('ID', 'id', sort, order) }}
|
||||
{{ sortable_header('Lizenzschlüssel', 'license_key', sort, order) }}
|
||||
{{ sortable_header('Kunde', 'customer', sort, order) }}
|
||||
{{ sortable_header('E-Mail', 'email', sort, order) }}
|
||||
{{ sortable_header('Typ', 'type', sort, order) }}
|
||||
{{ sortable_header('Gültig von', 'valid_from', sort, order) }}
|
||||
{{ sortable_header('Gültig bis', 'valid_until', sort, order) }}
|
||||
{{ sortable_header('Status', 'status', sort, order) }}
|
||||
{{ sortable_header('Aktiv', 'active', sort, order) }}
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in licenses %}
|
||||
<tr>
|
||||
<td class="checkbox-cell">
|
||||
<input type="checkbox" class="form-check-input form-check-input-custom license-checkbox" value="{{ license[0] }}">
|
||||
</td>
|
||||
<td>{{ license[0] }}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="me-2">{{ license[1] }}</code>
|
||||
<button class="btn btn-sm btn-outline-secondary btn-copy" onclick="copyToClipboard('{{ license[1] }}', this)" title="Kopieren">
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ license[2] }}
|
||||
{% if license[8] %}
|
||||
<span class="badge bg-secondary ms-1" title="Testdaten">🧪</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ license[3] or '-' }}</td>
|
||||
<td>
|
||||
{% if license[4] == 'full' %}
|
||||
<span class="badge bg-success">Vollversion</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Testversion</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ license[5].strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ license[6].strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
{% if license[9] == 'abgelaufen' %}
|
||||
<span class="status-abgelaufen">⚠️ Abgelaufen</span>
|
||||
{% elif license[9] == 'läuft bald ab' %}
|
||||
<span class="status-ablaufend">⏰ Läuft bald ab</span>
|
||||
{% elif license[9] == 'deaktiviert' %}
|
||||
<span class="status-deaktiviert">❌ Deaktiviert</span>
|
||||
{% else %}
|
||||
<span class="status-aktiv">✅ Aktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="form-check form-switch form-switch-custom">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="active_{{ license[0] }}"
|
||||
{{ 'checked' if license[7] else '' }}
|
||||
onchange="toggleLicenseStatus({{ license[0] }}, this.checked)">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="/license/edit/{{ license[0] }}" class="btn btn-outline-primary">✏️ Bearbeiten</a>
|
||||
<form method="post" action="/license/delete/{{ license[0] }}" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');">
|
||||
<button type="submit" class="btn btn-outline-danger">🗑️ Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not licenses %}
|
||||
<div class="text-center py-5">
|
||||
{% if search %}
|
||||
<p class="text-muted">Keine Lizenzen gefunden für: <strong>{{ search }}</strong></p>
|
||||
<a href="/licenses" class="btn btn-secondary">Alle Lizenzen anzeigen</a>
|
||||
{% else %}
|
||||
<p class="text-muted">Noch keine Lizenzen vorhanden.</p>
|
||||
<a href="/create" class="btn btn-primary">Erste Lizenz erstellen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav aria-label="Seitennavigation" class="mt-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
<!-- Erste Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('licenses', page=1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">Erste</a>
|
||||
</li>
|
||||
|
||||
<!-- Vorherige Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('licenses', page=page-1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">←</a>
|
||||
</li>
|
||||
|
||||
<!-- Seitenzahlen -->
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p >= page - 2 and p <= page + 2 %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('licenses', page=p, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Nächste Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('licenses', page=page+1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">→</a>
|
||||
</li>
|
||||
|
||||
<!-- Letzte Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('licenses', page=total_pages, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">Letzte</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted">
|
||||
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Lizenzen
|
||||
</p>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div class="bulk-actions" id="bulkActionsBar">
|
||||
<div>
|
||||
<span id="selectedCount">0</span> Lizenzen ausgewählt
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-success btn-sm me-2" onclick="bulkActivate()">✅ Aktivieren</button>
|
||||
<button class="btn btn-warning btn-sm me-2" onclick="bulkDeactivate()">⏸️ Deaktivieren</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="bulkDelete()">🗑️ Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Live Filtering
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const filterForm = document.getElementById('filterForm');
|
||||
const searchInput = document.getElementById('search');
|
||||
const typeSelect = document.getElementById('type');
|
||||
const statusSelect = document.getElementById('status');
|
||||
|
||||
// Debounce timer für Suchfeld
|
||||
let searchTimeout;
|
||||
|
||||
// Live-Filter für Suchfeld (mit 300ms Verzögerung)
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
filterForm.submit();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Live-Filter für Dropdowns (sofort)
|
||||
typeSelect.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
|
||||
statusSelect.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
});
|
||||
|
||||
// Copy to Clipboard
|
||||
function copyToClipboard(text, button) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
button.classList.add('copied');
|
||||
button.innerHTML = '✅';
|
||||
setTimeout(function() {
|
||||
button.classList.remove('copied');
|
||||
button.innerHTML = '📋';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle License Status
|
||||
function toggleLicenseStatus(licenseId, isActive) {
|
||||
fetch(`/api/license/${licenseId}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_active: isActive })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Optional: Show success message
|
||||
} else {
|
||||
// Revert toggle on error
|
||||
document.getElementById(`active_${licenseId}`).checked = !isActive;
|
||||
alert('Fehler beim Ändern des Status');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk Selection
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const checkboxes = document.querySelectorAll('.license-checkbox');
|
||||
const bulkActionsBar = document.getElementById('bulkActionsBar');
|
||||
const selectedCount = document.getElementById('selectedCount');
|
||||
|
||||
selectAll.addEventListener('change', function() {
|
||||
checkboxes.forEach(cb => cb.checked = this.checked);
|
||||
updateBulkActions();
|
||||
});
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
cb.addEventListener('change', updateBulkActions);
|
||||
});
|
||||
|
||||
function updateBulkActions() {
|
||||
const checkedBoxes = document.querySelectorAll('.license-checkbox:checked');
|
||||
const count = checkedBoxes.length;
|
||||
|
||||
if (count > 0) {
|
||||
bulkActionsBar.classList.add('show');
|
||||
selectedCount.textContent = count;
|
||||
} else {
|
||||
bulkActionsBar.classList.remove('show');
|
||||
}
|
||||
|
||||
// Update select all checkbox
|
||||
selectAll.checked = count === checkboxes.length && count > 0;
|
||||
selectAll.indeterminate = count > 0 && count < checkboxes.length;
|
||||
}
|
||||
|
||||
// Bulk Actions
|
||||
function getSelectedIds() {
|
||||
return Array.from(document.querySelectorAll('.license-checkbox:checked'))
|
||||
.map(cb => cb.value);
|
||||
}
|
||||
|
||||
function bulkActivate() {
|
||||
const ids = getSelectedIds();
|
||||
if (confirm(`${ids.length} Lizenzen aktivieren?`)) {
|
||||
performBulkAction('/api/licenses/bulk-activate', ids);
|
||||
}
|
||||
}
|
||||
|
||||
function bulkDeactivate() {
|
||||
const ids = getSelectedIds();
|
||||
if (confirm(`${ids.length} Lizenzen deaktivieren?`)) {
|
||||
performBulkAction('/api/licenses/bulk-deactivate', ids);
|
||||
}
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
const ids = getSelectedIds();
|
||||
if (confirm(`${ids.length} Lizenzen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden!`)) {
|
||||
performBulkAction('/api/licenses/bulk-delete', ids);
|
||||
}
|
||||
}
|
||||
|
||||
function performBulkAction(url, ids) {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ids: ids })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler bei der Bulk-Aktion: ' + data.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Login - Lizenzverwaltung</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.error-failed {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 1.5rem !important;
|
||||
text-align: center !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
text-transform: uppercase !important;
|
||||
animation: shake 0.5s;
|
||||
box-shadow: 0 0 20px rgba(220, 53, 69, 0.5);
|
||||
}
|
||||
|
||||
.error-blocked {
|
||||
background-color: #6f42c1 !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 1.2rem !important;
|
||||
text-align: center !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.error-captcha {
|
||||
background-color: #fd7e14 !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 1.2rem !important;
|
||||
text-align: center !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(10px); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.attempts-warning {
|
||||
background-color: #ffc107;
|
||||
color: #000;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.security-info {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container">
|
||||
<div class="row min-vh-100 align-items-center justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="text-center mb-4">🔐 Admin Login</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="error-{{ error_type|default('failed') }}">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if attempts_left is defined and attempts_left > 0 and attempts_left < 5 %}
|
||||
<div class="attempts-warning">
|
||||
⚠️ Noch {{ attempts_left }} Versuch(e) bis zur IP-Sperre!
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Benutzername</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Passwort</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
{% if show_captcha and recaptcha_site_key %}
|
||||
<div class="mb-3">
|
||||
<div class="g-recaptcha" data-sitekey="{{ recaptcha_site_key }}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<div class="security-info">
|
||||
🛡️ Geschützt durch Rate-Limiting und IP-Sperre
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_captcha and recaptcha_site_key %}
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,216 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Benutzerprofil{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.profile-card {
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.profile-card:hover {
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.profile-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.security-badge {
|
||||
font-size: 2rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #6c757d;
|
||||
box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.25);
|
||||
}
|
||||
.password-strength {
|
||||
height: 4px;
|
||||
margin-top: 5px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.strength-very-weak { background-color: #dc3545; width: 20%; }
|
||||
.strength-weak { background-color: #fd7e14; width: 40%; }
|
||||
.strength-medium { background-color: #ffc107; width: 60%; }
|
||||
.strength-strong { background-color: #28a745; width: 80%; }
|
||||
.strength-very-strong { background-color: #0d6efd; width: 100%; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>👤 Benutzerprofil</h1>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- User Info Stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card profile-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="profile-icon text-primary">👤</div>
|
||||
<h5 class="card-title">{{ user.username }}</h5>
|
||||
<p class="text-muted mb-0">{{ user.email or 'Keine E-Mail angegeben' }}</p>
|
||||
<small class="text-muted">Mitglied seit: {{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'Unbekannt' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card profile-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="profile-icon text-info">🔐</div>
|
||||
<h5 class="card-title">Sicherheitsstatus</h5>
|
||||
{% if user.totp_enabled %}
|
||||
<span class="badge bg-success">2FA Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">2FA Inaktiv</span>
|
||||
{% endif %}
|
||||
<p class="text-muted mb-0 mt-2">
|
||||
<small>Letztes Passwort-Update:<br>{{ user.last_password_change.strftime('%d.%m.%Y') if user.last_password_change else 'Noch nie' }}</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Change Card -->
|
||||
<div class="card profile-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title d-flex align-items-center">
|
||||
<span class="security-badge">🔑</span>
|
||||
Passwort ändern
|
||||
</h5>
|
||||
<hr>
|
||||
<form method="POST" action="{{ url_for('change_password') }}">
|
||||
<div class="mb-3">
|
||||
<label for="current_password" class="form-label">Aktuelles Passwort</label>
|
||||
<input type="password" class="form-control" id="current_password" name="current_password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">Neues Passwort</label>
|
||||
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
|
||||
<div class="password-strength" id="password-strength"></div>
|
||||
<div class="form-text" id="password-help">Mindestens 8 Zeichen</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm_password" class="form-label">Neues Passwort bestätigen</label>
|
||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">🔄 Passwort ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Card -->
|
||||
<div class="card profile-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title d-flex align-items-center">
|
||||
<span class="security-badge">🔐</span>
|
||||
Zwei-Faktor-Authentifizierung (2FA)
|
||||
</h5>
|
||||
<hr>
|
||||
{% if user.totp_enabled %}
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">Status: <span class="badge bg-success">Aktiv</span></h6>
|
||||
<p class="text-muted mb-0">Ihr Account ist durch 2FA geschützt</p>
|
||||
</div>
|
||||
<div class="text-success" style="font-size: 3rem;">✅</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('disable_2fa') }}" onsubmit="return confirm('Sind Sie sicher, dass Sie 2FA deaktivieren möchten? Dies verringert die Sicherheit Ihres Accounts.');">
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Passwort zur Bestätigung</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required placeholder="Ihr aktuelles Passwort">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">🚫 2FA deaktivieren</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">Status: <span class="badge bg-warning text-dark">Inaktiv</span></h6>
|
||||
<p class="text-muted mb-0">Aktivieren Sie 2FA für zusätzliche Sicherheit</p>
|
||||
</div>
|
||||
<div class="text-warning" style="font-size: 3rem;">⚠️</div>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
Mit 2FA wird bei jeder Anmeldung zusätzlich ein Code aus Ihrer Authenticator-App benötigt.
|
||||
Dies schützt Ihren Account auch bei kompromittiertem Passwort.
|
||||
</p>
|
||||
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">✨ 2FA einrichten</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Password strength indicator
|
||||
document.getElementById('new_password').addEventListener('input', function(e) {
|
||||
const password = e.target.value;
|
||||
let strength = 0;
|
||||
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength++;
|
||||
if (password.match(/[0-9]/)) strength++;
|
||||
if (password.match(/[^a-zA-Z0-9]/)) strength++;
|
||||
|
||||
const strengthText = ['Sehr schwach', 'Schwach', 'Mittel', 'Stark', 'Sehr stark'];
|
||||
const strengthClass = ['strength-very-weak', 'strength-weak', 'strength-medium', 'strength-strong', 'strength-very-strong'];
|
||||
const textClass = ['text-danger', 'text-warning', 'text-warning', 'text-success', 'text-primary'];
|
||||
|
||||
const strengthBar = document.getElementById('password-strength');
|
||||
const helpText = document.getElementById('password-help');
|
||||
|
||||
if (password.length > 0) {
|
||||
strengthBar.className = `password-strength ${strengthClass[strength]}`;
|
||||
strengthBar.style.display = 'block';
|
||||
helpText.textContent = `Stärke: ${strengthText[strength]}`;
|
||||
helpText.className = `form-text ${textClass[strength]}`;
|
||||
} else {
|
||||
strengthBar.style.display = 'none';
|
||||
helpText.textContent = 'Mindestens 8 Zeichen';
|
||||
helpText.className = 'form-text';
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm password validation
|
||||
document.getElementById('confirm_password').addEventListener('input', function(e) {
|
||||
const password = document.getElementById('new_password').value;
|
||||
const confirm = e.target.value;
|
||||
|
||||
if (confirm.length > 0) {
|
||||
if (password !== confirm) {
|
||||
e.target.classList.add('is-invalid');
|
||||
e.target.setCustomValidity('Passwörter stimmen nicht überein');
|
||||
} else {
|
||||
e.target.classList.remove('is-invalid');
|
||||
e.target.classList.add('is-valid');
|
||||
e.target.setCustomValidity('');
|
||||
}
|
||||
} else {
|
||||
e.target.classList.remove('is-invalid', 'is-valid');
|
||||
}
|
||||
});
|
||||
|
||||
// Also check when password field changes
|
||||
document.getElementById('new_password').addEventListener('input', function(e) {
|
||||
const confirm = document.getElementById('confirm_password');
|
||||
if (confirm.value.length > 0) {
|
||||
confirm.dispatchEvent(new Event('input'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,365 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Historie{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Resource Info Card */
|
||||
.resource-info-card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Resource Value Display */
|
||||
.resource-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
background-color: #f8f9fa;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
display: inline-block;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Status Badge Large */
|
||||
.status-badge-large {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Timeline Styling */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(to bottom, #e9ecef 0%, #dee2e6 100%);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-left: 80px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.timeline-content:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 10px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.timeline-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 15px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 10px 10px 10px 0;
|
||||
border-color: transparent #fff transparent transparent;
|
||||
}
|
||||
|
||||
/* Action Icons */
|
||||
.action-icon {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 1rem;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.action-created { background-color: #d4edda; color: #155724; }
|
||||
.action-allocated { background-color: #cce5ff; color: #004085; }
|
||||
.action-deallocated { background-color: #d1ecf1; color: #0c5460; }
|
||||
.action-quarantined { background-color: #fff3cd; color: #856404; }
|
||||
.action-released { background-color: #d4edda; color: #155724; }
|
||||
.action-deleted { background-color: #f8d7da; color: #721c24; }
|
||||
|
||||
/* Details Box */
|
||||
.details-box {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: #212529;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">Resource Historie</h1>
|
||||
<p class="text-muted mb-0">Detaillierte Aktivitätshistorie</p>
|
||||
</div>
|
||||
<a href="{{ url_for('resources') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Zurück zur Übersicht
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Resource Info Card -->
|
||||
<div class="card resource-info-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">📋 Resource Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Main Resource Info -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="mb-3">
|
||||
{% if resource.resource_type == 'domain' %}
|
||||
<span class="display-1">🌐</span>
|
||||
{% elif resource.resource_type == 'ipv4' %}
|
||||
<span class="display-1">🖥️</span>
|
||||
{% else %}
|
||||
<span class="display-1">📱</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="resource-value mb-3">{{ resource.resource_value }}</div>
|
||||
<div>
|
||||
{% if resource.status == 'available' %}
|
||||
<span class="status-badge-large badge bg-success">
|
||||
✅ Verfügbar
|
||||
</span>
|
||||
{% elif resource.status == 'allocated' %}
|
||||
<span class="status-badge-large badge bg-primary">
|
||||
🔗 Zugeteilt
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-badge-large badge bg-warning text-dark">
|
||||
⚠️ Quarantäne
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Info Grid -->
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<div class="info-label">Ressourcentyp</div>
|
||||
<div class="info-value">{{ resource.resource_type|upper }}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<div class="info-label">Erstellt am</div>
|
||||
<div class="info-value">
|
||||
{{ resource.created_at.strftime('%d.%m.%Y %H:%M') if resource.created_at else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<div class="info-label">Status geändert</div>
|
||||
<div class="info-value">
|
||||
{{ resource.status_changed_at.strftime('%d.%m.%Y %H:%M') if resource.status_changed_at else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if resource.allocated_to_license %}
|
||||
<div class="info-item">
|
||||
<div class="info-label">Zugewiesen an Lizenz</div>
|
||||
<div class="info-value">
|
||||
<a href="{{ url_for('edit_license', license_id=resource.allocated_to_license) }}"
|
||||
class="text-decoration-none">
|
||||
{{ license_info.license_key if license_info else 'ID: ' + resource.allocated_to_license|string }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if resource.quarantine_reason %}
|
||||
<div class="info-item">
|
||||
<div class="info-label">Quarantäne-Grund</div>
|
||||
<div class="info-value">
|
||||
<span class="badge bg-warning text-dark">{{ resource.quarantine_reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if resource.quarantine_until %}
|
||||
<div class="info-item">
|
||||
<div class="info-label">Quarantäne bis</div>
|
||||
<div class="info-value">
|
||||
{{ resource.quarantine_until.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if resource.notes %}
|
||||
<div class="mt-4">
|
||||
<div class="alert alert-info mb-0">
|
||||
<h6 class="alert-heading">📝 Notizen</h6>
|
||||
<p class="mb-0">{{ resource.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Timeline -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">⏱️ Aktivitäts-Historie</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if history %}
|
||||
<div class="timeline">
|
||||
{% for event in history %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker
|
||||
{% if event.action == 'created' %}bg-success
|
||||
{% elif event.action == 'allocated' %}bg-primary
|
||||
{% elif event.action == 'deallocated' %}bg-info
|
||||
{% elif event.action == 'quarantined' %}bg-warning
|
||||
{% elif event.action == 'released' %}bg-success
|
||||
{% elif event.action == 'deleted' %}bg-danger
|
||||
{% else %}bg-secondary{% endif %}">
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
{% if event.action == 'created' %}
|
||||
<span class="action-icon action-created">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Ressource erstellt</h6>
|
||||
{% elif event.action == 'allocated' %}
|
||||
<span class="action-icon action-allocated">
|
||||
<i class="fas fa-link"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">An Lizenz zugeteilt</h6>
|
||||
{% elif event.action == 'deallocated' %}
|
||||
<span class="action-icon action-deallocated">
|
||||
<i class="fas fa-unlink"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Von Lizenz freigegeben</h6>
|
||||
{% elif event.action == 'quarantined' %}
|
||||
<span class="action-icon action-quarantined">
|
||||
<i class="fas fa-ban"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">In Quarantäne gesetzt</h6>
|
||||
{% elif event.action == 'released' %}
|
||||
<span class="action-icon action-released">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Aus Quarantäne entlassen</h6>
|
||||
{% elif event.action == 'deleted' %}
|
||||
<span class="action-icon action-deleted">
|
||||
<i class="fas fa-trash"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Ressource gelöscht</h6>
|
||||
{% else %}
|
||||
<h6 class="mb-0">{{ event.action }}</h6>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-muted small">
|
||||
<i class="fas fa-user"></i> {{ event.action_by }}
|
||||
{% if event.ip_address %}
|
||||
• <i class="fas fa-globe"></i> {{ event.ip_address }}
|
||||
{% endif %}
|
||||
{% if event.license_id %}
|
||||
•
|
||||
<i class="fas fa-key"></i>
|
||||
<a href="{{ url_for('edit_license', license_id=event.license_id) }}">
|
||||
Lizenz #{{ event.license_id }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if event.details %}
|
||||
<div class="details-box">
|
||||
<strong>Details:</strong>
|
||||
<pre class="mb-0" style="white-space: pre-wrap;">{{ event.details|tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-end ms-3">
|
||||
<div class="badge bg-light text-dark">
|
||||
<i class="far fa-calendar"></i> {{ event.action_at.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
<div class="small text-muted mt-1">
|
||||
<i class="far fa-clock"></i> {{ event.action_at.strftime('%H:%M:%S') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-history text-muted" style="font-size: 3rem; opacity: 0.5;"></i>
|
||||
<p class="text-muted mt-3">Keine Historie-Einträge vorhanden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,559 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Metriken{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Metric Cards */
|
||||
.metric-card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
.metric-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
|
||||
}
|
||||
.metric-card .card-body {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 1rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.metric-sublabel {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Chart Cards */
|
||||
.chart-card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
height: 100%;
|
||||
}
|
||||
.chart-card .card-header {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Performance Table */
|
||||
.performance-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.performance-table td {
|
||||
vertical-align: middle;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.performance-table .resource-link {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.performance-table .resource-link:hover {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
/* Progress Bars */
|
||||
.progress-custom {
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge {
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Icon Badges */
|
||||
.icon-badge {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.icon-badge.blue { background-color: #e7f3ff; color: #0066cc; }
|
||||
.icon-badge.green { background-color: #e8f5e9; color: #2e7d32; }
|
||||
.icon-badge.orange { background-color: #fff3e0; color: #ef6c00; }
|
||||
.icon-badge.red { background-color: #ffebee; color: #c62828; }
|
||||
|
||||
/* Trend Indicator */
|
||||
.trend-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.trend-up {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.trend-down {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
.trend-neutral {
|
||||
background-color: #f5f5f5;
|
||||
color: #616161;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">Performance Dashboard</h1>
|
||||
<p class="text-muted mb-0">Resource Pool Metriken und Analysen</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('resources_report') }}" class="btn btn-info">
|
||||
📄 Report generieren
|
||||
</a>
|
||||
<a href="{{ url_for('resources') }}" class="btn btn-secondary">
|
||||
← Zurück
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge blue">
|
||||
📊
|
||||
</div>
|
||||
<div class="metric-label">Ressourcen gesamt</div>
|
||||
<div class="metric-value text-primary">{{ stats.total_resources or 0 }}</div>
|
||||
<div class="metric-sublabel">Aktive Ressourcen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge green">
|
||||
📈
|
||||
</div>
|
||||
<div class="metric-label">Ø Performance</div>
|
||||
<div class="metric-value text-{{ 'success' if stats.avg_performance > 80 else ('warning' if stats.avg_performance > 60 else 'danger') }}">
|
||||
{{ "%.1f"|format(stats.avg_performance or 0) }}%
|
||||
</div>
|
||||
<div class="metric-sublabel">Letzte 30 Tage</div>
|
||||
{% if stats.performance_trend %}
|
||||
<div class="mt-2">
|
||||
<span class="trend-indicator trend-{{ stats.performance_trend }}">
|
||||
{% if stats.performance_trend == 'up' %}
|
||||
<i class="fas fa-arrow-up me-1"></i> Steigend
|
||||
{% elif stats.performance_trend == 'down' %}
|
||||
<i class="fas fa-arrow-down me-1"></i> Fallend
|
||||
{% else %}
|
||||
<i class="fas fa-minus me-1"></i> Stabil
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge orange">
|
||||
💰
|
||||
</div>
|
||||
<div class="metric-label">ROI</div>
|
||||
<div class="metric-value text-{{ 'success' if stats.roi > 1 else 'danger' }}">
|
||||
{{ "%.2f"|format(stats.roi) }}x
|
||||
</div>
|
||||
<div class="metric-sublabel">Revenue / Cost</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge red">
|
||||
⚠️
|
||||
</div>
|
||||
<div class="metric-label">Probleme</div>
|
||||
<div class="metric-value text-{{ 'danger' if stats.total_issues > 10 else ('warning' if stats.total_issues > 5 else 'success') }}">
|
||||
{{ stats.total_issues or 0 }}
|
||||
</div>
|
||||
<div class="metric-sublabel">Letzte 30 Tage</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📊 Performance nach Ressourcentyp</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="performanceByTypeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">🎯 Auslastung nach Typ</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="utilizationChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Tables -->
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">🏆 Top Performer</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table performance-table mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Ressource</th>
|
||||
<th width="80">Typ</th>
|
||||
<th width="140">Score</th>
|
||||
<th width="80" class="text-center">ROI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for resource in top_performers %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="me-2">{{ resource.resource_value }}</code>
|
||||
<a href="{{ url_for('resource_history', resource_id=resource.id) }}"
|
||||
class="resource-link" title="Historie anzeigen">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">
|
||||
{% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %}
|
||||
{{ resource.resource_type|upper }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress progress-custom">
|
||||
<div class="progress-bar bg-success"
|
||||
style="width: {{ resource.avg_score }}%">
|
||||
{{ "%.1f"|format(resource.avg_score) }}%
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-success">
|
||||
{{ "%.2f"|format(resource.roi) }}x
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not top_performers %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
Keine Performance-Daten verfügbar
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0">⚠️ Problematische Ressourcen</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table performance-table mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Ressource</th>
|
||||
<th width="80">Typ</th>
|
||||
<th width="100" class="text-center">Probleme</th>
|
||||
<th width="120">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for resource in problem_resources %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="me-2">{{ resource.resource_value }}</code>
|
||||
<a href="{{ url_for('resource_history', resource_id=resource.id) }}"
|
||||
class="resource-link" title="Historie anzeigen">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">
|
||||
{% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %}
|
||||
{{ resource.resource_type|upper }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-danger">
|
||||
{{ resource.total_issues }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if resource.status == 'quarantine' %}
|
||||
<span class="status-badge bg-warning text-dark">
|
||||
⚠️ Quarantäne
|
||||
</span>
|
||||
{% elif resource.status == 'allocated' %}
|
||||
<span class="status-badge bg-primary text-white">
|
||||
🔗 Zugeteilt
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-badge bg-success text-white">
|
||||
✅ Verfügbar
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not problem_resources %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
Keine problematischen Ressourcen gefunden
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trend Chart -->
|
||||
<div class="card chart-card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📈 30-Tage Performance Trend</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="trendChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
|
||||
<script>
|
||||
// Chart defaults
|
||||
Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
|
||||
// Performance by Type Chart
|
||||
const performanceCtx = document.getElementById('performanceByTypeChart').getContext('2d');
|
||||
new Chart(performanceCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ performance_by_type|map(attribute=0)|list|tojson }},
|
||||
datasets: [{
|
||||
label: 'Durchschnittliche Performance',
|
||||
data: {{ performance_by_type|map(attribute=1)|list|tojson }},
|
||||
backgroundColor: [
|
||||
'rgba(33, 150, 243, 0.8)',
|
||||
'rgba(156, 39, 176, 0.8)',
|
||||
'rgba(76, 175, 80, 0.8)'
|
||||
],
|
||||
borderColor: [
|
||||
'rgb(33, 150, 243)',
|
||||
'rgb(156, 39, 176)',
|
||||
'rgb(76, 175, 80)'
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderRadius: 8
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utilization Chart
|
||||
const utilizationCtx = document.getElementById('utilizationChart').getContext('2d');
|
||||
new Chart(utilizationCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: {{ utilization_data|map(attribute='type')|list|tojson }},
|
||||
datasets: [{
|
||||
data: {{ utilization_data|map(attribute='allocated_percent')|list|tojson }},
|
||||
backgroundColor: [
|
||||
'rgba(33, 150, 243, 0.8)',
|
||||
'rgba(156, 39, 176, 0.8)',
|
||||
'rgba(76, 175, 80, 0.8)'
|
||||
],
|
||||
borderColor: '#fff',
|
||||
borderWidth: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.label + ': ' + context.parsed + '% ausgelastet';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trend Chart
|
||||
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||||
new Chart(trendCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ daily_metrics|map(attribute='date')|list|tojson }},
|
||||
datasets: [{
|
||||
label: 'Performance Score',
|
||||
data: {{ daily_metrics|map(attribute='performance')|list|tojson }},
|
||||
borderColor: 'rgb(76, 175, 80)',
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||
tension: 0.4,
|
||||
borderWidth: 3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
yAxisID: 'y',
|
||||
}, {
|
||||
label: 'Probleme',
|
||||
data: {{ daily_metrics|map(attribute='issues')|list|tojson }},
|
||||
borderColor: 'rgb(244, 67, 54)',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
tension: 0.4,
|
||||
borderWidth: 3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
yAxisID: 'y1',
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Performance %'
|
||||
},
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Anzahl Probleme'
|
||||
},
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,212 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Report Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Resource Report Generator</h1>
|
||||
<a href="{{ url_for('resources') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Zurück
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Report-Einstellungen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('resources_report') }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="report_type" class="form-label">Report-Typ</label>
|
||||
<select name="type" id="report_type" class="form-select" required>
|
||||
<option value="usage">Auslastungsreport</option>
|
||||
<option value="performance">Performance-Report</option>
|
||||
<option value="compliance">Compliance-Report</option>
|
||||
<option value="inventory">Bestands-Report</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="format" class="form-label">Export-Format</label>
|
||||
<select name="format" id="format" class="form-select" required>
|
||||
<option value="excel">Excel (.xlsx)</option>
|
||||
<option value="csv">CSV (.csv)</option>
|
||||
<option value="pdf">PDF (Vorschau)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="date_from" class="form-label">Von</label>
|
||||
<input type="date" name="from" id="date_from" class="form-control"
|
||||
value="{{ (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="date_to" class="form-label">Bis</label>
|
||||
<input type="date" name="to" id="date_to" class="form-control"
|
||||
value="{{ datetime.now().strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h6>Report-Beschreibungen:</h6>
|
||||
<div id="report_descriptions">
|
||||
<div class="alert alert-info report-desc" data-type="usage">
|
||||
<h6><i class="fas fa-chart-line"></i> Auslastungsreport</h6>
|
||||
<p class="mb-0">Zeigt die Nutzung aller Ressourcen im gewählten Zeitraum.
|
||||
Enthält Allokations-Historie, durchschnittliche Auslastung und Trends.</p>
|
||||
</div>
|
||||
<div class="alert alert-warning report-desc" data-type="performance" style="display: none;">
|
||||
<h6><i class="fas fa-tachometer-alt"></i> Performance-Report</h6>
|
||||
<p class="mb-0">Analysiert die Performance-Metriken aller Ressourcen.
|
||||
Enthält ROI-Berechnungen, Issue-Tracking und Performance-Scores.</p>
|
||||
</div>
|
||||
<div class="alert alert-success report-desc" data-type="compliance" style="display: none;">
|
||||
<h6><i class="fas fa-shield-alt"></i> Compliance-Report</h6>
|
||||
<p class="mb-0">Überprüft Compliance-Aspekte wie Quarantäne-Gründe,
|
||||
Sicherheitsvorfälle und Policy-Verletzungen.</p>
|
||||
</div>
|
||||
<div class="alert alert-primary report-desc" data-type="inventory" style="display: none;">
|
||||
<h6><i class="fas fa-boxes"></i> Bestands-Report</h6>
|
||||
<p class="mb-0">Aktueller Bestand aller Ressourcen mit Status-Übersicht,
|
||||
Verfügbarkeit und Zuordnungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="previewReport()">
|
||||
<i class="fas fa-eye"></i> Vorschau
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" name="download" value="true">
|
||||
<i class="fas fa-download"></i> Report generieren
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzte Reports -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Letzte generierte Reports</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">Auslastungsreport_2025-06-01.xlsx</h6>
|
||||
<small>vor 5 Tagen</small>
|
||||
</div>
|
||||
<p class="mb-1">Zeitraum: 01.05.2025 - 01.06.2025</p>
|
||||
<small>Generiert von: {{ username }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vorschau Modal -->
|
||||
<div class="modal fade" id="previewModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Report-Vorschau</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="previewContent">
|
||||
<p class="text-center">
|
||||
<i class="fas fa-spinner fa-spin"></i> Lade Vorschau...
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Report-Typ Beschreibungen
|
||||
document.getElementById('report_type').addEventListener('change', function() {
|
||||
const selectedType = this.value;
|
||||
document.querySelectorAll('.report-desc').forEach(desc => {
|
||||
desc.style.display = desc.dataset.type === selectedType ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Datum-Validierung
|
||||
document.getElementById('date_from').addEventListener('change', function() {
|
||||
document.getElementById('date_to').min = this.value;
|
||||
});
|
||||
|
||||
document.getElementById('date_to').addEventListener('change', function() {
|
||||
document.getElementById('date_from').max = this.value;
|
||||
});
|
||||
|
||||
// Report-Vorschau
|
||||
function previewReport() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
const content = document.getElementById('previewContent');
|
||||
|
||||
// Simuliere Lade-Vorgang
|
||||
content.innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i> Generiere Vorschau...</p>';
|
||||
modal.show();
|
||||
|
||||
setTimeout(() => {
|
||||
// Beispiel-Vorschau
|
||||
content.innerHTML = `
|
||||
<h5>Report-Vorschau</h5>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ressourcentyp</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Verfügbar</th>
|
||||
<th>Zugeteilt</th>
|
||||
<th>Auslastung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Domain</td>
|
||||
<td>150</td>
|
||||
<td>45</td>
|
||||
<td>100</td>
|
||||
<td>66.7%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>IPv4</td>
|
||||
<td>100</td>
|
||||
<td>20</td>
|
||||
<td>75</td>
|
||||
<td>75.0%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Phone</td>
|
||||
<td>50</td>
|
||||
<td>15</td>
|
||||
<td>30</td>
|
||||
<td>60.0%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-muted">Dies ist eine vereinfachte Vorschau. Der vollständige Report enthält weitere Details.</p>
|
||||
`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Initialisierung
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
|
||||
document.getElementById('date_from').value = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
document.getElementById('date_to').value = today.toISOString().split('T')[0];
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,898 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Pool{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Statistik-Karten Design wie Dashboard */
|
||||
.stat-card {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
height: 100%;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.stat-card .card-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.stat-card .card-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.stat-card .card-label {
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Resource Type Icons */
|
||||
.resource-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.resource-icon.domain {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
.resource-icon.ipv4 {
|
||||
background-color: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
.resource-icon.phone {
|
||||
background-color: #e8f5e9;
|
||||
color: #388e3c;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge {
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-available {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.status-allocated {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
.status-quarantine {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* Progress Bar Custom */
|
||||
.progress-custom {
|
||||
height: 25px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.progress-bar-custom {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Table Styling */
|
||||
.table-custom {
|
||||
border: none;
|
||||
}
|
||||
.table-custom thead th {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
padding: 1rem;
|
||||
}
|
||||
.table-custom tbody tr {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.table-custom tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.table-custom td {
|
||||
padding: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.btn-action {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
margin: 0 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-action:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Copy Button */
|
||||
.copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6c757d;
|
||||
padding: 0.25rem 0.5rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
color: #28a745;
|
||||
}
|
||||
.copy-btn.copied {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
/* Filter Card */
|
||||
.filter-card {
|
||||
background-color: #f8f9fa;
|
||||
border: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
.empty-state i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Dropdown Verbesserungen */
|
||||
.dropdown-menu {
|
||||
min-width: 220px;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
border: none;
|
||||
}
|
||||
.dropdown-item {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.dropdown-item i {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Quick Action Buttons */
|
||||
.btn-success {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Sortable Headers */
|
||||
thead th a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-right: 20px;
|
||||
}
|
||||
thead th a:hover {
|
||||
color: #0d6efd !important;
|
||||
}
|
||||
thead th a i {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="mb-0">Resource Pool</h1>
|
||||
<p class="text-muted mb-0">Verwalten Sie Domains, IPs und Telefonnummern</p>
|
||||
</div>
|
||||
|
||||
<!-- Test/Live Mode Toggle -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="showTestResources"
|
||||
{% if show_test %}checked{% endif %}
|
||||
onchange="toggleTestResources()">
|
||||
<label class="form-check-label" for="showTestResources">
|
||||
Testressourcen anzeigen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistik-Karten -->
|
||||
<div class="row g-4 mb-4">
|
||||
{% for type, data in stats.items() %}
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon">
|
||||
{% if type == 'domain' %}
|
||||
🌐
|
||||
{% elif type == 'ipv4' %}
|
||||
🖥️
|
||||
{% else %}
|
||||
📱
|
||||
{% endif %}
|
||||
</div>
|
||||
<h5 class="text-muted mb-2">{{ type|upper }}</h5>
|
||||
<div class="card-value text-primary">{{ data.available }}</div>
|
||||
<div class="card-label text-muted mb-3">von {{ data.total }} verfügbar</div>
|
||||
|
||||
<div class="progress progress-custom">
|
||||
<div class="progress-bar bg-success progress-bar-custom"
|
||||
style="width: {{ data.available_percent }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.available }} verfügbar">
|
||||
{{ data.available_percent }}%
|
||||
</div>
|
||||
<div class="progress-bar bg-info progress-bar-custom"
|
||||
style="width: {{ (data.allocated / data.total * 100) if data.total > 0 else 0 }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.allocated }} zugeteilt">
|
||||
{% if data.allocated > 0 %}{{ data.allocated }}{% endif %}
|
||||
</div>
|
||||
<div class="progress-bar bg-warning progress-bar-custom"
|
||||
style="width: {{ (data.quarantine / data.total * 100) if data.total > 0 else 0 }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.quarantine }} in Quarantäne">
|
||||
{% if data.quarantine > 0 %}{{ data.quarantine }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
{% if data.available_percent < 20 %}
|
||||
<span class="badge bg-danger">⚠️ Niedriger Bestand</span>
|
||||
{% elif data.available_percent < 50 %}
|
||||
<span class="badge bg-warning text-dark">⚡ Bestand prüfen</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">✅ Gut gefüllt</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card filter-card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('resources') }}" id="filterForm">
|
||||
<input type="hidden" name="show_test" value="{{ 'true' if show_test else 'false' }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="type" class="form-label">🏷️ Typ</label>
|
||||
<select name="type" id="type" class="form-select">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="domain" {% if resource_type == 'domain' %}selected{% endif %}>🌐 Domain</option>
|
||||
<option value="ipv4" {% if resource_type == 'ipv4' %}selected{% endif %}>🖥️ IPv4</option>
|
||||
<option value="phone" {% if resource_type == 'phone' %}selected{% endif %}>📱 Telefon</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label">📊 Status</label>
|
||||
<select name="status" id="status" class="form-select">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="available" {% if status_filter == 'available' %}selected{% endif %}>✅ Verfügbar</option>
|
||||
<option value="allocated" {% if status_filter == 'allocated' %}selected{% endif %}>🔗 Zugeteilt</option>
|
||||
<option value="quarantine" {% if status_filter == 'quarantine' %}selected{% endif %}>⚠️ Quarantäne</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="search" class="form-label">🔍 Suche</label>
|
||||
<input type="text" name="search" id="search" class="form-control"
|
||||
placeholder="Ressource suchen..." value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"> </label>
|
||||
<a href="{{ url_for('resources', show_test=show_test) }}" class="btn btn-secondary w-100">
|
||||
🔄 Zurücksetzen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ressourcen-Tabelle -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-white">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">📋 Ressourcen-Liste</h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-secondary dropdown-toggle"
|
||||
type="button"
|
||||
id="exportDropdown"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-download"></i> Export
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="exportDropdown">
|
||||
<li><a class="dropdown-item" href="/export/resources?format=excel&type={{ resource_type }}&status={{ status_filter }}&search={{ search }}&show_test={{ show_test }}">
|
||||
<i class="bi bi-file-earmark-excel text-success"></i> Excel Export</a></li>
|
||||
<li><a class="dropdown-item" href="/export/resources?format=csv&type={{ resource_type }}&status={{ status_filter }}&search={{ search }}&show_test={{ show_test }}">
|
||||
<i class="bi bi-file-earmark-text"></i> CSV Export</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="badge bg-secondary">{{ total }} Einträge</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if resources %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-custom mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="80">
|
||||
<a href="{{ url_for('resources', sort='id', order='desc' if sort_by == 'id' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
ID
|
||||
{% if sort_by == 'id' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th width="120">
|
||||
<a href="{{ url_for('resources', sort='type', order='desc' if sort_by == 'type' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
Typ
|
||||
{% if sort_by == 'type' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="{{ url_for('resources', sort='resource', order='desc' if sort_by == 'resource' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
Ressource
|
||||
{% if sort_by == 'resource' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th width="140">
|
||||
<a href="{{ url_for('resources', sort='status', order='desc' if sort_by == 'status' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
Status
|
||||
{% if sort_by == 'status' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="{{ url_for('resources', sort='assigned', order='desc' if sort_by == 'assigned' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
Zugewiesen an
|
||||
{% if sort_by == 'assigned' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th width="180">
|
||||
<a href="{{ url_for('resources', sort='changed', order='desc' if sort_by == 'changed' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
Letzte Änderung
|
||||
{% if sort_by == 'changed' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th width="200" class="text-center">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for resource in resources %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="text-muted">#{{ resource[0] }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="resource-icon {{ resource[1] }}">
|
||||
{% if resource[1] == 'domain' %}
|
||||
🌐
|
||||
{% elif resource[1] == 'ipv4' %}
|
||||
🖥️
|
||||
{% else %}
|
||||
📱
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="me-2">{{ resource[2] }}</code>
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ resource[2] }}', this)"
|
||||
title="Kopieren">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if resource[3] == 'available' %}
|
||||
<span class="status-badge status-available">
|
||||
✅ Verfügbar
|
||||
</span>
|
||||
{% elif resource[3] == 'allocated' %}
|
||||
<span class="status-badge status-allocated">
|
||||
🔗 Zugeteilt
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-badge status-quarantine">
|
||||
⚠️ Quarantäne
|
||||
</span>
|
||||
{% if resource[8] %}
|
||||
<div class="small text-muted mt-1">{{ resource[8] }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if resource[5] %}
|
||||
<div>
|
||||
<a href="{{ url_for('customers_licenses', customer_id=resource[10] if resource[10] else '', show_test=show_test) }}"
|
||||
class="text-decoration-none">
|
||||
<strong>{{ resource[5] }}</strong>
|
||||
</a>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
<a href="{{ url_for('edit_license', license_id=resource[4]) }}?ref=resources{{ '&show_test=true' if show_test else '' }}"
|
||||
class="text-decoration-none text-muted">
|
||||
{{ resource[6] }}
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if resource[7] %}
|
||||
<div class="small">
|
||||
<div>{{ resource[7].strftime('%d.%m.%Y') }}</div>
|
||||
<div class="text-muted">{{ resource[7].strftime('%H:%M Uhr') }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if resource[3] == 'quarantine' %}
|
||||
<!-- Quick Action für Quarantäne -->
|
||||
<form method="post" action="/resources/release?show_test={{ show_test }}&type={{ resource_type }}&status={{ status_filter }}&search={{ search }}"
|
||||
style="display: inline-block; margin-right: 5px;">
|
||||
<input type="hidden" name="resource_ids" value="{{ resource[0] }}">
|
||||
<input type="hidden" name="show_test" value="{{ show_test }}">
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-success">
|
||||
<i class="bi bi-check-circle"></i> Freigeben
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- Dropdown für weitere Aktionen -->
|
||||
<div class="dropdown" style="display: inline-block;">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle"
|
||||
type="button"
|
||||
id="dropdownMenuButton{{ resource[0] }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-three-dots-vertical"></i> Aktionen
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton{{ resource[0] }}">
|
||||
<!-- Historie immer verfügbar -->
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('resource_history', resource_id=resource[0]) }}">
|
||||
<i class="bi bi-clock-history text-info"></i> Historie anzeigen
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
{% if resource[3] == 'available' %}
|
||||
<!-- Aktionen für verfügbare Ressourcen -->
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
onclick="showQuarantineModal({{ resource[0] }})">
|
||||
<i class="bi bi-exclamation-triangle text-warning"></i> In Quarantäne setzen
|
||||
</button>
|
||||
</li>
|
||||
{% elif resource[3] == 'allocated' %}
|
||||
<!-- Aktionen für zugeteilte Ressourcen -->
|
||||
{% if resource[4] %}
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('edit_license', license_id=resource[4]) }}?ref=resources{{ '&show_test=true' if show_test else '' }}">
|
||||
<i class="bi bi-file-text text-primary"></i> Lizenz bearbeiten
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if resource[10] %}
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('customers_licenses', customer_id=resource[10], show_test=show_test) }}">
|
||||
<i class="bi bi-person text-primary"></i> Kunde anzeigen
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% elif resource[3] == 'quarantine' %}
|
||||
<!-- Aktionen für Quarantäne-Ressourcen -->
|
||||
<li>
|
||||
<form method="post" action="/resources/release?show_test={{ show_test }}&type={{ resource_type }}&status={{ status_filter }}&search={{ search }}"
|
||||
style="display: contents;">
|
||||
<input type="hidden" name="resource_ids" value="{{ resource[0] }}">
|
||||
<input type="hidden" name="show_test" value="{{ show_test }}">
|
||||
<button type="submit" class="dropdown-item">
|
||||
<i class="bi bi-check-circle text-success"></i> Ressource freigeben
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% if resource[9] %}
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
onclick="extendQuarantine({{ resource[0] }})">
|
||||
<i class="bi bi-calendar-plus text-warning"></i> Quarantäne verlängern
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
<!-- Kopieren immer verfügbar -->
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
onclick="copyToClipboard('{{ resource[2] }}', this)">
|
||||
<i class="bi bi-clipboard text-secondary"></i> Ressource kopieren
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<h4>Keine Ressourcen gefunden</h4>
|
||||
<p>Ändern Sie Ihre Filterkriterien oder fügen Sie neue Ressourcen hinzu.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('resources', page=1, type=resource_type, status=status_filter, search=search, show_test=show_test, sort=sort_by, order=sort_order) }}">
|
||||
<i class="bi bi-chevron-double-left"></i> Erste
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('resources', page=page-1, type=resource_type, status=status_filter, search=search, show_test=show_test, sort=sort_by, order=sort_order) }}">
|
||||
<i class="bi bi-chevron-left"></i> Zurück
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page or (p >= page - 2 and p <= page + 2) %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('resources', page=p, type=resource_type, status=status_filter, search=search, show_test=show_test, sort=sort_by, order=sort_order) }}">
|
||||
{{ p }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('resources', page=page+1, type=resource_type, status=status_filter, search=search, show_test=show_test, sort=sort_by, order=sort_order) }}">
|
||||
Weiter <i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('resources', page=total_pages, type=resource_type, status=status_filter, search=search, show_test=show_test, sort=sort_by, order=sort_order) }}">
|
||||
Letzte <i class="bi bi-chevron-double-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<!-- Kürzliche Aktivitäten -->
|
||||
{% if recent_activities %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">⏰ Kürzliche Aktivitäten</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="timeline">
|
||||
{% for activity in recent_activities %}
|
||||
<div class="d-flex mb-3">
|
||||
<div class="me-3">
|
||||
{% if activity[0] == 'created' %}
|
||||
<span class="badge bg-success rounded-pill">➕</span>
|
||||
{% elif activity[0] == 'allocated' %}
|
||||
<span class="badge bg-info rounded-pill">🔗</span>
|
||||
{% elif activity[0] == 'deallocated' %}
|
||||
<span class="badge bg-secondary rounded-pill">🔓</span>
|
||||
{% elif activity[0] == 'quarantined' %}
|
||||
<span class="badge bg-warning rounded-pill">⚠️</span>
|
||||
{% else %}
|
||||
<span class="badge bg-primary rounded-pill">ℹ️</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<strong>{{ activity[4] }}</strong> ({{ activity[3] }}) - {{ activity[0] }}
|
||||
{% if activity[1] %}
|
||||
<span class="text-muted">von {{ activity[1] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{ activity[2].strftime('%d.%m.%Y %H:%M') if activity[2] else '' }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Quarantäne Modal -->
|
||||
<div class="modal fade" id="quarantineModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post" id="quarantineForm">
|
||||
<!-- Filter-Parameter als Hidden Fields -->
|
||||
<input type="hidden" name="show_test" value="{{ show_test }}">
|
||||
<input type="hidden" name="type" value="{{ resource_type }}">
|
||||
<input type="hidden" name="status" value="{{ status_filter }}">
|
||||
<input type="hidden" name="search" value="{{ search }}">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">⚠️ Ressource in Quarantäne setzen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="reason" class="form-label">Grund</label>
|
||||
<select name="reason" id="reason" class="form-select" required>
|
||||
<option value="">Bitte wählen...</option>
|
||||
<option value="review">🔍 Überprüfung</option>
|
||||
<option value="abuse">⚠️ Missbrauch</option>
|
||||
<option value="defect">❌ Defekt</option>
|
||||
<option value="maintenance">🔧 Wartung</option>
|
||||
<option value="blacklisted">🚫 Blacklisted</option>
|
||||
<option value="expired">⏰ Abgelaufen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="until_date" class="form-label">Bis wann? (optional)</label>
|
||||
<input type="date" name="until_date" id="until_date" class="form-control"
|
||||
min="{{ datetime.now().strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">Notizen</label>
|
||||
<textarea name="notes" id="notes" class="form-control" rows="3"
|
||||
placeholder="Zusätzliche Informationen..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
⚠️ In Quarantäne setzen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Debug Bootstrap
|
||||
console.log('Bootstrap loaded:', typeof bootstrap !== 'undefined');
|
||||
if (typeof bootstrap !== 'undefined') {
|
||||
console.log('Bootstrap version:', bootstrap.Dropdown.VERSION);
|
||||
}
|
||||
|
||||
// Scroll-Position speichern und wiederherstellen
|
||||
(function() {
|
||||
// Speichere Scroll-Position vor dem Verlassen der Seite
|
||||
window.addEventListener('beforeunload', function() {
|
||||
sessionStorage.setItem('resourcesScrollPos', window.scrollY);
|
||||
});
|
||||
|
||||
// Stelle Scroll-Position wieder her
|
||||
const scrollPos = sessionStorage.getItem('resourcesScrollPos');
|
||||
if (scrollPos) {
|
||||
window.scrollTo(0, parseInt(scrollPos));
|
||||
sessionStorage.removeItem('resourcesScrollPos');
|
||||
}
|
||||
})();
|
||||
|
||||
// Live-Filtering
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('filterForm');
|
||||
const inputs = form.querySelectorAll('select, input[type="text"]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.type === 'text') {
|
||||
let timeout;
|
||||
input.addEventListener('input', function() {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => form.submit(), 300);
|
||||
});
|
||||
} else {
|
||||
input.addEventListener('change', () => form.submit());
|
||||
}
|
||||
});
|
||||
|
||||
// Bootstrap Tooltips initialisieren
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
});
|
||||
|
||||
// Bootstrap Dropdowns explizit initialisieren
|
||||
var dropdownElementList = [].slice.call(document.querySelectorAll('.dropdown-toggle'))
|
||||
var dropdownList = dropdownElementList.map(function (dropdownToggleEl) {
|
||||
return new bootstrap.Dropdown(dropdownToggleEl)
|
||||
});
|
||||
|
||||
// Export Dropdown manuell aktivieren falls nötig
|
||||
const exportBtn = document.getElementById('exportDropdown');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const dropdown = bootstrap.Dropdown.getOrCreateInstance(this);
|
||||
dropdown.toggle();
|
||||
});
|
||||
}
|
||||
|
||||
// Sort-Links: Scroll-Position speichern
|
||||
document.querySelectorAll('.sort-link').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
sessionStorage.setItem('resourcesScrollPos', window.scrollY);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Copy to Clipboard mit besserem Feedback
|
||||
function copyToClipboard(text, button) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// Original Text speichern
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<i class="bi bi-check"></i> Kopiert!';
|
||||
button.classList.add('copied');
|
||||
|
||||
// Nach 2 Sekunden zurücksetzen
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.classList.remove('copied');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Quarantäne Modal
|
||||
function showQuarantineModal(resourceId) {
|
||||
const modalElement = document.getElementById('quarantineModal');
|
||||
const modal = new bootstrap.Modal(modalElement);
|
||||
const form = modalElement.querySelector('form');
|
||||
|
||||
// URL mit aktuellen Filtern
|
||||
const currentUrl = new URL(window.location);
|
||||
const params = new URLSearchParams(currentUrl.search);
|
||||
form.setAttribute('action', `/resources/quarantine/${resourceId}?${params.toString()}`);
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Toggle Testressourcen
|
||||
function toggleTestResources() {
|
||||
const showTest = document.getElementById('showTestResources').checked;
|
||||
const currentUrl = new URL(window.location);
|
||||
currentUrl.searchParams.set('show_test', showTest);
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
|
||||
// Quarantäne verlängern
|
||||
function extendQuarantine(resourceId) {
|
||||
if (confirm('Möchten Sie die Quarantäne für diese Ressource verlängern?')) {
|
||||
// TODO: Implementiere Modal für Quarantäne-Verlängerung
|
||||
alert('Diese Funktion wird noch implementiert.');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback für Dropdowns
|
||||
document.addEventListener('click', function(e) {
|
||||
// Prüfe ob ein Dropdown-Toggle geklickt wurde
|
||||
if (e.target.closest('.dropdown-toggle')) {
|
||||
const button = e.target.closest('.dropdown-toggle');
|
||||
const dropdown = button.nextElementSibling;
|
||||
|
||||
if (dropdown && dropdown.classList.contains('dropdown-menu')) {
|
||||
// Toggle show class
|
||||
dropdown.classList.toggle('show');
|
||||
button.setAttribute('aria-expanded', dropdown.classList.contains('show'));
|
||||
|
||||
// Schließe andere Dropdowns
|
||||
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
|
||||
if (menu !== dropdown) {
|
||||
menu.classList.remove('show');
|
||||
menu.previousElementSibling.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Schließe alle Dropdowns wenn außerhalb geklickt
|
||||
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
|
||||
menu.classList.remove('show');
|
||||
menu.previousElementSibling.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,183 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Session-Tracking{% endblock %}
|
||||
|
||||
{% macro active_sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('sessions', active_sort=field, active_order='desc' if current_order == 'asc' else 'asc', ended_sort=ended_sort, ended_order=ended_order) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('sessions', active_sort=field, active_order='asc', ended_sort=ended_sort, ended_order=ended_order) }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro ended_sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('sessions', active_sort=active_sort, active_order=active_order, ended_sort=field, ended_order='desc' if current_order == 'asc' else 'asc') }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('sessions', active_sort=active_sort, active_order=active_order, ended_sort=field, ended_order='asc') }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.session-active { background-color: #d4edda; }
|
||||
.session-warning { background-color: #fff3cd; }
|
||||
.session-inactive { background-color: #f8f9fa; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Session-Tracking</h2>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-download"></i> Export
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><h6 class="dropdown-header">Aktive Sessions</h6></li>
|
||||
<li><a class="dropdown-item" href="/export/sessions?type=active&format=excel">
|
||||
<i class="bi bi-file-earmark-excel text-success"></i> Excel Export</a></li>
|
||||
<li><a class="dropdown-item" href="/export/sessions?type=active&format=csv">
|
||||
<i class="bi bi-file-earmark-text"></i> CSV Export</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">Beendete Sessions</h6></li>
|
||||
<li><a class="dropdown-item" href="/export/sessions?type=ended&format=excel">
|
||||
<i class="bi bi-file-earmark-excel text-success"></i> Excel Export</a></li>
|
||||
<li><a class="dropdown-item" href="/export/sessions?type=ended&format=csv">
|
||||
<i class="bi bi-file-earmark-text"></i> CSV Export</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aktive Sessions -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">🟢 Aktive Sessions ({{ active_sessions|length }})</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if active_sessions %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ active_sortable_header('Kunde', 'customer', active_sort, active_order) }}
|
||||
{{ active_sortable_header('Lizenz', 'license', active_sort, active_order) }}
|
||||
{{ active_sortable_header('IP-Adresse', 'ip', active_sort, active_order) }}
|
||||
{{ active_sortable_header('Gestartet', 'started', active_sort, active_order) }}
|
||||
{{ active_sortable_header('Letzter Heartbeat', 'last_heartbeat', active_sort, active_order) }}
|
||||
{{ active_sortable_header('Inaktiv seit', 'inactive', active_sort, active_order) }}
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in active_sessions %}
|
||||
<tr class="{% if session[8] > 5 %}session-warning{% else %}session-active{% endif %}">
|
||||
<td>{{ session[3] }}</td>
|
||||
<td><small><code>{{ session[2][:12] }}...</code></small></td>
|
||||
<td>{{ session[4] or '-' }}</td>
|
||||
<td>{{ session[6].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ session[7].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>
|
||||
{% if session[8] < 1 %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% elif session[8] < 5 %}
|
||||
<span class="badge bg-warning">{{ session[8]|round|int }} Min.</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">{{ session[8]|round|int }} Min.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/session/end/{{ session[0] }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Session wirklich beenden?');">
|
||||
⏹️ Beenden
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Sessions gelten als inaktiv nach 5 Minuten ohne Heartbeat
|
||||
</small>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine aktiven Sessions vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beendete Sessions -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<h5 class="mb-0">⏸️ Beendete Sessions (letzte 24 Stunden)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_sessions %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ ended_sortable_header('Kunde', 'customer', ended_sort, ended_order) }}
|
||||
{{ ended_sortable_header('Lizenz', 'license', ended_sort, ended_order) }}
|
||||
{{ ended_sortable_header('IP-Adresse', 'ip', ended_sort, ended_order) }}
|
||||
{{ ended_sortable_header('Gestartet', 'started', ended_sort, ended_order) }}
|
||||
{{ ended_sortable_header('Beendet', 'ended_at', ended_sort, ended_order) }}
|
||||
{{ ended_sortable_header('Dauer', 'duration', ended_sort, ended_order) }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in recent_sessions %}
|
||||
<tr class="session-inactive">
|
||||
<td>{{ session[3] }}</td>
|
||||
<td><small><code>{{ session[2][:12] }}...</code></small></td>
|
||||
<td>{{ session[4] or '-' }}</td>
|
||||
<td>{{ session[5].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ session[6].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>
|
||||
{% if session[7] < 60 %}
|
||||
{{ session[7]|round|int }} Min.
|
||||
{% else %}
|
||||
{{ (session[7]/60)|round(1) }} Std.
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine beendeten Sessions in den letzten 24 Stunden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,210 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}2FA Einrichten{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.setup-card {
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.setup-card:hover {
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.step-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.app-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.qr-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
display: inline-block;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.secret-code {
|
||||
font-family: monospace;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 2px;
|
||||
background-color: #f8f9fa;
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.code-input {
|
||||
font-size: 2rem;
|
||||
letter-spacing: 0.5rem;
|
||||
text-align: center;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>🔐 2FA einrichten</h1>
|
||||
<a href="{{ url_for('profile') }}" class="btn btn-secondary">← Zurück zum Profil</a>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Step 1: Install App -->
|
||||
<div class="card setup-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="step-number">1</span>
|
||||
Authenticator-App installieren
|
||||
</h5>
|
||||
<p class="ms-5">Wählen Sie eine der folgenden Apps für Ihr Smartphone:</p>
|
||||
<div class="row ms-4">
|
||||
<div class="col-md-4 mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<span style="font-size: 2rem; margin-right: 10px;">📱</span>
|
||||
<div>
|
||||
<strong>Google Authenticator</strong><br>
|
||||
<small class="text-muted">Android / iOS</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<span style="font-size: 2rem; margin-right: 10px;">🔷</span>
|
||||
<div>
|
||||
<strong>Microsoft Authenticator</strong><br>
|
||||
<small class="text-muted">Android / iOS</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<span style="font-size: 2rem; margin-right: 10px;">🔴</span>
|
||||
<div>
|
||||
<strong>Authy</strong><br>
|
||||
<small class="text-muted">Android / iOS / Desktop</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Scan QR Code -->
|
||||
<div class="card setup-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="step-number">2</span>
|
||||
QR-Code scannen oder Code eingeben
|
||||
</h5>
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6 text-center mb-4">
|
||||
<p class="fw-bold">Option A: QR-Code scannen</p>
|
||||
<div class="qr-container">
|
||||
<img src="data:image/png;base64,{{ qr_code }}" alt="2FA QR Code" style="max-width: 250px;">
|
||||
</div>
|
||||
<p class="text-muted mt-2">
|
||||
<small>Öffnen Sie Ihre Authenticator-App und scannen Sie diesen Code</small>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<p class="fw-bold">Option B: Code manuell eingeben</p>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">Account-Name:</label>
|
||||
<div class="alert alert-light py-2">
|
||||
<strong>V2 Admin Panel</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">Geheimer Schlüssel:</label>
|
||||
<div class="secret-code">{{ totp_secret }}</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="copySecret()">
|
||||
📋 Schlüssel kopieren
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-warning">
|
||||
<small>
|
||||
<strong>⚠️ Wichtiger Hinweis:</strong><br>
|
||||
Speichern Sie diesen Code sicher. Er ist Ihre einzige Möglichkeit,
|
||||
2FA auf einem neuen Gerät einzurichten.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Verify -->
|
||||
<div class="card setup-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="step-number">3</span>
|
||||
Code verifizieren
|
||||
</h5>
|
||||
<p class="ms-5">Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein:</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('enable_2fa') }}" class="ms-5 me-5">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6 mb-3">
|
||||
<input type="text"
|
||||
class="form-control code-input"
|
||||
id="token"
|
||||
name="token"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
required>
|
||||
<div class="form-text text-center">Der Code ändert sich alle 30 Sekunden</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<button type="submit" class="btn btn-success btn-lg w-100">
|
||||
✅ 2FA aktivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copySecret() {
|
||||
const secret = '{{ totp_secret }}';
|
||||
navigator.clipboard.writeText(secret).then(function() {
|
||||
alert('Code wurde in die Zwischenablage kopiert!');
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-format the code input
|
||||
document.getElementById('token').addEventListener('input', function(e) {
|
||||
// Remove non-digits
|
||||
e.target.value = e.target.value.replace(/\D/g, '');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>2FA Verifizierung - Admin Panel</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.code-input {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="logo">🔐</div>
|
||||
<h2 class="text-center mb-4">Zwei-Faktor-Authentifizierung</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="token" class="form-label">Authentifizierungscode eingeben</label>
|
||||
<input type="text"
|
||||
class="form-control code-input"
|
||||
id="token"
|
||||
name="token"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
required>
|
||||
<div class="form-text text-center mt-2">
|
||||
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Verifizieren</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<details>
|
||||
<summary class="text-muted" style="cursor: pointer;">Backup-Code verwenden</summary>
|
||||
<div class="mt-2">
|
||||
<p class="small text-muted">
|
||||
Falls Sie keinen Zugriff auf Ihre Authenticator-App haben,
|
||||
können Sie einen 8-stelligen Backup-Code eingeben.
|
||||
</p>
|
||||
<input type="text"
|
||||
class="form-control code-input mt-2"
|
||||
id="backup-token"
|
||||
placeholder="ABCD1234"
|
||||
maxlength="8"
|
||||
pattern="[A-Z0-9]{8}"
|
||||
style="letter-spacing: 0.25rem;">
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/logout" class="text-muted">Abbrechen und zur Anmeldung zurück</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Auto-format the code input
|
||||
document.getElementById('token').addEventListener('input', function(e) {
|
||||
// Remove non-digits
|
||||
e.target.value = e.target.value.replace(/\D/g, '');
|
||||
});
|
||||
|
||||
// Handle backup code input
|
||||
document.getElementById('backup-token').addEventListener('input', function(e) {
|
||||
// Convert to uppercase and remove non-alphanumeric
|
||||
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
|
||||
// If backup code is being used, copy to main token field
|
||||
if (e.target.value.length > 0) {
|
||||
document.getElementById('token').value = e.target.value;
|
||||
document.getElementById('token').removeAttribute('pattern');
|
||||
document.getElementById('token').setAttribute('maxlength', '8');
|
||||
}
|
||||
});
|
||||
|
||||
// Reset main field when typing in it
|
||||
document.getElementById('token').addEventListener('focus', function(e) {
|
||||
document.getElementById('backup-token').value = '';
|
||||
e.target.setAttribute('pattern', '[0-9]{6}');
|
||||
e.target.setAttribute('maxlength', '6');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test-Skript zur Verifizierung aller Blueprint-Routes
|
||||
Prüft ob alle Routes korrekt registriert sind und erreichbar sind
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app import app
|
||||
from flask import url_for
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def test_all_routes():
|
||||
"""Test alle registrierten Routes"""
|
||||
|
||||
print("=== Blueprint Route Test ===\n")
|
||||
|
||||
# Sammle alle Routes
|
||||
routes_by_blueprint = {}
|
||||
|
||||
with app.test_request_context():
|
||||
for rule in app.url_map.iter_rules():
|
||||
# Skip static files
|
||||
if rule.endpoint == 'static':
|
||||
continue
|
||||
|
||||
# Blueprint name is before the dot
|
||||
parts = rule.endpoint.split('.')
|
||||
if len(parts) == 2:
|
||||
blueprint_name = parts[0]
|
||||
function_name = parts[1]
|
||||
else:
|
||||
blueprint_name = 'app'
|
||||
function_name = rule.endpoint
|
||||
|
||||
if blueprint_name not in routes_by_blueprint:
|
||||
routes_by_blueprint[blueprint_name] = []
|
||||
|
||||
routes_by_blueprint[blueprint_name].append({
|
||||
'rule': str(rule),
|
||||
'endpoint': rule.endpoint,
|
||||
'methods': sorted(rule.methods - {'HEAD', 'OPTIONS'}),
|
||||
'function': function_name
|
||||
})
|
||||
|
||||
# Sortiere und zeige Routes nach Blueprint
|
||||
for blueprint_name in sorted(routes_by_blueprint.keys()):
|
||||
routes = routes_by_blueprint[blueprint_name]
|
||||
print(f"\n📦 Blueprint: {blueprint_name}")
|
||||
print(f" Anzahl Routes: {len(routes)}")
|
||||
print(" " + "-" * 50)
|
||||
|
||||
for route in sorted(routes, key=lambda x: x['rule']):
|
||||
methods = ', '.join(route['methods'])
|
||||
print(f" {route['rule']:<40} [{methods:<15}] -> {route['function']}")
|
||||
|
||||
# Zusammenfassung
|
||||
print("\n=== Zusammenfassung ===")
|
||||
total_routes = sum(len(routes) for routes in routes_by_blueprint.values())
|
||||
print(f"Gesamt Blueprints: {len(routes_by_blueprint)}")
|
||||
print(f"Gesamt Routes: {total_routes}")
|
||||
|
||||
# Erwartete Blueprints prüfen
|
||||
expected_blueprints = ['auth', 'admin', 'license', 'customer', 'resource',
|
||||
'session', 'batch', 'api', 'export']
|
||||
|
||||
print("\n=== Blueprint Status ===")
|
||||
for bp in expected_blueprints:
|
||||
if bp in routes_by_blueprint:
|
||||
print(f"✅ {bp:<10} - {len(routes_by_blueprint[bp])} routes")
|
||||
else:
|
||||
print(f"❌ {bp:<10} - FEHLT!")
|
||||
|
||||
# Prüfe ob noch Routes direkt in app.py sind
|
||||
if 'app' in routes_by_blueprint:
|
||||
print(f"\n⚠️ WARNUNG: {len(routes_by_blueprint['app'])} Routes sind noch direkt in app.py!")
|
||||
for route in routes_by_blueprint['app']:
|
||||
print(f" - {route['rule']}")
|
||||
|
||||
def test_route_accessibility():
|
||||
"""Test ob wichtige Routes erreichbar sind"""
|
||||
print("\n\n=== Route Erreichbarkeits-Test ===\n")
|
||||
|
||||
test_client = app.test_client()
|
||||
|
||||
# Wichtige Routes zum Testen (ohne Login)
|
||||
public_routes = [
|
||||
('GET', '/login', 'Login-Seite'),
|
||||
('GET', '/heartbeat', 'Session Heartbeat'),
|
||||
]
|
||||
|
||||
for method, route, description in public_routes:
|
||||
try:
|
||||
if method == 'GET':
|
||||
response = test_client.get(route)
|
||||
elif method == 'POST':
|
||||
response = test_client.post(route)
|
||||
|
||||
status = "✅" if response.status_code in [200, 302, 401] else "❌"
|
||||
print(f"{status} {method:<6} {route:<30} - Status: {response.status_code} ({description})")
|
||||
|
||||
# Bei Fehler mehr Details
|
||||
if response.status_code >= 400 and response.status_code != 401:
|
||||
print(f" ⚠️ Fehler-Details: {response.data[:200]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ {method:<6} {route:<30} - FEHLER: {str(e)}")
|
||||
|
||||
def check_duplicate_routes():
|
||||
"""Prüfe ob es doppelte Route-Definitionen gibt"""
|
||||
print("\n\n=== Doppelte Routes Check ===\n")
|
||||
|
||||
route_paths = {}
|
||||
duplicates_found = False
|
||||
|
||||
for rule in app.url_map.iter_rules():
|
||||
if rule.endpoint == 'static':
|
||||
continue
|
||||
|
||||
path = str(rule)
|
||||
if path in route_paths:
|
||||
print(f"⚠️ DUPLIKAT gefunden:")
|
||||
print(f" Route: {path}")
|
||||
print(f" 1. Endpoint: {route_paths[path]}")
|
||||
print(f" 2. Endpoint: {rule.endpoint}")
|
||||
duplicates_found = True
|
||||
else:
|
||||
route_paths[path] = rule.endpoint
|
||||
|
||||
if not duplicates_found:
|
||||
print("✅ Keine doppelten Routes gefunden!")
|
||||
|
||||
def check_template_references():
|
||||
"""Prüfe ob Template-Dateien für die Routes existieren"""
|
||||
print("\n\n=== Template Verfügbarkeits-Check ===\n")
|
||||
|
||||
template_dir = os.path.join(os.path.dirname(__file__), 'templates')
|
||||
|
||||
# Sammle alle verfügbaren Templates
|
||||
available_templates = []
|
||||
if os.path.exists(template_dir):
|
||||
for root, dirs, files in os.walk(template_dir):
|
||||
for file in files:
|
||||
if file.endswith(('.html', '.jinja2')):
|
||||
rel_path = os.path.relpath(os.path.join(root, file), template_dir)
|
||||
available_templates.append(rel_path.replace('\\', '/'))
|
||||
|
||||
print(f"Gefundene Templates: {len(available_templates)}")
|
||||
|
||||
# Wichtige Templates prüfen
|
||||
required_templates = [
|
||||
'login.html',
|
||||
'index.html',
|
||||
'profile.html',
|
||||
'licenses.html',
|
||||
'customers.html',
|
||||
'resources.html',
|
||||
'sessions.html',
|
||||
'audit.html',
|
||||
'backups.html'
|
||||
]
|
||||
|
||||
for template in required_templates:
|
||||
if template in available_templates:
|
||||
print(f"✅ {template}")
|
||||
else:
|
||||
print(f"❌ {template} - FEHLT!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🔍 Starte Blueprint-Verifizierung...\n")
|
||||
|
||||
try:
|
||||
test_all_routes()
|
||||
test_route_accessibility()
|
||||
check_duplicate_routes()
|
||||
check_template_references()
|
||||
|
||||
print("\n\n✅ Test abgeschlossen!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n\n❌ Fehler beim Test: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test if blueprints can be imported successfully"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
try:
|
||||
from routes.auth_routes import auth_bp
|
||||
print("✓ auth_routes blueprint imported successfully")
|
||||
print(f" Routes: {[str(r) for r in auth_bp.url_values_defaults]}")
|
||||
except Exception as e:
|
||||
print(f"✗ Error importing auth_routes: {e}")
|
||||
|
||||
try:
|
||||
from routes.admin_routes import admin_bp
|
||||
print("✓ admin_routes blueprint imported successfully")
|
||||
except Exception as e:
|
||||
print(f"✗ Error importing admin_routes: {e}")
|
||||
|
||||
print("\nBlueprints are ready to use!")
|
||||
@ -0,0 +1 @@
|
||||
# Utils module initialization
|
||||
@ -0,0 +1,37 @@
|
||||
import logging
|
||||
from flask import session, request
|
||||
from psycopg2.extras import Json
|
||||
from db import get_db_connection, get_db_cursor
|
||||
from utils.network import get_client_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None):
|
||||
"""Log changes to the audit log"""
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
try:
|
||||
username = session.get('username', 'system')
|
||||
ip_address = get_client_ip() if request else None
|
||||
user_agent = request.headers.get('User-Agent') if request else None
|
||||
|
||||
# Debug logging
|
||||
logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}")
|
||||
|
||||
# Convert dictionaries to JSONB
|
||||
old_json = Json(old_values) if old_values else None
|
||||
new_json = Json(new_values) if new_values else None
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO audit_log
|
||||
(username, action, entity_type, entity_id, old_values, new_values,
|
||||
ip_address, user_agent, additional_info)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (username, action, entity_type, entity_id, old_json, new_json,
|
||||
ip_address, user_agent, additional_info))
|
||||
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Audit log error: {e}")
|
||||
conn.rollback()
|
||||
223
backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/backup.py
Normale Datei
223
backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/backup.py
Normale Datei
@ -0,0 +1,223 @@
|
||||
import os
|
||||
import time
|
||||
import gzip
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from cryptography.fernet import Fernet
|
||||
from db import get_db_connection, get_db_cursor
|
||||
from config import BACKUP_DIR, DATABASE_CONFIG, EMAIL_ENABLED, BACKUP_ENCRYPTION_KEY
|
||||
from utils.audit import log_audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_or_create_encryption_key():
|
||||
"""Get or create an encryption key"""
|
||||
key_file = BACKUP_DIR / ".backup_key"
|
||||
|
||||
# Try to read key from environment variable
|
||||
if BACKUP_ENCRYPTION_KEY:
|
||||
try:
|
||||
# Validate the key
|
||||
Fernet(BACKUP_ENCRYPTION_KEY.encode())
|
||||
return BACKUP_ENCRYPTION_KEY.encode()
|
||||
except:
|
||||
pass
|
||||
|
||||
# If no valid key in ENV, check file
|
||||
if key_file.exists():
|
||||
return key_file.read_bytes()
|
||||
|
||||
# Create new key
|
||||
key = Fernet.generate_key()
|
||||
key_file.write_bytes(key)
|
||||
logger.info("New backup encryption key created")
|
||||
return key
|
||||
|
||||
|
||||
def create_backup(backup_type="manual", created_by=None):
|
||||
"""Create an encrypted database backup"""
|
||||
start_time = time.time()
|
||||
timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc"
|
||||
filepath = BACKUP_DIR / filename
|
||||
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
# Create backup entry
|
||||
cur.execute("""
|
||||
INSERT INTO backup_history
|
||||
(filename, filepath, backup_type, status, created_by, is_encrypted)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (filename, str(filepath), backup_type, 'in_progress',
|
||||
created_by or 'system', True))
|
||||
backup_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
# PostgreSQL dump command
|
||||
dump_command = [
|
||||
'pg_dump',
|
||||
'-h', DATABASE_CONFIG['host'],
|
||||
'-p', DATABASE_CONFIG['port'],
|
||||
'-U', DATABASE_CONFIG['user'],
|
||||
'-d', DATABASE_CONFIG['dbname'],
|
||||
'--no-password',
|
||||
'--verbose'
|
||||
]
|
||||
|
||||
# Set PGPASSWORD
|
||||
env = os.environ.copy()
|
||||
env['PGPASSWORD'] = DATABASE_CONFIG['password']
|
||||
|
||||
# Execute dump
|
||||
result = subprocess.run(dump_command, capture_output=True, text=True, env=env)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"pg_dump failed: {result.stderr}")
|
||||
|
||||
dump_data = result.stdout.encode('utf-8')
|
||||
|
||||
# Compress
|
||||
compressed_data = gzip.compress(dump_data)
|
||||
|
||||
# Encrypt
|
||||
key = get_or_create_encryption_key()
|
||||
f = Fernet(key)
|
||||
encrypted_data = f.encrypt(compressed_data)
|
||||
|
||||
# Save
|
||||
filepath.write_bytes(encrypted_data)
|
||||
|
||||
# Collect statistics
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'")
|
||||
tables_count = cur.fetchone()[0]
|
||||
|
||||
cur.execute("SELECT SUM(n_live_tup) FROM pg_stat_user_tables")
|
||||
records_count = cur.fetchone()[0] or 0
|
||||
|
||||
duration = time.time() - start_time
|
||||
filesize = filepath.stat().st_size
|
||||
|
||||
# Update backup entry
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("""
|
||||
UPDATE backup_history
|
||||
SET status = %s, filesize = %s, tables_count = %s,
|
||||
records_count = %s, duration_seconds = %s
|
||||
WHERE id = %s
|
||||
""", ('success', filesize, tables_count, records_count, duration, backup_id))
|
||||
conn.commit()
|
||||
|
||||
# Audit log
|
||||
log_audit('BACKUP', 'database', backup_id,
|
||||
additional_info=f"Backup created: {filename} ({filesize} bytes)")
|
||||
|
||||
# Email notification (if configured)
|
||||
send_backup_notification(True, filename, filesize, duration)
|
||||
|
||||
logger.info(f"Backup successfully created: {filename}")
|
||||
return True, filename
|
||||
|
||||
except Exception as e:
|
||||
# Log error
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
cur.execute("""
|
||||
UPDATE backup_history
|
||||
SET status = %s, error_message = %s, duration_seconds = %s
|
||||
WHERE id = %s
|
||||
""", ('failed', str(e), time.time() - start_time, backup_id))
|
||||
conn.commit()
|
||||
|
||||
logger.error(f"Backup failed: {e}")
|
||||
send_backup_notification(False, filename, error=str(e))
|
||||
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def restore_backup(backup_id, encryption_key=None):
|
||||
"""Restore a backup"""
|
||||
with get_db_connection() as conn:
|
||||
with get_db_cursor(conn) as cur:
|
||||
# Get backup info
|
||||
cur.execute("""
|
||||
SELECT filename, filepath, is_encrypted
|
||||
FROM backup_history
|
||||
WHERE id = %s
|
||||
""", (backup_id,))
|
||||
backup_info = cur.fetchone()
|
||||
|
||||
if not backup_info:
|
||||
raise Exception("Backup not found")
|
||||
|
||||
filename, filepath, is_encrypted = backup_info
|
||||
filepath = Path(filepath)
|
||||
|
||||
if not filepath.exists():
|
||||
raise Exception("Backup file not found")
|
||||
|
||||
try:
|
||||
# Read file
|
||||
encrypted_data = filepath.read_bytes()
|
||||
|
||||
# Decrypt
|
||||
if is_encrypted:
|
||||
key = encryption_key.encode() if encryption_key else get_or_create_encryption_key()
|
||||
try:
|
||||
f = Fernet(key)
|
||||
compressed_data = f.decrypt(encrypted_data)
|
||||
except:
|
||||
raise Exception("Decryption failed. Wrong password?")
|
||||
else:
|
||||
compressed_data = encrypted_data
|
||||
|
||||
# Decompress
|
||||
dump_data = gzip.decompress(compressed_data)
|
||||
sql_commands = dump_data.decode('utf-8')
|
||||
|
||||
# Restore database
|
||||
restore_command = [
|
||||
'psql',
|
||||
'-h', DATABASE_CONFIG['host'],
|
||||
'-p', DATABASE_CONFIG['port'],
|
||||
'-U', DATABASE_CONFIG['user'],
|
||||
'-d', DATABASE_CONFIG['dbname'],
|
||||
'--no-password'
|
||||
]
|
||||
|
||||
env = os.environ.copy()
|
||||
env['PGPASSWORD'] = DATABASE_CONFIG['password']
|
||||
|
||||
result = subprocess.run(restore_command, input=sql_commands,
|
||||
capture_output=True, text=True, env=env)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"Restore failed: {result.stderr}")
|
||||
|
||||
# Audit log
|
||||
log_audit('RESTORE', 'database', backup_id,
|
||||
additional_info=f"Backup restored: {filename}")
|
||||
|
||||
return True, "Backup successfully restored"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Restore failed: {e}")
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def send_backup_notification(success, filename, filesize=None, duration=None, error=None):
|
||||
"""Send email notification (if configured)"""
|
||||
if not EMAIL_ENABLED:
|
||||
return
|
||||
|
||||
# Email function prepared but disabled
|
||||
# TODO: Implement when email server is configured
|
||||
logger.info(f"Email notification prepared: Backup {'successful' if success else 'failed'}")
|
||||
127
backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/export.py
Normale Datei
127
backups/refactoring_20250616_223724/v2_adminpanel_backup/utils/export.py
Normale Datei
@ -0,0 +1,127 @@
|
||||
import pandas as pd
|
||||
from io import BytesIO
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from openpyxl.utils import get_column_letter
|
||||
from flask import send_file
|
||||
|
||||
|
||||
def create_excel_export(data, columns, filename_prefix="export"):
|
||||
"""Create an Excel file from data"""
|
||||
df = pd.DataFrame(data, columns=columns)
|
||||
|
||||
# Create Excel file in memory
|
||||
output = BytesIO()
|
||||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||||
df.to_excel(writer, index=False, sheet_name='Data')
|
||||
|
||||
# Auto-adjust column widths
|
||||
worksheet = writer.sheets['Data']
|
||||
for idx, col in enumerate(df.columns):
|
||||
max_length = max(df[col].astype(str).map(len).max(), len(col)) + 2
|
||||
worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50)
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Generate filename with timestamp
|
||||
timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"{filename_prefix}_{timestamp}.xlsx"
|
||||
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
|
||||
def format_datetime_for_export(dt):
|
||||
"""Format datetime for export"""
|
||||
if dt:
|
||||
if isinstance(dt, str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt)
|
||||
except:
|
||||
return dt
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
return ''
|
||||
|
||||
|
||||
def prepare_license_export_data(licenses):
|
||||
"""Prepare license data for export"""
|
||||
export_data = []
|
||||
for license in licenses:
|
||||
export_data.append([
|
||||
license[0], # ID
|
||||
license[1], # Key
|
||||
license[2], # Customer Name
|
||||
license[3], # Email
|
||||
'Aktiv' if license[4] else 'Inaktiv', # Active
|
||||
license[5], # Max Users
|
||||
format_datetime_for_export(license[6]), # Valid From
|
||||
format_datetime_for_export(license[7]), # Valid Until
|
||||
format_datetime_for_export(license[8]), # Created At
|
||||
license[9], # Device Limit
|
||||
license[10] or 0, # Current Devices
|
||||
'Test' if license[11] else 'Full' # Is Test License
|
||||
])
|
||||
return export_data
|
||||
|
||||
|
||||
def prepare_customer_export_data(customers):
|
||||
"""Prepare customer data for export"""
|
||||
export_data = []
|
||||
for customer in customers:
|
||||
export_data.append([
|
||||
customer[0], # ID
|
||||
customer[1], # Name
|
||||
customer[2], # Email
|
||||
customer[3], # Company
|
||||
customer[4], # Address
|
||||
customer[5], # Phone
|
||||
format_datetime_for_export(customer[6]), # Created At
|
||||
customer[7] or 0, # License Count
|
||||
customer[8] or 0 # Active License Count
|
||||
])
|
||||
return export_data
|
||||
|
||||
|
||||
def prepare_session_export_data(sessions):
|
||||
"""Prepare session data for export"""
|
||||
export_data = []
|
||||
for session in sessions:
|
||||
export_data.append([
|
||||
session[0], # ID
|
||||
session[1], # License Key
|
||||
session[2], # Username
|
||||
session[3], # Computer Name
|
||||
session[4], # Hardware ID
|
||||
format_datetime_for_export(session[5]), # Login Time
|
||||
format_datetime_for_export(session[6]), # Last Activity
|
||||
'Aktiv' if session[7] else 'Beendet', # Active
|
||||
session[8], # IP Address
|
||||
session[9], # App Version
|
||||
session[10], # Customer Name
|
||||
session[11] # Email
|
||||
])
|
||||
return export_data
|
||||
|
||||
|
||||
def prepare_audit_export_data(audit_logs):
|
||||
"""Prepare audit log data for export"""
|
||||
export_data = []
|
||||
for log in audit_logs:
|
||||
export_data.append([
|
||||
log['id'],
|
||||
format_datetime_for_export(log['timestamp']),
|
||||
log['username'],
|
||||
log['action'],
|
||||
log['entity_type'],
|
||||
log['entity_id'] or '',
|
||||
log['ip_address'] or '',
|
||||
log['user_agent'] or '',
|
||||
str(log['old_values']) if log['old_values'] else '',
|
||||
str(log['new_values']) if log['new_values'] else '',
|
||||
log['additional_info'] or ''
|
||||
])
|
||||
return export_data
|
||||
@ -0,0 +1,50 @@
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
def generate_license_key(license_type='full'):
|
||||
"""
|
||||
Generate a license key in format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ
|
||||
|
||||
AF = Account Factory (Product identifier)
|
||||
F/T = F for Full version, T for Test version
|
||||
YYYY = Year
|
||||
MM = Month
|
||||
XXXX-YYYY-ZZZZ = Random alphanumeric characters
|
||||
"""
|
||||
# Allowed characters (without confusing ones like 0/O, 1/I/l)
|
||||
chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
|
||||
# Date part
|
||||
now = datetime.now(ZoneInfo("Europe/Berlin"))
|
||||
date_part = now.strftime('%Y%m')
|
||||
type_char = 'F' if license_type == 'full' else 'T'
|
||||
|
||||
# Generate random parts (3 blocks of 4 characters)
|
||||
parts = []
|
||||
for _ in range(3):
|
||||
part = ''.join(secrets.choice(chars) for _ in range(4))
|
||||
parts.append(part)
|
||||
|
||||
# Assemble key
|
||||
key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}"
|
||||
|
||||
return key
|
||||
|
||||
|
||||
def validate_license_key(key):
|
||||
"""
|
||||
Validate the License Key Format
|
||||
Expected: AF-F-YYYYMM-XXXX-YYYY-ZZZZ or AF-T-YYYYMM-XXXX-YYYY-ZZZZ
|
||||
"""
|
||||
if not key:
|
||||
return False
|
||||
|
||||
# Pattern for the new format
|
||||
# AF- (fixed) + F or T + - + 6 digits (YYYYMM) + - + 4 characters + - + 4 characters + - + 4 characters
|
||||
pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||||
|
||||
# Uppercase for comparison
|
||||
return bool(re.match(pattern, key.upper()))
|
||||
@ -0,0 +1,23 @@
|
||||
import logging
|
||||
from flask import request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_client_ip():
|
||||
"""Get the real IP address of the client"""
|
||||
# Debug logging
|
||||
logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, "
|
||||
f"X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, "
|
||||
f"Remote-Addr: {request.remote_addr}")
|
||||
|
||||
# Try X-Real-IP first (set by nginx)
|
||||
if request.headers.get('X-Real-IP'):
|
||||
return request.headers.get('X-Real-IP')
|
||||
# Then X-Forwarded-For
|
||||
elif request.headers.get('X-Forwarded-For'):
|
||||
# X-Forwarded-For can contain multiple IPs, take the first one
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
# Fallback to remote_addr
|
||||
else:
|
||||
return request.remote_addr
|
||||
@ -0,0 +1,39 @@
|
||||
import logging
|
||||
import requests
|
||||
import config
|
||||
|
||||
|
||||
def verify_recaptcha(response):
|
||||
"""Verifiziert die reCAPTCHA v2 Response mit Google"""
|
||||
secret_key = config.RECAPTCHA_SECRET_KEY
|
||||
|
||||
# Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC)
|
||||
if not secret_key:
|
||||
logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen")
|
||||
return True
|
||||
|
||||
# Verifizierung bei Google
|
||||
try:
|
||||
verify_url = 'https://www.google.com/recaptcha/api/siteverify'
|
||||
data = {
|
||||
'secret': secret_key,
|
||||
'response': response
|
||||
}
|
||||
|
||||
# Timeout für Request setzen
|
||||
r = requests.post(verify_url, data=data, timeout=5)
|
||||
result = r.json()
|
||||
|
||||
# Log für Debugging
|
||||
if not result.get('success'):
|
||||
logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}")
|
||||
|
||||
return result.get('success', False)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}")
|
||||
# Bei Netzwerkfehlern CAPTCHA als bestanden werten
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}")
|
||||
return False
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren